From fee73f241859bc5b911fc4f9692007489c4732ad Mon Sep 17 00:00:00 2001 From: dotta Date: Wed, 18 Mar 2026 07:21:10 -0500 Subject: [PATCH] Add manual Docker release smoke workflow --- .agents/skills/release-changelog/SKILL.md | 36 +- .agents/skills/release/SKILL.md | 238 +++--- .github/workflows/release-smoke.yml | 118 +++ .gitignore | 2 + doc/DOCKER.md | 2 + doc/RELEASING.md | 17 +- .../2026-03-17-docker-release-browser-e2e.md | 424 ++++++++++ package.json | 4 +- pnpm-lock.yaml | 785 +++++++++++++++++- releases/v2026.3.17.md | 65 ++ scripts/docker-onboard-smoke.sh | 62 +- tests/e2e/onboarding.spec.ts | 42 +- tests/e2e/playwright.config.ts | 2 +- .../docker-auth-onboarding.spec.ts | 146 ++++ tests/release-smoke/playwright.config.ts | 28 + ui/src/App.tsx | 50 +- ui/src/components/OnboardingWizard.tsx | 63 +- ui/src/lib/onboarding-route.test.ts | 80 ++ ui/src/lib/onboarding-route.ts | 51 ++ 19 files changed, 1974 insertions(+), 241 deletions(-) create mode 100644 .github/workflows/release-smoke.yml create mode 100644 doc/plans/2026-03-17-docker-release-browser-e2e.md create mode 100644 releases/v2026.3.17.md create mode 100644 tests/release-smoke/docker-auth-onboarding.spec.ts create mode 100644 tests/release-smoke/playwright.config.ts create mode 100644 ui/src/lib/onboarding-route.test.ts create mode 100644 ui/src/lib/onboarding-route.ts diff --git a/.agents/skills/release-changelog/SKILL.md b/.agents/skills/release-changelog/SKILL.md index 4b1cdba0..0debdb3e 100644 --- a/.agents/skills/release-changelog/SKILL.md +++ b/.agents/skills/release-changelog/SKILL.md @@ -1,7 +1,7 @@ --- name: release-changelog description: > - Generate the stable Paperclip release changelog at releases/v{version}.md by + Generate the stable Paperclip release changelog at releases/vYYYY.M.D.md by reading commits, changesets, and merged PR context since the last stable tag. --- @@ -9,20 +9,33 @@ description: > Generate the user-facing changelog for the **stable** Paperclip release. +## Versioning Model + +Paperclip uses **calendar versioning (calver)**: + +- Stable releases: `YYYY.M.D` (e.g. `2026.3.17`) +- Canary releases: `YYYY.M.D-canary.N` (e.g. `2026.3.17-canary.0`) +- Git tags: `vYYYY.M.D` for stable, `canary/vYYYY.M.D-canary.N` for canary + +There are no major/minor/patch bumps. The stable version is derived from the +intended release date (UTC). + Output: -- `releases/v{version}.md` +- `releases/vYYYY.M.D.md` -Important rule: +Important rules: -- even if there are canary releases such as `1.2.3-canary.0`, the changelog file stays `releases/v1.2.3.md` +- even if there are canary releases such as `2026.3.17-canary.0`, the changelog file stays `releases/v2026.3.17.md` +- do not derive versions from semver bump types +- do not create canary changelog files ## Step 0 — Idempotency Check Before generating anything, check whether the file already exists: ```bash -ls releases/v{version}.md 2>/dev/null +ls releases/vYYYY.M.D.md 2>/dev/null ``` If it exists: @@ -41,13 +54,14 @@ git tag --list 'v*' --sort=-version:refname | head -1 git log v{last}..HEAD --oneline --no-merges ``` -The planned stable version comes from one of: +The stable version comes from one of: - an explicit maintainer request -- the chosen bump type applied to the last stable tag +- the intended release date (UTC) — default is today's date - the release plan already agreed in `doc/RELEASING.md` Do not derive the changelog version from a canary tag or prerelease suffix. +Do not derive major/minor/patch bumps — calver uses the date. ## Step 2 — Gather the Raw Inputs @@ -73,7 +87,6 @@ Look for: - destructive migrations - removed or changed API fields/endpoints - renamed or removed config keys -- `major` changesets - `BREAKING:` or `BREAKING CHANGE:` commit signals Key commands: @@ -85,7 +98,8 @@ git diff v{last}..HEAD -- server/src/routes/ server/src/api/ git log v{last}..HEAD --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true ``` -If the requested bump is lower than the minimum required bump, flag that before the release proceeds. +If breaking changes are detected, flag them prominently — they must appear in the +Breaking Changes section with an upgrade path. ## Step 4 — Categorize for Users @@ -130,9 +144,9 @@ Rules: Template: ```markdown -# v{version} +# vYYYY.M.D -> Released: {YYYY-MM-DD} +> Released: YYYY-MM-DD ## Breaking Changes diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index 2eac6ad8..61511878 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -2,23 +2,21 @@ name: release description: > Coordinate a full Paperclip release across engineering verification, npm, - GitHub, website publishing, and announcement follow-up. Use when leadership - asks to ship a release, not merely to discuss version bumps. + GitHub, smoke testing, and announcement follow-up. Use when leadership asks + to ship a release, not merely to discuss versioning. --- # Release Coordination Skill -Run the full Paperclip release as a maintainer workflow, not just an npm publish. +Run the full Paperclip maintainer release workflow, not just an npm publish. This skill coordinates: - stable changelog drafting via `release-changelog` -- release-train setup via `scripts/release-start.sh` -- prerelease canary publishing via `scripts/release.sh --canary` +- canary verification and publish status from `master` - Docker smoke testing via `scripts/docker-onboard-smoke.sh` -- stable publishing via `scripts/release.sh` -- pushing the stable branch commit and tag -- GitHub Release creation via `scripts/create-github-release.sh` +- manual stable promotion from a chosen source ref +- GitHub Release creation - website / announcement follow-up tasks ## Trigger @@ -26,8 +24,9 @@ This skill coordinates: Use this skill when leadership asks for: - "do a release" -- "ship the next patch/minor/major" -- "release vX.Y.Z" +- "ship the release" +- "promote this canary to stable" +- "cut the stable release" ## Preconditions @@ -35,10 +34,10 @@ Before proceeding, verify all of the following: 1. `.agents/skills/release-changelog/SKILL.md` exists and is usable. 2. The repo working tree is clean, including untracked files. -3. There are commits since the last stable tag. -4. The release SHA has passed the verification gate or is about to. -5. If package manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master` before the release branch is cut. -6. npm publish rights are available locally, or the GitHub release workflow is being used with trusted publishing. +3. There is at least one canary or candidate commit since the last stable tag. +4. The candidate SHA has passed the verification gate or is about to. +5. If manifests changed, the CI-owned `pnpm-lock.yaml` refresh is already merged on `master`. +6. npm publish rights are available through GitHub trusted publishing, or through local npm auth for emergency/manual use. 7. If running through Paperclip, you have issue context for status updates and follow-up task creation. If any precondition fails, stop and report the blocker. @@ -47,78 +46,65 @@ If any precondition fails, stop and report the blocker. Collect these inputs up front: -- requested bump: `patch`, `minor`, or `major` -- whether this run is a dry run or live release -- whether the release is being run locally or from GitHub Actions +- whether the target is a canary check or a stable promotion +- the candidate `source_ref` for stable +- whether the stable run is dry-run or live - release issue / company context for website and announcement follow-up ## Step 0 — Release Model -Paperclip now uses this release model: +Paperclip now uses a commit-driven release model: -1. Start or resume `release/X.Y.Z` -2. Draft the **stable** changelog as `releases/vX.Y.Z.md` -3. Publish one or more **prerelease canaries** such as `X.Y.Z-canary.0` -4. Smoke test the canary via Docker -5. Publish the stable version `X.Y.Z` -6. Push the stable branch commit and tag -7. Create the GitHub Release -8. Merge `release/X.Y.Z` back to `master` without squash or rebase -9. Complete website and announcement surfaces +1. every push to `master` publishes a canary automatically +2. canaries use `YYYY.M.D-canary.N` +3. stable releases use `YYYY.M.D` +4. stable releases are manually promoted from a chosen tested commit or canary source commit +5. only stable releases get `releases/vYYYY.M.D.md`, git tag `vYYYY.M.D`, and a GitHub Release -Critical consequence: +Critical consequences: -- Canaries do **not** use promote-by-dist-tag anymore. -- The changelog remains stable-only. Do not create `releases/vX.Y.Z-canary.N.md`. +- do not use release branches as the default path +- do not derive major/minor/patch bumps +- do not create canary changelog files +- do not create canary GitHub Releases -## Step 1 — Decide the Stable Version +## Step 1 — Choose the Candidate -Start the release train first: +For canary validation: + +- inspect the latest successful canary run on `master` +- record the canary version and source SHA + +For stable promotion: + +1. choose the tested source ref +2. confirm it is the exact SHA you want to promote +3. derive the stable version from the intended UTC date + +Useful commands: ```bash -./scripts/release-start.sh {patch|minor|major} +git tag --list 'v*' --sort=-version:refname | head -1 +git log --oneline --no-merges +npm view paperclipai@canary version ``` -Then run release preflight: - -```bash -./scripts/release-preflight.sh canary {patch|minor|major} -# or -./scripts/release-preflight.sh stable {patch|minor|major} -``` - -Then use the last stable tag as the base: - -```bash -LAST_TAG=$(git tag --list 'v*' --sort=-version:refname | head -1) -git log "${LAST_TAG}..HEAD" --oneline --no-merges -git diff --name-only "${LAST_TAG}..HEAD" -- packages/db/src/migrations/ -git diff "${LAST_TAG}..HEAD" -- packages/db/src/schema/ -git log "${LAST_TAG}..HEAD" --format="%s" | rg -n 'BREAKING CHANGE|BREAKING:|^[a-z]+!:' || true -``` - -Bump policy: - -- destructive migrations, removed APIs, breaking config changes -> `major` -- additive migrations or clearly user-visible features -> at least `minor` -- fixes only -> `patch` - -If the requested bump is too low, escalate it and explain why. - ## Step 2 — Draft the Stable Changelog -Invoke `release-changelog` and generate: +Stable changelog files live at: -- `releases/vX.Y.Z.md` +- `releases/vYYYY.M.D.md` + +Invoke `release-changelog` and generate or update the stable notes only. Rules: - review the draft with a human before publish - preserve manual edits if the file already exists -- keep the heading and filename stable-only, for example `v1.2.3` -- do not create a separate canary changelog file +- keep the filename stable-only +- do not create a canary changelog file -## Step 3 — Verify the Release SHA +## Step 3 — Verify the Candidate SHA Run the standard gate: @@ -128,41 +114,27 @@ pnpm test:run pnpm build ``` -If the release will be run through GitHub Actions, the workflow can rerun this gate. Still report whether the local tree currently passes. +If the GitHub release workflow will run the publish, it can rerun this gate. Still report local status if you checked it. -The GitHub Actions release workflow installs with `pnpm install --frozen-lockfile`. Treat that as a release invariant, not a nuisance: if manifests changed and the lockfile refresh PR has not landed yet, stop and wait for `master` to contain the committed lockfile before shipping. +For PRs that touch release logic, the repo also runs a canary release dry-run in CI. That is a release-specific guard, not a substitute for the standard gate. -## Step 4 — Publish a Canary +## Step 4 — Validate the Canary -Run from the `release/X.Y.Z` branch: +The normal canary path is automatic from `master` via: -```bash -./scripts/release.sh {patch|minor|major} --canary --dry-run -./scripts/release.sh {patch|minor|major} --canary -``` +- `.github/workflows/release.yml` -What this means: +Confirm: -- npm receives `X.Y.Z-canary.N` under dist-tag `canary` -- `latest` remains unchanged -- no git tag is created -- the script cleans the working tree afterward +1. verification passed +2. npm canary publish succeeded +3. git tag `canary/vYYYY.M.D-canary.N` exists -Guard: - -- if the current stable is `0.2.7`, the next patch canary is `0.2.8-canary.0` -- the tooling must never publish `0.2.7-canary.N` after `0.2.7` is already stable - -After publish, verify: +Useful checks: ```bash npm view paperclipai@canary version -``` - -The user install path is: - -```bash -npx paperclipai@canary onboard +git tag --list 'canary/v*' --sort=-version:refname | head -5 ``` ## Step 5 — Smoke Test the Canary @@ -173,60 +145,68 @@ Run: PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh ``` +Useful isolated variant: + +```bash +HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh +``` + Confirm: 1. install succeeds -2. onboarding completes -3. server boots -4. UI loads -5. basic company/dashboard flow works +2. onboarding completes without crashes +3. the server boots +4. the UI loads +5. basic company creation and dashboard load work If smoke testing fails: - stop the stable release -- fix the issue -- publish another canary -- repeat the smoke test +- fix the issue on `master` +- wait for the next automatic canary +- rerun smoke testing -Each retry should create a higher canary ordinal, while the stable target version can stay the same. +## Step 6 — Preview or Publish Stable -## Step 6 — Publish Stable +The normal stable path is manual `workflow_dispatch` on: -Once the SHA is vetted, run: +- `.github/workflows/release.yml` + +Inputs: + +- `source_ref` +- `stable_date` +- `dry_run` + +Before live stable: + +1. ensure `releases/vYYYY.M.D.md` exists on the source ref +2. run the stable workflow in dry-run mode first when practical +3. then run the real stable publish + +The stable workflow: + +- re-verifies the exact source ref +- publishes `YYYY.M.D` under dist-tag `latest` +- creates git tag `vYYYY.M.D` +- creates or updates the GitHub Release from `releases/vYYYY.M.D.md` + +Local emergency/manual commands: ```bash -./scripts/release.sh {patch|minor|major} --dry-run -./scripts/release.sh {patch|minor|major} +./scripts/release.sh stable --dry-run +./scripts/release.sh stable +git push public-gh refs/tags/vYYYY.M.D +./scripts/create-github-release.sh YYYY.M.D ``` -Stable publish does this: - -- publishes `X.Y.Z` to npm under `latest` -- creates the local release commit -- creates the local git tag `vX.Y.Z` - -Stable publish does **not** push the release for you. - -## Step 7 — Push and Create GitHub Release - -After stable publish succeeds: - -```bash -git push public-gh HEAD --follow-tags -./scripts/create-github-release.sh X.Y.Z -``` - -Use the stable changelog file as the GitHub Release notes source. - -Then open the PR from `release/X.Y.Z` back to `master` and merge without squash or rebase. - -## Step 8 — Finish the Other Surfaces +## Step 7 — Finish the Other Surfaces Create or verify follow-up work for: - website changelog publishing - launch post / social announcement -- any release summary in Paperclip issue context +- release summary in Paperclip issue context These should reference the stable release, not the canary. @@ -236,9 +216,9 @@ If the canary is bad: - publish another canary, do not ship stable -If stable npm publish succeeds but push or GitHub release creation fails: +If stable npm publish succeeds but tag push or GitHub release creation fails: -- fix the git/GitHub issue immediately from the same checkout +- fix the git/GitHub issue immediately from the same release result - do not republish the same version If `latest` is bad after stable publish: @@ -247,15 +227,17 @@ If `latest` is bad after stable publish: ./scripts/rollback-latest.sh ``` -Then fix forward with a new patch release. +Then fix forward with a new stable release. ## Output When the skill completes, provide: -- stable version and, if relevant, the final canary version tested +- candidate SHA and tested canary version, if relevant +- stable version, if promoted - verification status - npm status +- smoke-test status - git tag / GitHub Release status - website / announcement follow-up status - rollback recommendation if anything is still partially complete diff --git a/.github/workflows/release-smoke.yml b/.github/workflows/release-smoke.yml new file mode 100644 index 00000000..823a578c --- /dev/null +++ b/.github/workflows/release-smoke.yml @@ -0,0 +1,118 @@ +name: Release Smoke + +on: + workflow_dispatch: + inputs: + paperclip_version: + description: Published Paperclip dist-tag to test + required: true + default: canary + type: choice + options: + - canary + - latest + host_port: + description: Host port for the Docker smoke container + required: false + default: "3232" + type: string + artifact_name: + description: Artifact name for uploaded diagnostics + required: false + default: release-smoke + type: string + workflow_call: + inputs: + paperclip_version: + required: true + type: string + host_port: + required: false + default: "3232" + type: string + artifact_name: + required: false + default: release-smoke + type: string + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Install Playwright browser + run: npx playwright install --with-deps chromium + + - name: Launch Docker smoke harness + run: | + metadata_file="$RUNNER_TEMP/release-smoke.env" + HOST_PORT="${{ inputs.host_port }}" \ + DATA_DIR="$RUNNER_TEMP/release-smoke-data" \ + PAPERCLIPAI_VERSION="${{ inputs.paperclip_version }}" \ + SMOKE_DETACH=true \ + SMOKE_METADATA_FILE="$metadata_file" \ + ./scripts/docker-onboard-smoke.sh + set -a + source "$metadata_file" + set +a + { + echo "SMOKE_BASE_URL=$SMOKE_BASE_URL" + echo "SMOKE_ADMIN_EMAIL=$SMOKE_ADMIN_EMAIL" + echo "SMOKE_ADMIN_PASSWORD=$SMOKE_ADMIN_PASSWORD" + echo "SMOKE_CONTAINER_NAME=$SMOKE_CONTAINER_NAME" + echo "SMOKE_DATA_DIR=$SMOKE_DATA_DIR" + echo "SMOKE_IMAGE_NAME=$SMOKE_IMAGE_NAME" + echo "SMOKE_PAPERCLIPAI_VERSION=$SMOKE_PAPERCLIPAI_VERSION" + echo "SMOKE_METADATA_FILE=$metadata_file" + } >> "$GITHUB_ENV" + + - name: Run release smoke Playwright suite + env: + PAPERCLIP_RELEASE_SMOKE_BASE_URL: ${{ env.SMOKE_BASE_URL }} + PAPERCLIP_RELEASE_SMOKE_EMAIL: ${{ env.SMOKE_ADMIN_EMAIL }} + PAPERCLIP_RELEASE_SMOKE_PASSWORD: ${{ env.SMOKE_ADMIN_PASSWORD }} + run: pnpm run test:release-smoke + + - name: Capture Docker logs + if: always() + run: | + if [[ -n "${SMOKE_CONTAINER_NAME:-}" ]]; then + docker logs "$SMOKE_CONTAINER_NAME" >"$RUNNER_TEMP/docker-onboard-smoke.log" 2>&1 || true + fi + + - name: Upload diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact_name }} + path: | + ${{ runner.temp }}/docker-onboard-smoke.log + ${{ env.SMOKE_METADATA_FILE }} + tests/release-smoke/playwright-report/ + tests/release-smoke/test-results/ + retention-days: 14 + + - name: Stop Docker smoke container + if: always() + run: | + if [[ -n "${SMOKE_CONTAINER_NAME:-}" ]]; then + docker rm -f "$SMOKE_CONTAINER_NAME" >/dev/null 2>&1 || true + fi diff --git a/.gitignore b/.gitignore index 312c3969..61b00a22 100644 --- a/.gitignore +++ b/.gitignore @@ -46,5 +46,7 @@ tmp/ # Playwright tests/e2e/test-results/ tests/e2e/playwright-report/ +tests/release-smoke/test-results/ +tests/release-smoke/playwright-report/ .superset/ .claude/worktrees/ diff --git a/doc/DOCKER.md b/doc/DOCKER.md index 6f6ca374..a7055e20 100644 --- a/doc/DOCKER.md +++ b/doc/DOCKER.md @@ -120,6 +120,7 @@ Useful overrides: ```sh HOST_PORT=3200 PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh PAPERCLIP_DEPLOYMENT_MODE=authenticated PAPERCLIP_DEPLOYMENT_EXPOSURE=private ./scripts/docker-onboard-smoke.sh +SMOKE_DETACH=true SMOKE_METADATA_FILE=/tmp/paperclip-smoke.env PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh ``` Notes: @@ -131,4 +132,5 @@ Notes: - Smoke script also defaults `PAPERCLIP_PUBLIC_URL` to `http://localhost:` so bootstrap invite URLs and auth callbacks use the reachable host port instead of the container's internal `3100`. - In authenticated mode, the smoke script defaults `SMOKE_AUTO_BOOTSTRAP=true` and drives the real bootstrap path automatically: it signs up a real user, runs `paperclipai auth bootstrap-ceo` inside the container to mint a real bootstrap invite, accepts that invite over HTTP, and verifies board session access. - Run the script in the foreground to watch the onboarding flow; stop with `Ctrl+C` after validation. +- Set `SMOKE_DETACH=true` to leave the container running for automation and optionally write shell-ready metadata to `SMOKE_METADATA_FILE`. - The image definition is in `Dockerfile.onboard-smoke`. diff --git a/doc/RELEASING.md b/doc/RELEASING.md index cb7a14fe..c3668b14 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -66,6 +66,8 @@ Users install canaries with: ```bash npx paperclipai@canary onboard +# or +npx paperclipai@canary onboard --data-dir "$(mktemp -d /tmp/paperclip-canary.XXXXXX)" ``` ### Stable @@ -160,13 +162,22 @@ HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary . HOST_PORT=3233 DATA_DIR=./data/release-smoke-stable PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh ``` +Automated browser smoke is also available: + +```bash +gh workflow run release-smoke.yml -f paperclip_version=canary +gh workflow run release-smoke.yml -f paperclip_version=latest +``` + Minimum checks: - `npx paperclipai@canary onboard` installs - onboarding completes without crashes -- the server boots -- the UI loads -- basic company creation and dashboard load work +- authenticated login works with the smoke credentials +- the browser lands in onboarding on a fresh instance +- company creation succeeds +- the first CEO agent is created +- the first CEO heartbeat run is triggered ## Rollback diff --git a/doc/plans/2026-03-17-docker-release-browser-e2e.md b/doc/plans/2026-03-17-docker-release-browser-e2e.md new file mode 100644 index 00000000..e776206a --- /dev/null +++ b/doc/plans/2026-03-17-docker-release-browser-e2e.md @@ -0,0 +1,424 @@ +# Docker Release Browser E2E Plan + +## Context + +Today release smoke testing for published Paperclip packages is manual and shell-driven: + +```sh +HOST_PORT=3232 DATA_DIR=./data/release-smoke-canary PAPERCLIPAI_VERSION=canary ./scripts/docker-onboard-smoke.sh +HOST_PORT=3233 DATA_DIR=./data/release-smoke-stable PAPERCLIPAI_VERSION=latest ./scripts/docker-onboard-smoke.sh +``` + +That is useful because it exercises the same public install surface users hit: + +- Docker +- `npx paperclipai@canary` +- `npx paperclipai@latest` +- authenticated bootstrap flow + +But it still leaves the most important release questions to a human with a browser: + +- can I sign in with the smoke credentials? +- do I land in onboarding? +- can I complete onboarding? +- does the initial CEO agent actually get created and run? + +The repo already has two adjacent pieces: + +- `tests/e2e/onboarding.spec.ts` covers the onboarding wizard against the local source tree +- `scripts/docker-onboard-smoke.sh` boots a published Docker install and auto-bootstraps authenticated mode, but only verifies the API/session layer + +What is missing is one deterministic browser test that joins those two paths. + +## Goal + +Add a release-grade Docker-backed browser E2E that validates the published `canary` and `latest` installs end to end: + +1. boot the published package in Docker +2. sign in with known smoke credentials +3. verify the user is routed into onboarding +4. complete onboarding in the browser +5. verify the first CEO agent exists +6. verify the initial CEO run was triggered and reached a terminal or active state + +Then wire that test into GitHub Actions so release validation is no longer manual-only. + +## Recommendation In One Sentence + +Turn the current Docker smoke script into a machine-friendly test harness, add a dedicated Playwright release-smoke spec that drives the authenticated browser flow against published Docker installs, and run it in GitHub Actions for both `canary` and `latest`. + +## What We Have Today + +### Existing local browser coverage + +`tests/e2e/onboarding.spec.ts` already proves the onboarding wizard can: + +- create a company +- create a CEO agent +- create an initial issue +- optionally observe task progress + +That is a good base, but it does not validate the public npm package, Docker path, authenticated login flow, or release dist-tags. + +### Existing Docker smoke coverage + +`scripts/docker-onboard-smoke.sh` already does useful setup work: + +- builds `Dockerfile.onboard-smoke` +- runs `paperclipai@${PAPERCLIPAI_VERSION}` inside Docker +- waits for health +- signs up or signs in a smoke admin user +- generates and accepts the bootstrap CEO invite in authenticated mode +- verifies a board session and `/api/companies` + +That means the hard bootstrap problem is mostly solved already. The main gap is that the script is human-oriented and never hands control to a browser test. + +### Existing CI shape + +The repo already has: + +- `.github/workflows/e2e.yml` for manual Playwright runs against local source +- `.github/workflows/release.yml` for canary publish on `master` and manual stable promotion + +So the right move is to extend the current test/release system, not create a parallel one. + +## Product Decision + +### 1. The release smoke should stay deterministic and token-free + +The first version should not require OpenAI, Anthropic, or external agent credentials. + +Use the onboarding flow with a deterministic adapter that can run on a stock GitHub runner and inside the published Docker install. The existing `process` adapter with a trivial command is the right base path for this release gate. + +That keeps this test focused on: + +- release packaging +- auth/bootstrap +- UI routing +- onboarding contract +- agent creation +- heartbeat invocation plumbing + +Later we can add a second credentialed smoke lane for real model-backed agents. + +### 2. Smoke credentials become an explicit test contract + +The current defaults in `scripts/docker-onboard-smoke.sh` should be treated as stable test fixtures: + +- email: `smoke-admin@paperclip.local` +- password: `paperclip-smoke-password` + +The browser test should log in with those exact values unless overridden by env vars. + +### 3. Published-package smoke and source-tree E2E stay separate + +Keep two lanes: + +- source-tree E2E for feature development +- published Docker release smoke for release confidence + +They overlap on onboarding assertions, but they guard different failure classes. + +## Proposed Design + +## 1. Add a CI-friendly Docker smoke harness + +Refactor `scripts/docker-onboard-smoke.sh` so it can run in two modes: + +- interactive mode + - current behavior + - streams logs and waits in foreground for manual inspection +- CI mode + - starts the container + - waits for health and authenticated bootstrap + - prints machine-readable metadata + - exits while leaving the container running for Playwright + +Recommended shape: + +- keep `scripts/docker-onboard-smoke.sh` as the public entry point +- add a `SMOKE_DETACH=true` or `--detach` mode +- emit a JSON blob or `.env` file containing: + - `SMOKE_BASE_URL` + - `SMOKE_ADMIN_EMAIL` + - `SMOKE_ADMIN_PASSWORD` + - `SMOKE_CONTAINER_NAME` + - `SMOKE_DATA_DIR` + +The workflow and Playwright tests can then consume the emitted metadata instead of scraping logs. + +### Why this matters + +The current script always tails logs and then blocks on `wait "$LOG_PID"`. That is convenient for manual smoke testing, but it is the wrong shape for CI orchestration. + +## 2. Add a dedicated Playwright release-smoke spec + +Create a second Playwright entry point specifically for published Docker installs, for example: + +- `tests/release-smoke/playwright.config.ts` +- `tests/release-smoke/docker-auth-onboarding.spec.ts` + +This suite should not use Playwright `webServer`, because the app server will already be running inside Docker. + +### Browser scenario + +The first release-smoke scenario should validate: + +1. open `/` +2. unauthenticated user is redirected to `/auth` +3. sign in using the smoke credentials +4. authenticated user lands on onboarding when no companies exist +5. onboarding wizard appears with the expected step labels +6. create a company +7. create the first agent using `process` +8. create the initial issue +9. finish onboarding and open the created issue +10. verify via API: + - company exists + - CEO agent exists + - issue exists and is assigned to the CEO +11. verify the first heartbeat run was triggered: + - either by checking issue status changed from initial state, or + - by checking agent/runs API shows a run for the CEO, or + - both + +The test should tolerate the run completing quickly. For this reason, the assertion should accept: + +- `queued` +- `running` +- `succeeded` + +and similarly for issue progression if the issue status changes before the assertion runs. + +### Why a separate spec instead of reusing `tests/e2e/onboarding.spec.ts` + +The local-source test and release-smoke test have different assumptions: + +- different server lifecycle +- different auth path +- different deployment mode +- published npm package instead of local workspace code + +Trying to force both through one spec will make both worse. + +## 3. Add a release-smoke workflow in GitHub Actions + +Add a workflow dedicated to this surface, ideally reusable: + +- `.github/workflows/release-smoke.yml` + +Recommended triggers: + +- `workflow_dispatch` +- `workflow_call` + +Recommended inputs: + +- `paperclip_version` + - `canary` or `latest` +- `host_port` + - optional, default runner-safe port +- `artifact_name` + - optional for clearer uploads + +### Job outline + +1. checkout repo +2. install Node/pnpm +3. install Playwright browser dependencies +4. launch Docker smoke harness in detached mode with the chosen dist-tag +5. run the release-smoke Playwright suite against the returned base URL +6. always collect diagnostics: + - Playwright report + - screenshots + - trace + - `docker logs` + - harness metadata file +7. stop and remove container + +### Why a reusable workflow + +This lets us: + +- run the smoke manually on demand +- call it from `release.yml` +- reuse the same job for both `canary` and `latest` + +## 4. Integrate it into release automation incrementally + +### Phase A: Manual workflow only + +First ship the workflow as manual-only so the harness and test can be stabilized without blocking releases. + +### Phase B: Run automatically after canary publish + +After `publish_canary` succeeds in `.github/workflows/release.yml`, call the reusable release-smoke workflow with: + +- `paperclip_version=canary` + +This proves the just-published public canary really boots and onboards. + +### Phase C: Run automatically after stable publish + +After `publish_stable` succeeds, call the same workflow with: + +- `paperclip_version=latest` + +This gives us post-publish confirmation that the stable dist-tag is healthy. + +### Important nuance + +Testing `latest` from npm cannot happen before stable publish, because the package under test does not exist under `latest` yet. So the `latest` smoke is a post-publish verification, not a pre-publish gate. + +If we later want a true pre-publish stable gate, that should be a separate source-ref or locally built package smoke job. + +## 5. Make diagnostics first-class + +This workflow is only valuable if failures are fast to debug. + +Always capture: + +- Playwright HTML report +- Playwright trace on failure +- final screenshot on failure +- full `docker logs` output +- emitted smoke metadata +- optional `curl /api/health` snapshot + +Without that, the test will become a flaky black box and people will stop trusting it. + +## Implementation Plan + +## Phase 1: Harness refactor + +Files: + +- `scripts/docker-onboard-smoke.sh` +- optionally `scripts/lib/docker-onboard-smoke.sh` or similar helper +- `doc/DOCKER.md` +- `doc/RELEASING.md` + +Tasks: + +1. Add detached/CI mode to the Docker smoke script. +2. Make the script emit machine-readable connection metadata. +3. Keep the current interactive manual mode intact. +4. Add reliable cleanup commands for CI. + +Acceptance: + +- a script invocation can start the published Docker app, auto-bootstrap it, and return control to the caller with enough metadata for browser automation + +## Phase 2: Browser release-smoke suite + +Files: + +- `tests/release-smoke/playwright.config.ts` +- `tests/release-smoke/docker-auth-onboarding.spec.ts` +- root `package.json` + +Tasks: + +1. Add a dedicated Playwright config for external server testing. +2. Implement login + onboarding + CEO creation flow. +3. Assert a CEO run was created or completed. +4. Add a root script such as: + - `test:release-smoke` + +Acceptance: + +- the suite passes locally against both: + - `PAPERCLIPAI_VERSION=canary` + - `PAPERCLIPAI_VERSION=latest` + +## Phase 3: GitHub Actions workflow + +Files: + +- `.github/workflows/release-smoke.yml` + +Tasks: + +1. Add manual and reusable workflow entry points. +2. Install Chromium and runner dependencies. +3. Start Docker smoke in detached mode. +4. Run the release-smoke Playwright suite. +5. Upload diagnostics artifacts. + +Acceptance: + +- a maintainer can run the workflow manually for either `canary` or `latest` + +## Phase 4: Release workflow integration + +Files: + +- `.github/workflows/release.yml` +- `doc/RELEASING.md` + +Tasks: + +1. Trigger release smoke automatically after canary publish. +2. Trigger release smoke automatically after stable publish. +3. Document expected behavior and failure handling. + +Acceptance: + +- canary releases automatically produce a published-package browser smoke result +- stable releases automatically produce a `latest` browser smoke result + +## Phase 5: Future extension for real model-backed agent validation + +Not part of the first implementation, but this should be the next layer after the deterministic lane is stable. + +Possible additions: + +- a second Playwright project gated on repo secrets +- real `claude_local` or `codex_local` adapter validation in Docker-capable environments +- assertion that the CEO posts a real task/comment artifact +- stable release holdback until the credentialed lane passes + +This should stay optional until the token-free lane is trustworthy. + +## Acceptance Criteria + +The plan is complete when the implemented system can demonstrate all of the following: + +1. A published `paperclipai@canary` Docker install can be smoke-tested by Playwright in CI. +2. A published `paperclipai@latest` Docker install can be smoke-tested by Playwright in CI. +3. The test logs into authenticated mode with the smoke credentials. +4. The test sees onboarding for a fresh instance. +5. The test completes onboarding in the browser. +6. The test verifies the initial CEO agent was created. +7. The test verifies at least one CEO heartbeat run was triggered. +8. Failures produce actionable artifacts rather than just a red job. + +## Risks And Decisions To Make + +### 1. Fast process runs may finish before the UI visibly updates + +That is expected. The assertions should prefer API polling for run existence/status rather than only visual indicators. + +### 2. `latest` smoke is post-publish, not preventive + +This is a real limitation of testing the published dist-tag itself. It is still valuable, but it should not be confused with a pre-publish gate. + +### 3. We should not overcouple the test to cosmetic onboarding text + +The important contract is flow success, created entities, and run creation. Use visible labels sparingly and prefer stable semantic selectors where possible. + +### 4. Keep the smoke adapter path boring + +For release safety, the first test should use the most boring runnable adapter possible. This is not the place to validate every adapter. + +## Recommended First Slice + +If we want the fastest path to value, ship this in order: + +1. add detached mode to `scripts/docker-onboard-smoke.sh` +2. add one Playwright spec for authenticated login + onboarding + CEO run verification +3. add manual `release-smoke.yml` +4. once stable, wire canary into `release.yml` +5. after that, wire stable `latest` smoke into `release.yml` + +That gives release confidence quickly without turning the first version into a large CI redesign. diff --git a/package.json b/package.json index 83de361a..71853b89 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,9 @@ "smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh", "smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh", "test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts", - "test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed" + "test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed", + "test:release-smoke": "npx playwright test --config tests/release-smoke/playwright.config.ts", + "test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed" }, "devDependencies": { "cross-env": "^10.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6820f52..c0990842 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,7 +25,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0) cli: dependencies: @@ -226,6 +226,9 @@ importers: drizzle-orm: specifier: ^0.38.4 version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4) + embedded-postgres: + specifier: ^18.1.0-beta.16 + version: 18.1.0-beta.16 postgres: specifier: ^3.4.5 version: 3.4.8 @@ -244,7 +247,181 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0) + + packages/plugins/create-paperclip-plugin: + dependencies: + '@paperclipai/plugin-sdk': + specifier: workspace:* + version: link:../sdk + devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + + packages/plugins/examples/plugin-authoring-smoke-example: + dependencies: + '@paperclipai/plugin-sdk': + specifier: workspace:* + version: link:../../sdk + react: + specifier: '>=18' + version: 19.2.4 + devDependencies: + '@rollup/plugin-node-resolve': + specifier: ^16.0.1 + version: 16.0.3(rollup@4.57.1) + '@rollup/plugin-typescript': + specifier: ^12.1.2 + version: 12.3.0(rollup@4.57.1)(tslib@2.8.1)(typescript@5.9.3) + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 + '@types/react': + specifier: ^19.0.8 + version: 19.2.14 + esbuild: + specifier: ^0.27.3 + version: 0.27.3 + rollup: + specifier: ^4.38.0 + version: 4.57.1 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^3.0.5 + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0) + + packages/plugins/examples/plugin-file-browser-example: + dependencies: + '@codemirror/lang-javascript': + specifier: ^6.2.2 + version: 6.2.4 + '@codemirror/language': + specifier: ^6.11.0 + version: 6.12.1 + '@codemirror/state': + specifier: ^6.4.0 + version: 6.5.4 + '@codemirror/view': + specifier: ^6.28.0 + version: 6.39.15 + '@lezer/highlight': + specifier: ^1.2.1 + version: 1.2.3 + '@paperclipai/plugin-sdk': + specifier: workspace:* + version: link:../../sdk + codemirror: + specifier: ^6.0.1 + version: 6.0.2 + devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 + '@types/react': + specifier: ^19.0.8 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.0.3 + version: 19.2.3(@types/react@19.2.14) + esbuild: + specifier: ^0.27.3 + version: 0.27.3 + react: + specifier: ^19.0.0 + version: 19.2.4 + react-dom: + specifier: ^19.0.0 + version: 19.2.4(react@19.2.4) + typescript: + specifier: ^5.7.3 + version: 5.9.3 + + packages/plugins/examples/plugin-hello-world-example: + dependencies: + '@paperclipai/plugin-sdk': + specifier: workspace:* + version: link:../../sdk + devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 + '@types/react': + specifier: ^19.0.8 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.0.3 + version: 19.2.3(@types/react@19.2.14) + react: + specifier: ^19.0.0 + version: 19.2.4 + react-dom: + specifier: ^19.0.0 + version: 19.2.4(react@19.2.4) + typescript: + specifier: ^5.7.3 + version: 5.9.3 + + packages/plugins/examples/plugin-kitchen-sink-example: + dependencies: + '@paperclipai/plugin-sdk': + specifier: workspace:* + version: link:../../sdk + '@paperclipai/shared': + specifier: workspace:* + version: link:../../../shared + devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 + '@types/react': + specifier: ^19.0.8 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.0.3 + version: 19.2.3(@types/react@19.2.14) + esbuild: + specifier: ^0.27.3 + version: 0.27.3 + react: + specifier: ^19.0.0 + version: 19.2.4 + react-dom: + specifier: ^19.0.0 + version: 19.2.4(react@19.2.4) + typescript: + specifier: ^5.7.3 + version: 5.9.3 + + packages/plugins/sdk: + dependencies: + '@paperclipai/shared': + specifier: workspace:* + version: link:../../shared + react: + specifier: '>=18' + version: 19.2.4 + zod: + specifier: ^3.24.2 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.0 + '@types/react': + specifier: ^19.0.8 + version: 19.2.14 + typescript: + specifier: ^5.7.3 + version: 5.9.3 packages/shared: dependencies: @@ -288,15 +465,30 @@ importers: '@paperclipai/db': specifier: workspace:* version: link:../packages/db + '@paperclipai/plugin-sdk': + specifier: workspace:* + version: link:../packages/plugins/sdk '@paperclipai/shared': specifier: workspace:* version: link:../packages/shared + ajv: + specifier: ^8.18.0 + version: 8.18.0 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.18.0) better-auth: specifier: 1.4.18 - version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) + version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)) + chokidar: + specifier: ^4.0.3 + version: 4.0.3 detect-port: specifier: ^2.1.0 version: 2.1.0 + dompurify: + specifier: ^3.3.2 + version: 3.3.2 dotenv: specifier: ^17.0.1 version: 17.3.1 @@ -309,6 +501,12 @@ importers: express: specifier: ^5.1.0 version: 5.2.1 + hermes-paperclip-adapter: + specifier: 0.1.1 + version: 0.1.1 + jsdom: + specifier: ^28.1.0 + version: 28.1.0(@noble/hashes@2.0.1) multer: specifier: ^2.0.2 version: 2.0.2 @@ -337,6 +535,9 @@ importers: '@types/express-serve-static-core': specifier: ^5.0.0 version: 5.1.1 + '@types/jsdom': + specifier: ^28.0.0 + version: 28.0.0 '@types/multer': specifier: ^2.0.0 version: 2.0.0 @@ -366,7 +567,7 @@ importers: version: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0) ui: dependencies: @@ -481,13 +682,26 @@ importers: version: 6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0) packages: + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@asamuzakjp/css-color@5.0.1': + resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -766,6 +980,10 @@ packages: '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@changesets/apply-release-plan@7.1.0': resolution: {integrity: sha512-yq8ML3YS7koKQ/9bk1PqO0HMzApIFNwjlwCnwFEXMzNe8NpzeeYYKCmnhWJGkN8g7E51MnWaSbqRcTcdIxUgnQ==} @@ -947,6 +1165,42 @@ packages: react: ^16.8.0 || ^17 || ^18 || ^19 react-dom: ^16.8.0 || ^17 || ^18 || ^19 + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.1.1': + resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.0.2': + resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.1': + resolution: {integrity: sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -1478,6 +1732,15 @@ packages: cpu: [x64] os: [win32] + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.7.4': resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} @@ -1705,6 +1968,9 @@ packages: '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + '@paperclipai/adapter-utils@0.3.1': + resolution: {integrity: sha512-W66k+hJkQE8ma0asM/Sd90AC8HHy/BLG/sd0aOC+rDWw+gOasQyUkTnDoPv1zhQuTyKEEvLFV6ByOOKqEiAz/A==} + '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} @@ -2436,6 +2702,37 @@ packages: '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + '@rollup/plugin-node-resolve@16.0.3': + resolution: {integrity: sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-typescript@12.3.0': + resolution: {integrity: sha512-7DP0/p7y3t67+NabT9f8oTBFE6gGkto4SA6Np2oudYmZE/m1dt8RB0SjL1msMxFpLo631qjRCcBlAbq1ml/Big==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.14.0||^3.0.0||^4.0.0 + tslib: '*' + typescript: '>=3.7.0' + peerDependenciesMeta: + rollup: + optional: true + tslib: + optional: true + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/rollup-android-arm-eabi@4.57.1': resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} cpu: [arm] @@ -3030,6 +3327,9 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/jsdom@28.0.0': + resolution: {integrity: sha512-A8TBQQC/xAOojy9kM8E46cqT00sF0h7dWjV8t8BJhUi2rG6JRh7XXQo/oLoENuZIQEpXsxLccLCnknyQd7qssQ==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -3068,6 +3368,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/resolve@1.20.2': + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/send@1.2.1': resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} @@ -3080,6 +3383,9 @@ packages: '@types/supertest@6.0.3': resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -3148,6 +3454,21 @@ packages: resolution: {integrity: sha512-XNAb/a6TCqou+TufU8/u11HCu9x1gYvOoxLwtlXgIqmkrYQADVv6ljyW2zwiPhHz9R1gItAWpuDrdJMmrOBFEA==} engines: {node: '>= 16.0.0'} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + anser@2.3.5: resolution: {integrity: sha512-vcZjxvvVoxTeR5XBNJB38oTu/7eDCZlwdz32N1eNgpyPF7j/Z7Idf+CUwQOkKKpJ7RJyjxgLHCM7vdIK0iCNMQ==} @@ -3278,6 +3599,9 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -3361,6 +3685,10 @@ packages: chevrotain@11.1.2: resolution: {integrity: sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -3470,11 +3798,19 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true + cssstyle@6.2.0: + resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==} + engines: {node: '>=20'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -3638,6 +3974,10 @@ packages: dagre-d3-es@7.0.13: resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} @@ -3653,6 +3993,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -3660,6 +4003,10 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + default-browser-id@5.0.1: resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} engines: {node: '>=18'} @@ -3862,6 +4209,10 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -3941,6 +4292,9 @@ packages: estree-util-visit@2.0.0: resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -3971,6 +4325,9 @@ packages: fast-copy@4.0.2: resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -3978,6 +4335,9 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-xml-parser@5.3.6: resolution: {integrity: sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==} hasBin: true @@ -4112,6 +4472,14 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} + hermes-paperclip-adapter@0.1.1: + resolution: {integrity: sha512-kbdX349VxExSkVL8n4RwTpP9fUBf2yWpsTsJp02X12A9NynRJatlpYqt0vEkFyE/X7qEXqdJvpBm9tlvUHahsA==} + engines: {node: '>=20.0.0'} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -4119,6 +4487,14 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-id@4.1.3: resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} hasBin: true @@ -4165,6 +4541,10 @@ packages: is-alphanumerical@2.0.1: resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} @@ -4193,6 +4573,9 @@ packages: engines: {node: '>=14.16'} hasBin: true + is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -4201,6 +4584,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -4247,11 +4633,23 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@28.1.0: + resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} hasBin: true + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -4383,6 +4781,10 @@ packages: loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@11.2.7: + resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -4467,6 +4869,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@0.3.0: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} @@ -4727,6 +5132,12 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -4742,6 +5153,9 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} @@ -4901,6 +5315,10 @@ packages: pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + qs@6.15.0: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} @@ -5030,6 +5448,10 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -5046,6 +5468,10 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -5053,6 +5479,11 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -5099,6 +5530,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -5257,6 +5692,13 @@ packages: resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} engines: {node: '>=14.18.0'} + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} @@ -5303,6 +5745,13 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tldts-core@7.0.26: + resolution: {integrity: sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew==} + + tldts@7.0.26: + resolution: {integrity: sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -5311,6 +5760,14 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -5357,6 +5814,13 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici-types@7.24.4: + resolution: {integrity: sha512-cRaY9PagdEZoRmcwzk3tUV3SVGrVQkR6bcSilav/A0vXsfpW4Lvd0BvgRMwTEDTLLGN+QdyBTG+nnvTgJhdt6w==} + + undici@7.24.4: + resolution: {integrity: sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==} + engines: {node: '>=20.18.1'} + unidiff@1.0.4: resolution: {integrity: sha512-ynU0vsAXw0ir8roa+xPCUHmnJ5goc5BTM2Kuc3IJd8UwgaeRs7VSD5+eeaQL+xp1JtB92hu/Zy/Lgy7RZcr1pQ==} @@ -5578,6 +6042,22 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -5607,6 +6087,13 @@ packages: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -5629,11 +6116,31 @@ packages: snapshots: + '@acemir/cssom@0.9.31': {} + '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.6.0 tinyexec: 1.0.2 + '@asamuzakjp/css-color@5.0.1': + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.2.7 + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.7 + + '@asamuzakjp/nwsapi@2.3.9': {} + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -6264,6 +6771,10 @@ snapshots: '@braintree/sanitize-url@7.1.2': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@changesets/apply-release-plan@7.1.0': dependencies: '@changesets/config': 3.1.3 @@ -6729,6 +7240,30 @@ snapshots: react-dom: 19.2.4(react@19.2.4) react-is: 17.0.2 + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.1(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.4)': dependencies: react: 19.2.4 @@ -7017,6 +7552,10 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true + '@exodus/bytes@1.15.0(@noble/hashes@2.0.1)': + optionalDependencies: + '@noble/hashes': 2.0.1 + '@floating-ui/core@1.7.4': dependencies: '@floating-ui/utils': 0.2.10 @@ -7430,6 +7969,8 @@ snapshots: '@open-draft/deferred-promise@2.2.0': {} + '@paperclipai/adapter-utils@0.3.1': {} + '@paralleldrive/cuid2@2.3.1': dependencies: '@noble/hashes': 1.8.0 @@ -8212,6 +8753,33 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.27': {} + '@rollup/plugin-node-resolve@16.0.3(rollup@4.57.1)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.57.1) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-module: 1.0.0 + resolve: 1.22.11 + optionalDependencies: + rollup: 4.57.1 + + '@rollup/plugin-typescript@12.3.0(rollup@4.57.1)(tslib@2.8.1)(typescript@5.9.3)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.57.1) + resolve: 1.22.11 + typescript: 5.9.3 + optionalDependencies: + rollup: 4.57.1 + tslib: 2.8.1 + + '@rollup/pluginutils@5.3.0(rollup@4.57.1)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.57.1 + '@rollup/rollup-android-arm-eabi@4.57.1': optional: true @@ -8896,6 +9464,13 @@ snapshots: '@types/http-errors@2.0.5': {} + '@types/jsdom@28.0.0': + dependencies: + '@types/node': 25.2.3 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + undici-types: 7.24.4 + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -8934,6 +9509,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/resolve@1.20.2': {} + '@types/send@1.2.1': dependencies: '@types/node': 25.2.3 @@ -8955,6 +9532,8 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/tough-cookie@4.0.5': {} + '@types/trusted-types@2.0.7': optional: true @@ -9043,6 +9622,19 @@ snapshots: address@2.0.3: {} + agent-base@7.1.4: {} + + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + anser@2.3.5: {} ansi-colors@4.1.3: {} @@ -9079,7 +9671,7 @@ snapshots: baseline-browser-mapping@2.9.19: {} - better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)): + better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(kysely@0.28.11)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0)): dependencies: '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@3.25.76))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) @@ -9099,7 +9691,7 @@ snapshots: pg: 8.18.0 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0) better-call@1.1.8(zod@4.3.6): dependencies: @@ -9114,6 +9706,10 @@ snapshots: dependencies: is-windows: 1.0.2 + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -9209,6 +9805,10 @@ snapshots: '@chevrotain/utils': 11.1.2 lodash-es: 4.17.23 + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -9310,8 +9910,20 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + cssesc@3.0.0: {} + cssstyle@6.2.0: + dependencies: + '@asamuzakjp/css-color': 5.0.1 + '@csstools/css-syntax-patches-for-csstree': 1.1.1(css-tree@3.2.1) + css-tree: 3.2.1 + lru-cache: 11.2.7 + csstype@3.2.3: {} cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1): @@ -9503,6 +10115,13 @@ snapshots: d3: 7.9.0 lodash-es: 4.17.23 + data-urls@7.0.0(@noble/hashes@2.0.1): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.0.1) + transitivePeerDependencies: + - '@noble/hashes' + dateformat@4.6.3: {} dayjs@1.11.19: {} @@ -9511,12 +10130,16 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 deep-eql@5.0.2: {} + deepmerge@4.3.1: {} + default-browser-id@5.0.1: {} default-browser@5.5.0: @@ -9640,6 +10263,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@6.0.1: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -9789,6 +10414,8 @@ snapshots: '@types/estree-jsx': 1.0.5 '@types/unist': 3.0.3 + estree-walker@2.0.2: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -9845,6 +10472,8 @@ snapshots: fast-copy@4.0.2: {} + fast-deep-equal@3.1.3: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -9855,6 +10484,8 @@ snapshots: fast-safe-stringify@2.1.1: {} + fast-uri@3.1.0: {} + fast-xml-parser@5.3.6: dependencies: strnum: 2.1.2 @@ -10014,6 +10645,17 @@ snapshots: help-me@5.0.0: {} + hermes-paperclip-adapter@0.1.1: + dependencies: + '@paperclipai/adapter-utils': 0.3.1 + picocolors: 1.1.1 + + html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1): + dependencies: + '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) + transitivePeerDependencies: + - '@noble/hashes' + html-url-attributes@3.0.1: {} http-errors@2.0.1: @@ -10024,6 +10666,20 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + human-id@4.1.3: {} iconv-lite@0.6.3: @@ -10057,6 +10713,10 @@ snapshots: is-alphabetical: 2.0.1 is-decimal: 2.0.1 + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + is-decimal@2.0.1: {} is-docker@3.0.0: {} @@ -10075,10 +10735,14 @@ snapshots: dependencies: is-docker: 3.0.0 + is-module@1.0.0: {} + is-number@7.0.0: {} is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-subdir@1.2.0: @@ -10114,8 +10778,37 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@28.1.0(@noble/hashes@2.0.1): + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@bramus/specificity': 2.4.2 + '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) + cssstyle: 6.2.0 + data-urls: 7.0.0(@noble/hashes@2.0.1) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.0.1) + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.24.4 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.0.1) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - supports-color + jsesc@3.1.0: {} + json-schema-traverse@1.0.0: {} + json5@2.2.3: {} jsonfile@4.0.0: @@ -10215,6 +10908,8 @@ snapshots: loupe@3.2.1: {} + lru-cache@11.2.7: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -10427,6 +11122,8 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdn-data@2.27.1: {} + media-typer@0.3.0: {} media-typer@1.1.0: {} @@ -10865,6 +11562,14 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parse5@8.0.0: + dependencies: + entities: 6.0.1 + parseurl@1.3.3: {} path-data-parser@0.1.0: {} @@ -10873,6 +11578,8 @@ snapshots: path-key@3.1.1: {} + path-parse@1.0.7: {} + path-to-regexp@8.3.0: {} path-type@4.0.0: {} @@ -11041,6 +11748,8 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + punycode@2.3.1: {} + qs@6.15.0: dependencies: side-channel: 1.1.0 @@ -11221,6 +11930,8 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + readdirp@4.1.2: {} + real-require@0.2.0: {} remark-gfm@4.0.1: @@ -11257,10 +11968,18 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + require-from-string@2.0.2: {} + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + reusify@1.1.0: {} robust-predicates@3.0.2: {} @@ -11333,6 +12052,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} secure-json-parse@4.1.0: {} @@ -11510,6 +12233,10 @@ snapshots: transitivePeerDependencies: - supports-color + supports-preserve-symlinks-flag@1.0.0: {} + + symbol-tree@3.2.4: {} + tabbable@6.4.0: {} tailwind-merge@3.4.1: {} @@ -11541,12 +12268,26 @@ snapshots: tinyspy@4.0.4: {} + tldts-core@7.0.26: {} + + tldts@7.0.26: + dependencies: + tldts-core: 7.0.26 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 toidentifier@1.0.1: {} + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.26 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -11585,6 +12326,10 @@ snapshots: undici-types@7.16.0: {} + undici-types@7.24.4: {} + + undici@7.24.4: {} + unidiff@1.0.4: dependencies: diff: 5.2.2 @@ -11780,7 +12525,7 @@ snapshots: lightningcss: 1.30.2 tsx: 4.21.0 - vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.12.0)(jiti@2.6.1)(jsdom@28.1.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(tsx@4.21.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -11808,6 +12553,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 24.12.0 + jsdom: 28.1.0(@noble/hashes@2.0.1) transitivePeerDependencies: - jiti - less @@ -11822,7 +12568,7 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -11850,6 +12596,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 25.2.3 + jsdom: 28.1.0(@noble/hashes@2.0.1) transitivePeerDependencies: - jiti - less @@ -11883,6 +12630,22 @@ snapshots: w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1(@noble/hashes@2.0.1): + dependencies: + '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 @@ -11901,6 +12664,10 @@ snapshots: is-wsl: 3.1.1 powershell-utils: 0.1.0 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xtend@4.0.2: {} yallist@3.1.1: {} diff --git a/releases/v2026.3.17.md b/releases/v2026.3.17.md new file mode 100644 index 00000000..cafaa60b --- /dev/null +++ b/releases/v2026.3.17.md @@ -0,0 +1,65 @@ +# v2026.3.17 + +> Released: 2026-03-17 + +## Highlights + +- **Plugin framework and SDK** — Full plugin system with runtime lifecycle management, CLI tooling, settings UI, breadcrumb and slot extensibility, domain event bridge, and a kitchen-sink example. The Plugin SDK now includes document CRUD methods and a testing harness. ([#904](https://github.com/paperclipai/paperclip/pull/904), [#910](https://github.com/paperclipai/paperclip/pull/910), [#912](https://github.com/paperclipai/paperclip/pull/912), [#909](https://github.com/paperclipai/paperclip/pull/909), [#1074](https://github.com/paperclipai/paperclip/pull/1074), @gsxdsm, @mvanhorn, @residentagent) +- **Upgraded costs and budgeting** — Improved cost tracking and budget management surfaces. ([#949](https://github.com/paperclipai/paperclip/pull/949)) +- **Issue documents and attachments** — Issues now support inline document editing, file staging before creation, deep-linked documents, copy and download actions, and live-event refresh. ([#899](https://github.com/paperclipai/paperclip/pull/899)) +- **Hermes agent adapter** — New `hermes_local` adapter brings support for the Hermes CLI as an agent backend. ([#587](https://github.com/paperclipai/paperclip/pull/587), @teknium1) +- **Execution workspaces (EXPERIMENTAL)** — Isolated execution workspaces for agent runs, including workspace operation tracking, reusable workspace deduplication, and work product management. Project-level workspace policies are configurable. ([#1038](https://github.com/paperclipai/paperclip/pull/1038)) +- **Heartbeat token optimization** — Heartbeat cycles now skip redundant token usage. + +## Improvements + +- **Session compaction is adapter-aware** — Compaction logic now respects per-adapter context limits. +- **Company logos** — Upload and display company logos with SVG sanitization and enhanced security headers for asset responses. ([#162](https://github.com/paperclipai/paperclip/pull/162), @JonCSykes) +- **App version label** — The sidebar now displays the running Paperclip version. ([#1096](https://github.com/paperclipai/paperclip/pull/1096), @saishankar404) +- **Project tab caching** — Active project tab is remembered per-project; tabs have been renamed and reordered. ([#990](https://github.com/paperclipai/paperclip/pull/990)) +- **Copy-to-clipboard on issues** — Issue detail headers now include a copy button; HTML entities no longer leak into copied text. ([#990](https://github.com/paperclipai/paperclip/pull/990)) +- **Me and Unassigned assignee options** — Quick-filter assignee options for the current user and unassigned issues. ([#990](https://github.com/paperclipai/paperclip/pull/990)) +- **Skip pre-filled fields in new issue dialog** — Tab order now skips assignee and project fields when they are already populated. ([#990](https://github.com/paperclipai/paperclip/pull/990)) +- **Worktree cleanup command** — New `worktree:cleanup` command, env-var defaults, and auto-prefix for worktree branches. ([#1038](https://github.com/paperclipai/paperclip/pull/1038)) +- **Release automation** — Automated canary and stable release workflows with npm trusted publishing and provenance metadata. ([#1151](https://github.com/paperclipai/paperclip/pull/1151), [#1162](https://github.com/paperclipai/paperclip/pull/1162)) +- **Documentation link** — Sidebar documentation link now points to external docs.paperclip.ing. +- **Onboarding starter task delay** — Starter tasks are no longer created until the user launches. + +## Fixes + +- **Embedded PostgreSQL hardening** — Startup adoption, data-dir verification, and UTF-8 encoding are now handled reliably. (@vkartaviy) +- **`os.userInfo()` guard** — Containers with UID-only users no longer crash; HOME is excluded from the cache key. ([#1145](https://github.com/paperclipai/paperclip/pull/1145), @wesseljt) +- **opencode-local HOME resolution** — `os.userInfo()` is used for model discovery instead of relying on the HOME env var. ([#1145](https://github.com/paperclipai/paperclip/pull/1145), @wesseljt) +- **dotenv cwd fallback** — The server now loads `.env` from `cwd` when `.paperclip/.env` is missing. ([#834](https://github.com/paperclipai/paperclip/pull/834), @mvanhorn) +- **Plugin event subscription wiring** — Fixed subscription cleanup, filter nullability, and stale diagram. ([#988](https://github.com/paperclipai/paperclip/pull/988), @leeknowsai) +- **Plugin slot rendering** — Corrected slot registration and rendering for plugin UI extensions. ([#916](https://github.com/paperclipai/paperclip/pull/916), [#918](https://github.com/paperclipai/paperclip/pull/918), @gsxdsm) +- **Archive project UX** — Archive now navigates to the dashboard and shows a toast; replaced `window.confirm` with inline confirmation. +- **Markdown editor spacing** — Image drop/paste adds proper newlines; header top margins increased. +- **Workspace form refresh** — Forms now refresh when projects are accessed via URL key and allow empty saves. +- **Legacy migration reconciliation** — Fixed migration reconciliation for existing installations. +- **`archivedAt` type coercion** — String-to-Date conversion before Drizzle update prevents type errors. +- **Agent HOME env var** — `AGENT_HOME` is now set correctly for child agent processes. ([#864](https://github.com/paperclipai/paperclip/pull/864)) +- **Sidebar scrollbar hover track** — Fixed scrollbar track visibility on hover. ([#919](https://github.com/paperclipai/paperclip/pull/919)) +- **Sticky save bar on non-config tabs** — Hidden to prevent layout push. +- **Empty goals display** — Removed "None" text from empty goals. +- **Runs page padding** — Removed unnecessary right padding. +- **Codex bootstrap logs** — Treated as stdout instead of stderr. +- **Dev runner syntax** — Fixed syntax issue in plugin dev runner. ([#914](https://github.com/paperclipai/paperclip/pull/914), @gsxdsm) +- **Process list** — Fixed process list rendering. ([#903](https://github.com/paperclipai/paperclip/pull/903), @gsxdsm) + +## Upgrade Guide + +Ten new database migrations (`0028`–`0037`) will run automatically on startup: + +- **Migrations 0028–0029** add plugin framework tables. +- **Migrations 0030–0037** extend the schema for issue documents, execution workspaces, company logos, cost tracking, and plugin enhancements. + +All migrations are additive (new tables and columns) — no existing data is modified. Standard `paperclipai` startup will apply them automatically. + +If you use the `.env` file, note that the server now falls back to loading `.env` from the current working directory when `.paperclip/.env` is not found. + +## Contributors + +Thank you to everyone who contributed to this release! + +@gsxdsm, @JonCSykes, @leeknowsai, @mvanhorn, @residentagent, @saishankar404, @teknium1, @vkartaviy, @wesseljt diff --git a/scripts/docker-onboard-smoke.sh b/scripts/docker-onboard-smoke.sh index 41c875be..97f6743f 100755 --- a/scripts/docker-onboard-smoke.sh +++ b/scripts/docker-onboard-smoke.sh @@ -7,6 +7,8 @@ HOST_PORT="${HOST_PORT:-3131}" PAPERCLIPAI_VERSION="${PAPERCLIPAI_VERSION:-latest}" DATA_DIR="${DATA_DIR:-$REPO_ROOT/data/docker-onboard-smoke}" HOST_UID="${HOST_UID:-$(id -u)}" +SMOKE_DETACH="${SMOKE_DETACH:-false}" +SMOKE_METADATA_FILE="${SMOKE_METADATA_FILE:-}" PAPERCLIP_DEPLOYMENT_MODE="${PAPERCLIP_DEPLOYMENT_MODE:-authenticated}" PAPERCLIP_DEPLOYMENT_EXPOSURE="${PAPERCLIP_DEPLOYMENT_EXPOSURE:-private}" PAPERCLIP_PUBLIC_URL="${PAPERCLIP_PUBLIC_URL:-http://localhost:${HOST_PORT}}" @@ -18,6 +20,7 @@ CONTAINER_NAME="${IMAGE_NAME//[^a-zA-Z0-9_.-]/-}" LOG_PID="" COOKIE_JAR="" TMP_DIR="" +PRESERVE_CONTAINER_ON_EXIT="false" mkdir -p "$DATA_DIR" @@ -25,7 +28,9 @@ cleanup() { if [[ -n "$LOG_PID" ]]; then kill "$LOG_PID" >/dev/null 2>&1 || true fi - docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true + if [[ "$PRESERVE_CONTAINER_ON_EXIT" != "true" ]]; then + docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true + fi if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then rm -rf "$TMP_DIR" fi @@ -33,6 +38,12 @@ cleanup() { trap cleanup EXIT INT TERM +container_is_running() { + local running + running="$(docker inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null || true)" + [[ "$running" == "true" ]] +} + wait_for_http() { local url="$1" local attempts="${2:-60}" @@ -42,11 +53,36 @@ wait_for_http() { if curl -fsS "$url" >/dev/null 2>&1; then return 0 fi + if ! container_is_running; then + echo "Smoke bootstrap failed: container $CONTAINER_NAME exited before $url became ready" >&2 + docker logs "$CONTAINER_NAME" >&2 || true + return 1 + fi sleep "$sleep_seconds" done + if ! container_is_running; then + echo "Smoke bootstrap failed: container $CONTAINER_NAME exited before readiness check completed" >&2 + docker logs "$CONTAINER_NAME" >&2 || true + fi return 1 } +write_metadata_file() { + if [[ -z "$SMOKE_METADATA_FILE" ]]; then + return 0 + fi + mkdir -p "$(dirname "$SMOKE_METADATA_FILE")" + { + printf 'SMOKE_BASE_URL=%q\n' "$PAPERCLIP_PUBLIC_URL" + printf 'SMOKE_ADMIN_EMAIL=%q\n' "$SMOKE_ADMIN_EMAIL" + printf 'SMOKE_ADMIN_PASSWORD=%q\n' "$SMOKE_ADMIN_PASSWORD" + printf 'SMOKE_CONTAINER_NAME=%q\n' "$CONTAINER_NAME" + printf 'SMOKE_DATA_DIR=%q\n' "$DATA_DIR" + printf 'SMOKE_IMAGE_NAME=%q\n' "$IMAGE_NAME" + printf 'SMOKE_PAPERCLIPAI_VERSION=%q\n' "$PAPERCLIPAI_VERSION" + } >"$SMOKE_METADATA_FILE" +} + generate_bootstrap_invite_url() { local bootstrap_output local bootstrap_status @@ -214,9 +250,12 @@ echo "==> Running onboard smoke container" echo " UI should be reachable at: http://localhost:$HOST_PORT" echo " Public URL: $PAPERCLIP_PUBLIC_URL" echo " Smoke auto-bootstrap: $SMOKE_AUTO_BOOTSTRAP" +echo " Detached mode: $SMOKE_DETACH" echo " Data dir: $DATA_DIR" echo " Deployment: $PAPERCLIP_DEPLOYMENT_MODE/$PAPERCLIP_DEPLOYMENT_EXPOSURE" -echo " Live output: onboard banner and server logs stream in this terminal (Ctrl+C to stop)" +if [[ "$SMOKE_DETACH" != "true" ]]; then + echo " Live output: onboard banner and server logs stream in this terminal (Ctrl+C to stop)" +fi docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true @@ -231,8 +270,10 @@ docker run -d --rm \ -v "$DATA_DIR:/paperclip" \ "$IMAGE_NAME" >/dev/null -docker logs -f "$CONTAINER_NAME" & -LOG_PID=$! +if [[ "$SMOKE_DETACH" != "true" ]]; then + docker logs -f "$CONTAINER_NAME" & + LOG_PID=$! +fi TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/paperclip-onboard-smoke.XXXXXX")" COOKIE_JAR="$TMP_DIR/cookies.txt" @@ -246,4 +287,17 @@ if [[ "$SMOKE_AUTO_BOOTSTRAP" == "true" && "$PAPERCLIP_DEPLOYMENT_MODE" == "auth auto_bootstrap_authenticated_smoke fi +write_metadata_file + +if [[ "$SMOKE_DETACH" == "true" ]]; then + PRESERVE_CONTAINER_ON_EXIT="true" + echo "==> Smoke container ready for automation" + echo " Smoke base URL: $PAPERCLIP_PUBLIC_URL" + echo " Smoke admin credentials: $SMOKE_ADMIN_EMAIL / $SMOKE_ADMIN_PASSWORD" + if [[ -n "$SMOKE_METADATA_FILE" ]]; then + echo " Smoke metadata file: $SMOKE_METADATA_FILE" + fi + exit 0 +fi + wait "$LOG_PID" diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index f1dbd0f5..f5f32be8 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -22,14 +22,11 @@ const TASK_TITLE = "E2E test task"; test.describe("Onboarding wizard", () => { test("completes full wizard flow", async ({ page }) => { - // Navigate to root — should auto-open onboarding when no companies exist await page.goto("/"); - // If the wizard didn't auto-open (company already exists), click the button const wizardHeading = page.locator("h3", { hasText: "Name your company" }); const newCompanyBtn = page.getByRole("button", { name: "New Company" }); - // Wait for either the wizard or the start page await expect( wizardHeading.or(newCompanyBtn) ).toBeVisible({ timeout: 15_000 }); @@ -38,40 +35,28 @@ test.describe("Onboarding wizard", () => { await newCompanyBtn.click(); } - // ----------------------------------------------------------- - // Step 1: Name your company - // ----------------------------------------------------------- await expect(wizardHeading).toBeVisible({ timeout: 5_000 }); - await expect(page.locator("text=Step 1 of 4")).toBeVisible(); const companyNameInput = page.locator('input[placeholder="Acme Corp"]'); await companyNameInput.fill(COMPANY_NAME); - // Click Next const nextButton = page.getByRole("button", { name: "Next" }); await nextButton.click(); - // ----------------------------------------------------------- - // Step 2: Create your first agent - // ----------------------------------------------------------- await expect( page.locator("h3", { hasText: "Create your first agent" }) ).toBeVisible({ timeout: 10_000 }); - await expect(page.locator("text=Step 2 of 4")).toBeVisible(); - // Agent name should default to "CEO" const agentNameInput = page.locator('input[placeholder="CEO"]'); await expect(agentNameInput).toHaveValue(AGENT_NAME); - // Claude Code adapter should be selected by default await expect( page.locator("button", { hasText: "Claude Code" }).locator("..") ).toBeVisible(); - // Select the "Process" adapter to avoid needing a real CLI tool installed - await page.locator("button", { hasText: "Process" }).click(); + await page.getByRole("button", { name: "More Agent Adapter Types" }).click(); + await page.getByRole("button", { name: "Process" }).click(); - // Fill in process adapter fields const commandInput = page.locator('input[placeholder="e.g. node, python"]'); await commandInput.fill("echo"); const argsInput = page.locator( @@ -79,52 +64,34 @@ test.describe("Onboarding wizard", () => { ); await argsInput.fill("hello"); - // Click Next (process adapter skips environment test) await page.getByRole("button", { name: "Next" }).click(); - // ----------------------------------------------------------- - // Step 3: Give it something to do - // ----------------------------------------------------------- await expect( page.locator("h3", { hasText: "Give it something to do" }) ).toBeVisible({ timeout: 10_000 }); - await expect(page.locator("text=Step 3 of 4")).toBeVisible(); - // Clear default title and set our test title const taskTitleInput = page.locator( 'input[placeholder="e.g. Research competitor pricing"]' ); await taskTitleInput.clear(); await taskTitleInput.fill(TASK_TITLE); - // Click Next await page.getByRole("button", { name: "Next" }).click(); - // ----------------------------------------------------------- - // Step 4: Ready to launch - // ----------------------------------------------------------- await expect( page.locator("h3", { hasText: "Ready to launch" }) ).toBeVisible({ timeout: 10_000 }); - await expect(page.locator("text=Step 4 of 4")).toBeVisible(); - // Verify summary displays our created entities await expect(page.locator("text=" + COMPANY_NAME)).toBeVisible(); await expect(page.locator("text=" + AGENT_NAME)).toBeVisible(); await expect(page.locator("text=" + TASK_TITLE)).toBeVisible(); - // Click "Open Issue" - await page.getByRole("button", { name: "Open Issue" }).click(); + await page.getByRole("button", { name: "Create & Open Issue" }).click(); - // Should navigate to the issue page await expect(page).toHaveURL(/\/issues\//, { timeout: 10_000 }); - // ----------------------------------------------------------- - // Verify via API that entities were created - // ----------------------------------------------------------- const baseUrl = page.url().split("/").slice(0, 3).join("/"); - // List companies and find ours const companiesRes = await page.request.get(`${baseUrl}/api/companies`); expect(companiesRes.ok()).toBe(true); const companies = await companiesRes.json(); @@ -133,7 +100,6 @@ test.describe("Onboarding wizard", () => { ); expect(company).toBeTruthy(); - // List agents for our company const agentsRes = await page.request.get( `${baseUrl}/api/companies/${company.id}/agents` ); @@ -146,7 +112,6 @@ test.describe("Onboarding wizard", () => { expect(ceoAgent.role).toBe("ceo"); expect(ceoAgent.adapterType).toBe("process"); - // List issues for our company const issuesRes = await page.request.get( `${baseUrl}/api/companies/${company.id}/issues` ); @@ -159,7 +124,6 @@ test.describe("Onboarding wizard", () => { expect(task.assigneeAgentId).toBe(ceoAgent.id); if (!SKIP_LLM) { - // LLM-dependent: wait for the heartbeat to transition the issue await expect(async () => { const res = await page.request.get( `${baseUrl}/api/issues/${task.id}` diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index 5ae1b677..fd7e8b59 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -23,7 +23,7 @@ export default defineConfig({ // The webServer directive starts `paperclipai run` before tests. // Expects `pnpm paperclipai` to be runnable from repo root. webServer: { - command: `pnpm paperclipai run --yes`, + command: `pnpm paperclipai run`, url: `${BASE_URL}/api/health`, reuseExistingServer: !!process.env.CI, timeout: 120_000, diff --git a/tests/release-smoke/docker-auth-onboarding.spec.ts b/tests/release-smoke/docker-auth-onboarding.spec.ts new file mode 100644 index 00000000..068c4234 --- /dev/null +++ b/tests/release-smoke/docker-auth-onboarding.spec.ts @@ -0,0 +1,146 @@ +import { expect, test, type Page } from "@playwright/test"; + +const ADMIN_EMAIL = + process.env.PAPERCLIP_RELEASE_SMOKE_EMAIL ?? + process.env.SMOKE_ADMIN_EMAIL ?? + "smoke-admin@paperclip.local"; +const ADMIN_PASSWORD = + process.env.PAPERCLIP_RELEASE_SMOKE_PASSWORD ?? + process.env.SMOKE_ADMIN_PASSWORD ?? + "paperclip-smoke-password"; + +const COMPANY_NAME = `Release-Smoke-${Date.now()}`; +const AGENT_NAME = "CEO"; +const TASK_TITLE = "Release smoke task"; + +async function signIn(page: Page) { + await page.goto("/"); + await expect(page).toHaveURL(/\/auth/); + + await page.locator('input[type="email"]').fill(ADMIN_EMAIL); + await page.locator('input[type="password"]').fill(ADMIN_PASSWORD); + await page.getByRole("button", { name: "Sign In" }).click(); + + await expect(page).not.toHaveURL(/\/auth/, { timeout: 20_000 }); +} + +async function openOnboarding(page: Page) { + const wizardHeading = page.locator("h3", { hasText: "Name your company" }); + const startButton = page.getByRole("button", { name: "Start Onboarding" }); + + await expect(wizardHeading.or(startButton)).toBeVisible({ timeout: 20_000 }); + + if (await startButton.isVisible()) { + await startButton.click(); + } + + await expect(wizardHeading).toBeVisible({ timeout: 10_000 }); +} + +test.describe("Docker authenticated onboarding smoke", () => { + test("logs in, completes onboarding, and triggers the first CEO run", async ({ + page, + }) => { + await signIn(page); + await openOnboarding(page); + + await page.locator('input[placeholder="Acme Corp"]').fill(COMPANY_NAME); + await page.getByRole("button", { name: "Next" }).click(); + + await expect( + page.locator("h3", { hasText: "Create your first agent" }) + ).toBeVisible({ timeout: 10_000 }); + + await expect(page.locator('input[placeholder="CEO"]')).toHaveValue(AGENT_NAME); + await page.getByRole("button", { name: "Process" }).click(); + await page.locator('input[placeholder="e.g. node, python"]').fill("echo"); + await page + .locator('input[placeholder="e.g. script.js, --flag"]') + .fill("release smoke"); + await page.getByRole("button", { name: "Next" }).click(); + + await expect( + page.locator("h3", { hasText: "Give it something to do" }) + ).toBeVisible({ timeout: 10_000 }); + await page + .locator('input[placeholder="e.g. Research competitor pricing"]') + .fill(TASK_TITLE); + await page.getByRole("button", { name: "Next" }).click(); + + await expect( + page.locator("h3", { hasText: "Ready to launch" }) + ).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText(COMPANY_NAME)).toBeVisible(); + await expect(page.getByText(AGENT_NAME)).toBeVisible(); + await expect(page.getByText(TASK_TITLE)).toBeVisible(); + + await page.getByRole("button", { name: "Create & Open Issue" }).click(); + await expect(page).toHaveURL(/\/issues\//, { timeout: 10_000 }); + + const baseUrl = new URL(page.url()).origin; + + const companiesRes = await page.request.get(`${baseUrl}/api/companies`); + expect(companiesRes.ok()).toBe(true); + const companies = (await companiesRes.json()) as Array<{ id: string; name: string }>; + const company = companies.find((entry) => entry.name === COMPANY_NAME); + expect(company).toBeTruthy(); + + const agentsRes = await page.request.get( + `${baseUrl}/api/companies/${company!.id}/agents` + ); + expect(agentsRes.ok()).toBe(true); + const agents = (await agentsRes.json()) as Array<{ + id: string; + name: string; + role: string; + adapterType: string; + }>; + const ceoAgent = agents.find((entry) => entry.name === AGENT_NAME); + expect(ceoAgent).toBeTruthy(); + expect(ceoAgent!.role).toBe("ceo"); + expect(ceoAgent!.adapterType).toBe("process"); + + const issuesRes = await page.request.get( + `${baseUrl}/api/companies/${company!.id}/issues` + ); + expect(issuesRes.ok()).toBe(true); + const issues = (await issuesRes.json()) as Array<{ + id: string; + title: string; + assigneeAgentId: string | null; + }>; + const issue = issues.find((entry) => entry.title === TASK_TITLE); + expect(issue).toBeTruthy(); + expect(issue!.assigneeAgentId).toBe(ceoAgent!.id); + + await expect.poll( + async () => { + const runsRes = await page.request.get( + `${baseUrl}/api/companies/${company!.id}/heartbeat-runs?agentId=${ceoAgent!.id}` + ); + expect(runsRes.ok()).toBe(true); + const runs = (await runsRes.json()) as Array<{ + agentId: string; + invocationSource: string; + status: string; + }>; + const latestRun = runs.find((entry) => entry.agentId === ceoAgent!.id); + return latestRun + ? { + invocationSource: latestRun.invocationSource, + status: latestRun.status, + } + : null; + }, + { + timeout: 30_000, + intervals: [1_000, 2_000, 5_000], + } + ).toEqual( + expect.objectContaining({ + invocationSource: "assignment", + status: expect.stringMatching(/^(queued|running|succeeded)$/), + }) + ); + }); +}); diff --git a/tests/release-smoke/playwright.config.ts b/tests/release-smoke/playwright.config.ts new file mode 100644 index 00000000..76e278f9 --- /dev/null +++ b/tests/release-smoke/playwright.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from "@playwright/test"; + +const BASE_URL = + process.env.PAPERCLIP_RELEASE_SMOKE_BASE_URL ?? "http://127.0.0.1:3232"; + +export default defineConfig({ + testDir: ".", + testMatch: "**/*.spec.ts", + timeout: 90_000, + expect: { + timeout: 15_000, + }, + retries: process.env.CI ? 1 : 0, + use: { + baseURL: BASE_URL, + headless: true, + screenshot: "only-on-failure", + trace: "retain-on-failure", + }, + projects: [ + { + name: "chromium", + use: { browserName: "chromium" }, + }, + ], + outputDir: "./test-results", + reporter: [["list"], ["html", { open: "never", outputFolder: "./playwright-report" }]], +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ff1daecf..05aa5381 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,4 +1,3 @@ -import { useEffect, useRef } from "react"; import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; @@ -40,6 +39,7 @@ import { queryKeys } from "./lib/queryKeys"; import { useCompany } from "./context/CompanyContext"; import { useDialog } from "./context/DialogContext"; import { loadLastInboxTab } from "./lib/inbox"; +import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route"; function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) { return ( @@ -175,24 +175,13 @@ function LegacySettingsRedirect() { } function OnboardingRoutePage() { - const { companies, loading } = useCompany(); - const { onboardingOpen, openOnboarding } = useDialog(); + const { companies } = useCompany(); + const { openOnboarding } = useDialog(); const { companyPrefix } = useParams<{ companyPrefix?: string }>(); - const opened = useRef(false); const matchedCompany = companyPrefix ? companies.find((company) => company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase()) ?? null : null; - useEffect(() => { - if (loading || opened.current || onboardingOpen) return; - opened.current = true; - if (matchedCompany) { - openOnboarding({ initialStep: 2, companyId: matchedCompany.id }); - return; - } - openOnboarding(); - }, [companyPrefix, loading, matchedCompany, onboardingOpen, openOnboarding]); - const title = matchedCompany ? `Add another agent to ${matchedCompany.name}` : companies.length > 0 @@ -227,19 +216,22 @@ function OnboardingRoutePage() { function CompanyRootRedirect() { const { companies, selectedCompany, loading } = useCompany(); - const { onboardingOpen } = useDialog(); + const location = useLocation(); if (loading) { return
Loading...
; } - // Keep the first-run onboarding mounted until it completes. - if (onboardingOpen) { - return ; - } - const targetCompany = selectedCompany ?? companies[0] ?? null; if (!targetCompany) { + if ( + shouldRedirectCompanylessRouteToOnboarding({ + pathname: location.pathname, + hasCompanies: false, + }) + ) { + return ; + } return ; } @@ -256,6 +248,14 @@ function UnprefixedBoardRedirect() { const targetCompany = selectedCompany ?? companies[0] ?? null; if (!targetCompany) { + if ( + shouldRedirectCompanylessRouteToOnboarding({ + pathname: location.pathname, + hasCompanies: false, + }) + ) { + return ; + } return ; } @@ -267,16 +267,8 @@ function UnprefixedBoardRedirect() { ); } -function NoCompaniesStartPage({ autoOpen = true }: { autoOpen?: boolean }) { +function NoCompaniesStartPage() { const { openOnboarding } = useDialog(); - const opened = useRef(false); - - useEffect(() => { - if (!autoOpen) return; - if (opened.current) return; - opened.current = true; - openOnboarding(); - }, [autoOpen, openOnboarding]); return (
diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 88e16d09..dbce861e 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -1,7 +1,7 @@ import { useEffect, useState, useRef, useCallback, useMemo } from "react"; -import { useNavigate } from "react-router-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { AdapterEnvironmentTestResult } from "@paperclipai/shared"; +import { useLocation, useNavigate, useParams } from "@/lib/router"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { companiesApi } from "../api/companies"; @@ -30,6 +30,7 @@ import { } from "@paperclipai/adapter-codex-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; +import { resolveRouteOnboardingOptions } from "../lib/onboarding-route"; import { AsciiArtAnimation } from "./AsciiArtAnimation"; import { ChoosePathButton } from "./PathInstructionsModal"; import { HintIcon } from "./agent-config-primitives"; @@ -75,12 +76,29 @@ After that, hire yourself a Founding Engineer agent and then plan the roadmap an export function OnboardingWizard() { const { onboardingOpen, onboardingOptions, closeOnboarding } = useDialog(); - const { selectedCompanyId, companies, setSelectedCompanyId } = useCompany(); + const { companies, setSelectedCompanyId, loading: companiesLoading } = useCompany(); const queryClient = useQueryClient(); const navigate = useNavigate(); + const location = useLocation(); + const { companyPrefix } = useParams<{ companyPrefix?: string }>(); + const [routeDismissed, setRouteDismissed] = useState(false); - const initialStep = onboardingOptions.initialStep ?? 1; - const existingCompanyId = onboardingOptions.companyId; + const routeOnboardingOptions = + companyPrefix && companiesLoading + ? null + : resolveRouteOnboardingOptions({ + pathname: location.pathname, + companyPrefix, + companies, + }); + const effectiveOnboardingOpen = + onboardingOpen || (routeOnboardingOptions !== null && !routeDismissed); + const effectiveOnboardingOptions = onboardingOpen + ? onboardingOptions + : routeOnboardingOptions ?? {}; + + const initialStep = effectiveOnboardingOptions.initialStep ?? 1; + const existingCompanyId = effectiveOnboardingOptions.companyId; const [step, setStep] = useState(initialStep); const [loading, setLoading] = useState(false); @@ -134,27 +152,31 @@ export function OnboardingWizard() { const [createdAgentId, setCreatedAgentId] = useState(null); const [createdIssueRef, setCreatedIssueRef] = useState(null); + useEffect(() => { + setRouteDismissed(false); + }, [location.pathname]); + // Sync step and company when onboarding opens with options. // Keep this independent from company-list refreshes so Step 1 completion // doesn't get reset after creating a company. useEffect(() => { - if (!onboardingOpen) return; - const cId = onboardingOptions.companyId ?? null; - setStep(onboardingOptions.initialStep ?? 1); + if (!effectiveOnboardingOpen) return; + const cId = effectiveOnboardingOptions.companyId ?? null; + setStep(effectiveOnboardingOptions.initialStep ?? 1); setCreatedCompanyId(cId); setCreatedCompanyPrefix(null); }, [ - onboardingOpen, - onboardingOptions.companyId, - onboardingOptions.initialStep + effectiveOnboardingOpen, + effectiveOnboardingOptions.companyId, + effectiveOnboardingOptions.initialStep ]); // Backfill issue prefix for an existing company once companies are loaded. useEffect(() => { - if (!onboardingOpen || !createdCompanyId || createdCompanyPrefix) return; + if (!effectiveOnboardingOpen || !createdCompanyId || createdCompanyPrefix) return; const company = companies.find((c) => c.id === createdCompanyId); if (company) setCreatedCompanyPrefix(company.issuePrefix); - }, [onboardingOpen, createdCompanyId, createdCompanyPrefix, companies]); + }, [effectiveOnboardingOpen, createdCompanyId, createdCompanyPrefix, companies]); // Resize textarea when step 3 is shown or description changes useEffect(() => { @@ -171,7 +193,7 @@ export function OnboardingWizard() { ? queryKeys.agents.adapterModels(createdCompanyId, adapterType) : ["agents", "none", "adapter-models", adapterType], queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType), - enabled: Boolean(createdCompanyId) && onboardingOpen && step === 2 + enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 2 }); const isLocalAdapter = adapterType === "claude_local" || @@ -546,13 +568,16 @@ export function OnboardingWizard() { } } - if (!onboardingOpen) return null; + if (!effectiveOnboardingOpen) return null; return ( { - if (!open) handleClose(); + if (!open) { + setRouteDismissed(true); + handleClose(); + } }} > @@ -762,6 +787,12 @@ export function OnboardingWizard() { icon: Gem, desc: "Local Gemini agent" }, + { + value: "process" as const, + label: "Process", + icon: Terminal, + desc: "Run a local command" + }, { value: "opencode_local" as const, label: "OpenCode", diff --git a/ui/src/lib/onboarding-route.test.ts b/ui/src/lib/onboarding-route.test.ts new file mode 100644 index 00000000..2d2b25c2 --- /dev/null +++ b/ui/src/lib/onboarding-route.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { + isOnboardingPath, + resolveRouteOnboardingOptions, + shouldRedirectCompanylessRouteToOnboarding, +} from "./onboarding-route"; + +describe("isOnboardingPath", () => { + it("matches the global onboarding route", () => { + expect(isOnboardingPath("/onboarding")).toBe(true); + }); + + it("matches a company-prefixed onboarding route", () => { + expect(isOnboardingPath("/pap/onboarding")).toBe(true); + }); + + it("ignores non-onboarding routes", () => { + expect(isOnboardingPath("/pap/dashboard")).toBe(false); + }); +}); + +describe("resolveRouteOnboardingOptions", () => { + it("opens company creation for the global onboarding route", () => { + expect( + resolveRouteOnboardingOptions({ + pathname: "/onboarding", + companies: [], + }), + ).toEqual({ initialStep: 1 }); + }); + + it("opens agent creation when the prefixed company exists", () => { + expect( + resolveRouteOnboardingOptions({ + pathname: "/pap/onboarding", + companyPrefix: "pap", + companies: [{ id: "company-1", issuePrefix: "PAP" }], + }), + ).toEqual({ initialStep: 2, companyId: "company-1" }); + }); + + it("falls back to company creation when the prefixed company is missing", () => { + expect( + resolveRouteOnboardingOptions({ + pathname: "/pap/onboarding", + companyPrefix: "pap", + companies: [], + }), + ).toEqual({ initialStep: 1 }); + }); +}); + +describe("shouldRedirectCompanylessRouteToOnboarding", () => { + it("redirects companyless entry routes into onboarding", () => { + expect( + shouldRedirectCompanylessRouteToOnboarding({ + pathname: "/", + hasCompanies: false, + }), + ).toBe(true); + }); + + it("does not redirect when already on onboarding", () => { + expect( + shouldRedirectCompanylessRouteToOnboarding({ + pathname: "/onboarding", + hasCompanies: false, + }), + ).toBe(false); + }); + + it("does not redirect when companies exist", () => { + expect( + shouldRedirectCompanylessRouteToOnboarding({ + pathname: "/issues", + hasCompanies: true, + }), + ).toBe(false); + }); +}); diff --git a/ui/src/lib/onboarding-route.ts b/ui/src/lib/onboarding-route.ts new file mode 100644 index 00000000..425d1fb6 --- /dev/null +++ b/ui/src/lib/onboarding-route.ts @@ -0,0 +1,51 @@ +type OnboardingRouteCompany = { + id: string; + issuePrefix: string; +}; + +export function isOnboardingPath(pathname: string): boolean { + const segments = pathname.split("/").filter(Boolean); + + if (segments.length === 1) { + return segments[0]?.toLowerCase() === "onboarding"; + } + + if (segments.length === 2) { + return segments[1]?.toLowerCase() === "onboarding"; + } + + return false; +} + +export function resolveRouteOnboardingOptions(params: { + pathname: string; + companyPrefix?: string; + companies: OnboardingRouteCompany[]; +}): { initialStep: 1 | 2; companyId?: string } | null { + const { pathname, companyPrefix, companies } = params; + + if (!isOnboardingPath(pathname)) return null; + + if (!companyPrefix) { + return { initialStep: 1 }; + } + + const matchedCompany = + companies.find( + (company) => + company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase(), + ) ?? null; + + if (!matchedCompany) { + return { initialStep: 1 }; + } + + return { initialStep: 2, companyId: matchedCompany.id }; +} + +export function shouldRedirectCompanylessRouteToOnboarding(params: { + pathname: string; + hasCompanies: boolean; +}): boolean { + return !params.hasCompanies && !isOnboardingPath(params.pathname); +}