diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..16cdc57 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +# EditorConfig — https://editorconfig.org +# Keep editors aligned with the Prettier config (.prettierrc). +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +# Markdown uses two trailing spaces for hard line breaks — don't strip them. +[*.md] +trim_trailing_whitespace = false diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..4a865eb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,63 @@ +name: Bug report +description: Report a problem with the dcd CLI or dcd-mcp server +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a bug! Please fill in the details below. + + ⚠️ **Do not report security vulnerabilities here** — see our + [Security Policy](https://github.com/devicecloud-dev/dcd-cli/blob/dev/SECURITY.md). + - type: textarea + id: what-happened + attributes: + label: What happened? + description: A clear description of the bug, including what you expected to happen instead. + validations: + required: true + - type: textarea + id: repro + attributes: + label: Steps to reproduce + description: The exact `dcd` command(s) you ran and what followed. Redact any API keys. + placeholder: | + 1. Run `dcd cloud --apiKey *** app.apk flows/` + 2. ... + 3. See error + validations: + required: true + - type: input + id: version + attributes: + label: CLI version + description: Output of `dcd --version`. + placeholder: "e.g. 5.0.0" + validations: + required: true + - type: dropdown + id: os + attributes: + label: Operating system + options: + - macOS + - Linux + - Windows + - Other (note in description) + validations: + required: true + - type: input + id: install + attributes: + label: How did you install dcd? + placeholder: "binary (curl/irm), npm global, npx, …" + validations: + required: false + - type: textarea + id: logs + attributes: + label: Logs / output + description: Relevant output. Re-run with more detail if you can. This is automatically formatted as code. + render: shell + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..8769523 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: Questions & help + url: https://discord.gg/gm3mJwcNw8 + about: For usage questions and general help, ask in our Discord rather than opening an issue. + - name: Documentation + url: https://docs.devicecloud.dev + about: Check the docs for installation, usage, and command reference. + - name: Report a security vulnerability + url: https://github.com/devicecloud-dev/dcd-cli/blob/dev/SECURITY.md + about: Do not file security issues publicly — email security@devicecloud.dev (see our Security Policy). diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..ece12e5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,35 @@ +name: Feature request +description: Suggest an idea or improvement for the dcd CLI or dcd-mcp server +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: Thanks for the suggestion! Please describe the problem before the solution. + - type: textarea + id: problem + attributes: + label: What problem are you trying to solve? + description: What are you trying to do, and where does the CLI get in the way today? + validations: + required: true + - type: textarea + id: solution + attributes: + label: Proposed solution + description: What would you like to happen? A concrete command/flag/output sketch helps. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Other approaches or workarounds you've thought about. + validations: + required: false + - type: textarea + id: context + attributes: + label: Additional context + description: Anything else — links, screenshots, related issues. + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..288cc94 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,36 @@ + + +## What & why + + + +## Type of change + + + +- [ ] `fix` — bug fix +- [ ] `feat` — new feature +- [ ] `perf` — performance improvement +- [ ] `refactor` — code change that's neither a fix nor a feature +- [ ] `docs` — documentation only +- [ ] `chore` / `ci` / `build` / `test` — tooling, no user-facing change +- [ ] Breaking change (title has `!` or PR notes a `BREAKING CHANGE:`) + +## Checklist + +- [ ] PR title follows the Conventional Commits format (see comment above) +- [ ] `pnpm lint` passes +- [ ] `pnpm typecheck` passes +- [ ] `pnpm build` passes +- [ ] I have **not** bumped the version or edited `CHANGELOG.md` (release-please handles this) +- [ ] I have signed the CLA (the bot will prompt on first contribution) +- [ ] Docs / `README.md` / `STYLE_GUIDE.md` updated if behaviour or output changed + +## How to test + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e93cbd3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,35 @@ +version: 2 +updates: + # npm / pnpm dependencies. + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + target-branch: dev + open-pull-requests-limit: 10 + commit-message: + # Conventional Commit prefix so the squashed PR title matches our PR-title + # lint and release-please picks dependency bumps into the changelog. + prefix: deps + prefix-development: chore + groups: + # Collapse the noise: one PR for all non-major updates. + minor-and-patch: + update-types: + - minor + - patch + + # GitHub Actions used by our workflows. + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + target-branch: dev + commit-message: + prefix: ci + groups: + # One PR per week for ALL action bumps (including majors). Actions are + # low-risk and quick to eyeball together; no need for a PR each. + actions: + patterns: + - "*" diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml new file mode 100644 index 0000000..24fe627 --- /dev/null +++ b/.github/workflows/cla.yml @@ -0,0 +1,85 @@ +name: CLA Assistant + +# Gates merges on a signed Contributor License Agreement. +# +# Uses CLA Assistant Lite: signatures are stored as a JSON file committed to a +# branch of THIS repo (no third-party service holds the data). Contributors sign +# by commenting the configured phrase on their PR; the action records it and +# flips the check green. +# +# ACTION SOURCE: devicecloud-dev/cla-assistant-action, our fork of the upstream +# contributor-assistant/github-action, which was archived read-only on +# 2026-03-23. We forked because GitHub's Node 20 deprecation began force-running +# node20 actions on Node 24, under which the upstream step does its work, logs +# "All contributors have signed the CLA", and THEN exits non-zero — failing a +# required check on every PR (first hit 2026-09-02, last green 2026-08-31). The +# fork's only change is `using: node24`; dist is unmodified. See its FORK.md. +# +# The fork must stay PUBLIC: this repo is public, and a public repo's workflow +# cannot resolve an action from a private one — it fails at resolution with +# "Unable to resolve action ... not found", before any CLA logic runs, even with +# the org access policy set. Do not flip it private. +# +# AUTH: mints a token from the shared automation GitHub App (the same App +# release-please uses), so signature commits show as the bot and there's no +# personal token to expire. +# +# SETUP REQUIRED before this enforces anything: +# 1. Create/install the automation GitHub App (Contents R/W, Pull requests R/W, +# Issues R/W) and add BOT_APP_ID + BOT_APP_PRIVATE_KEY repo secrets — the +# same secrets release-please uses. +# 2. Create the `cla-signatures` branch (empty orphan) so the action has +# somewhere to write `signatures/version1/cla.json`. +# 3. Finalise CLA.md (legal review) — it's the document contributors agree to. +# +# Until the App secrets exist the CLA step self-skips, so the check is green +# (not failing) on every PR and auto-activates once they're set. +on: + issue_comment: + types: [created] + pull_request_target: + types: [opened, closed, synchronize] + +permissions: + actions: write + contents: write + pull-requests: write + statuses: write + +jobs: + cla: + runs-on: ubuntu-latest + # Empty until the automation App secrets are configured (see SETUP above). + # While empty, the steps below self-skip so this check passes (green) instead + # of failing on every PR with "Branch cla-signatures not found". + env: + HAS_APP: ${{ secrets.BOT_APP_ID != '' }} + # Only act on the signature comment or on PR events (not every comment). + if: (github.event.issue.pull_request && contains(github.event.comment.body, 'I have read the CLA Document and I hereby sign the CLA')) || github.event_name == 'pull_request_target' + steps: + - uses: actions/create-github-app-token@v3 + id: app-token + if: env.HAS_APP == 'true' + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + - uses: devicecloud-dev/cla-assistant-action@v2.6.2 + if: env.HAS_APP == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PERSONAL_ACCESS_TOKEN: ${{ steps.app-token.outputs.token }} + with: + path-to-signatures: "signatures/version1/cla.json" + path-to-document: "https://github.com/devicecloud-dev/dcd-cli/blob/dev/CLA.md" + branch: "cla-signatures" + # Do NOT lock the PR on merge (the action's default is true). release-please + # comments on its release PR *after* merge; a locked conversation makes that + # comment fail and takes down the whole Release job (npm publish + binaries + # never run). Keeping this false is load-bearing for the release pipeline. + lock-pullrequest-aftermerge: false + # Internal maintainers (covered by employment/CCLA) + bots skip the prompt. + allowlist: riglar,finalerock44,dependabot[bot],renovate[bot],*[bot] + # Customise the bot's prompts if desired: + custom-notsigned-prcomment: "Thanks for your contribution! Please sign our Contributor License Agreement before we can merge. Comment the line below to sign:" + custom-pr-sign-comment: "I have read the CLA Document and I hereby sign the CLA" + custom-allsigned-prcomment: "All contributors have signed the CLA. ✍️ ✅" diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index c396185..1eac5d0 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -12,12 +12,11 @@ on: jobs: claude-review: - # Optional: Filter by PR author - # if: | - # github.event.pull_request.user.login == 'external-contributor' || - # github.event.pull_request.user.login == 'new-developer' || - # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' - + # Skip PRs we shouldn't (or can't) review: + # - Dependabot / forks: no CLAUDE_CODE_OAUTH_TOKEN, so the action would fail. + # - release-please release PRs: just version bumps + changelog — nothing to + # review, and it must never block a release. + if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' && !startsWith(github.head_ref, 'release-please--') }} runs-on: ubuntu-latest permissions: contents: read @@ -30,7 +29,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 1 diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 68c8f95..56235d1 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -26,7 +26,7 @@ jobs: actions: read # Required for Claude to read CI results on PRs steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 1 diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml index 71d6ae3..539f1c1 100644 --- a/.github/workflows/cli-ci.yml +++ b/.github/workflows/cli-ci.yml @@ -2,9 +2,11 @@ name: CLI CI on: push: - branches: [ dev ] + branches: [ dev, production ] pull_request: - branches: [ dev ] + # `production` is included so the dev→production promotion PR is also gated + # by lint/typecheck/build (and is required by the production ruleset). + branches: [ dev, production ] workflow_dispatch: permissions: @@ -15,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout (full history) - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: # Full history so gitleaks scans every commit, not just the tip. fetch-depth: 0 @@ -29,46 +31,45 @@ jobs: run: | curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ | tar -xz gitleaks - ./gitleaks git . --redact --verbose --no-banner + # --log-opts=HEAD scopes the scan to commits reachable from what's + # checked out: the whole history of this branch (or of the PR merge + # commit, i.e. base + PR commits), but NOT unrelated branches. + # `fetch-depth: 0` fetches refs/heads/* — every branch — and gitleaks + # otherwise scans all of them, so an open branch that legitimately + # commits a high-entropy value plus its own .gitleaks.toml allowlist + # would fail every OTHER branch's scan, which is judged against the + # allowlist at its own tip. Each branch is still fully scanned by its + # own PR, and pushes to dev/production scan their full history. + ./gitleaks git . --redact --verbose --no-banner --log-opts=HEAD lint-and-test: runs-on: ubuntu-latest - # The mock-api lives in the private moropo-com/dcd repo, checked out via an - # SSH deploy key. GitHub does NOT expose secrets to pull_request workflows - # triggered from forks, so that checkout (and the integration tests that need - # it) can only run for same-repo events. Fork PRs still run lint/typecheck/build. - env: - HAS_PRIVATE_ACCESS: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} - + # This repo is PUBLIC and runs no step that reaches into the private + # devicecloud-dev/dcd repo. It used to check out that repo's mock-api over an + # SSH deploy key to run test/integration/*, which meant a private-repo + # credential lived in a public repo's secrets and the API's OpenAPI spec was + # pulled onto the runner on every same-repo PR. dcd#1036 deleted that mock-api; + # rather than re-point at it, the linkage is gone. + # + # The consequence is deliberate: test/integration/* does NOT run here, and + # neither does the swagger contract-drift check it provided (spec drift used to + # surface as a Prism 422). Only test/unit/* runs — pure, no backend. To run the + # integration suite locally, point MOCK_API_DIR at a mock; see CLAUDE.md. steps: - name: Checkout CLI - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: path: cli - - name: Checkout dcd (mock-api) - if: env.HAS_PRIVATE_ACCESS == 'true' - uses: actions/checkout@v5 - with: - repository: moropo-com/dcd - path: dcd - ssh-key: ${{ secrets.DCD_SSH_DEPLOY_KEY }} - # api/swagger.json is a file, which cone-mode sparse checkout rejects - # as of git 2.51 ("is not a directory") — use non-cone patterns. - sparse-checkout-cone-mode: false - sparse-checkout: | - /mock-api/ - /api/swagger.json - - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6.1.0 with: version: 10 run_install: false - name: Setup Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@v7 with: node-version: '22' cache: 'pnpm' @@ -78,11 +79,6 @@ jobs: working-directory: ./cli run: pnpm install --frozen-lockfile - - name: Install Mock API dependencies - if: env.HAS_PRIVATE_ACCESS == 'true' - working-directory: ./dcd/mock-api - run: pnpm install --frozen-lockfile - - name: Run CLI linter working-directory: ./cli run: pnpm lint @@ -91,16 +87,12 @@ jobs: working-directory: ./cli run: pnpm typecheck - - name: Run CLI tests - if: env.HAS_PRIVATE_ACCESS == 'true' + - name: Run CLI unit tests working-directory: ./cli - env: - MOCK_API_DIR: ${{ github.workspace }}/dcd/mock-api - run: pnpm test + run: pnpm test:unit - - name: Skip integration tests (fork PR — no mock-api access) - if: env.HAS_PRIVATE_ACCESS != 'true' - run: echo "::notice::Integration tests skipped — the mock-api (private moropo-com/dcd) is not accessible from fork PRs. Lint, typecheck, and build still ran." + - name: Note skipped integration tests + run: echo "::notice::Integration tests are not run in CI — they need a mock of the dcd API, and this public repo does not reach into the private one. Lint, typecheck, unit tests, build and audit all ran." - name: Build CLI working-directory: ./cli diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index dbee47d..feed479 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -24,14 +24,14 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 # Setup .npmrc file to publish to npm - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6.1.0 with: run_install: false - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v7 with: node-version: '22.x' registry-url: 'https://registry.npmjs.org' diff --git a/.github/workflows/pr-title-lint.yml b/.github/workflows/pr-title-lint.yml new file mode 100644 index 0000000..e3f8cb8 --- /dev/null +++ b/.github/workflows/pr-title-lint.yml @@ -0,0 +1,43 @@ +name: PR Title + +# Enforces Conventional Commits on the PR *title*. Because PRs are squash-merged +# with the title as the commit subject, this is what release-please parses to +# compute version bumps and the changelog — so the allowed types below must stay +# in sync with `changelog-sections` in release-please-config.json. +# +# Uses pull_request_target so it also runs (and reports a required status check) +# on PRs from forks. It only reads the title — no untrusted code is checked out. +on: + pull_request_target: + types: + - opened + - edited + - synchronize + - reopened + +permissions: + pull-requests: read + +jobs: + validate: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + # Keep in lockstep with release-please-config.json changelog-sections. + types: | + feat + fix + perf + deps + revert + refactor + docs + chore + test + ci + build + style diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index 32467ed..16bb21b 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -24,14 +24,14 @@ jobs: permissions: contents: write # Required to upload release assets steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6.1.0 with: run_install: false - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v7 with: node-version: '22.x' cache: 'pnpm' diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 284b290..3913334 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -22,15 +22,29 @@ jobs: release-please-prod: if: github.ref_name == 'production' runs-on: ubuntu-latest + # Empty until the automation GitHub App secrets are configured (the same App + # powers the CLA workflow). We use an App token (not GITHUB_TOKEN) so the + # Release PR triggers CI / PR-title / CLA + # checks — PRs opened by GITHUB_TOKEN do not, which would deadlock branch + # protection. Falls back to GITHUB_TOKEN (today's behaviour) until the App + # is set up, so this is safe to merge before then. + env: + BOT_APP_ID: ${{ secrets.BOT_APP_ID }} outputs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} version: ${{ steps.release.outputs.version }} steps: - - uses: googleapis/release-please-action@v4 + - uses: actions/create-github-app-token@v3 + id: app-token + if: env.BOT_APP_ID != '' + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + - uses: googleapis/release-please-action@v5 id: release with: - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} target-branch: production config-file: release-please-config.json manifest-file: .release-please-manifest.json @@ -38,15 +52,25 @@ jobs: release-please-beta: if: github.ref_name == 'dev' runs-on: ubuntu-latest + # See release-please-prod above for why this uses an App token with a + # GITHUB_TOKEN fallback. + env: + BOT_APP_ID: ${{ secrets.BOT_APP_ID }} outputs: release_created: ${{ steps.release.outputs.release_created }} tag_name: ${{ steps.release.outputs.tag_name }} version: ${{ steps.release.outputs.version }} steps: - - uses: googleapis/release-please-action@v4 + - uses: actions/create-github-app-token@v3 + id: app-token + if: env.BOT_APP_ID != '' + with: + app-id: ${{ secrets.BOT_APP_ID }} + private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} + - uses: googleapis/release-please-action@v5 id: release with: - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} target-branch: dev config-file: release-please-config-beta.json manifest-file: .release-please-manifest-beta.json diff --git a/.gitleaks.toml b/.gitleaks.toml index eb5cdaf..555ff26 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -1,20 +1,33 @@ # Gitleaks configuration — extends the default ruleset. # -# The only allowlisted secrets are the two PUBLIC Supabase anon keys committed -# in src/config/environments.ts. Those JWTs are anon-role keys, designed to be -# embedded in client code and gated by RLS (see the doc comment in that file) — -# they are intentionally not secret. +# The only allowlisted secrets are PUBLIC key material committed in +# src/config/environments.ts: +# - the two Supabase anon keys — anon-role JWTs, designed to be embedded in +# client code and gated by RLS (see the doc comment in that file). +# - the two KEK public keys (prod + dev) for client-side binary envelope +# encryption — base64 of the raw 32-byte X25519 PUBLIC half. They can only +# *encrypt*; the private halves live solely on the API, never in this repo. +# Both are intentionally not secret; gitleaks flags them on entropy alone +# (generic-api-key), not because it recognizes them as credentials. # # They are allowlisted by EXACT VALUE, deliberately not by file path or by the -# whole `jwt` rule: a Supabase service_role key is also a JWT, so a path/rule -# allowlist would let a genuinely sensitive key pasted into the same file slip -# through. Matching exact values keeps that detection intact. +# whole `jwt` / `generic-api-key` rule: a Supabase service_role key is also a +# JWT, and a KEK *private* key is the same shape as its public half, so a +# path/rule allowlist would let a genuinely sensitive key pasted into the same +# file slip through. Matching exact values keeps that detection intact. +# +# When a KEK is rotated, replace the corresponding value below — do not simply +# append, or the retired key stops being distinguishable from a live secret. [extend] useDefault = true [allowlist] -description = "Public Supabase anon keys (safe to commit, gated by RLS)" +description = "Public Supabase anon keys and KEK public keys (safe to commit)" regexes = [ '''eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBneWRucGhiaW1ldGluc2dma2JvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDc1OTQzNDYsImV4cCI6MjAyMzE3MDM0Nn0\.hAYOMFxxwX1exkQkY9xyQJGC_GhGnyogkj2N-kBkMI8''', '''eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxibXNvd2VodGp3bnFsdXJwZW1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDkyMTg0ODcsImV4cCI6MjAyNDc5NDQ4N30\.zeLTMAuZ_WwYvGdeP0kdvL_Zrs-RQee5APPyxmWq7qQ''', + # prod kekPublicKey v1 + '''wtfyWEwK7nJzwI4PD\+9RAW8jxIR1u8kMQq2IhsrVnH4=''', + # dev kekPublicKey v1 + '''RgcToF/OJpcQI9koYvSvtj/WLaebfcN4v5GJoqtr/00=''', ] diff --git a/.mocharc.json b/.mocharc.json index b771cc0..160f481 100644 --- a/.mocharc.json +++ b/.mocharc.json @@ -1,5 +1,5 @@ { - "node-option": ["import=tsx"], + "node-option": ["import=tsx/esm"], "extensions": ["ts"], "watch-extensions": ["ts"], "recursive": true, diff --git a/.release-please-manifest-beta.json b/.release-please-manifest-beta.json index 86cb1d6..52d1780 100644 --- a/.release-please-manifest-beta.json +++ b/.release-please-manifest-beta.json @@ -1,3 +1,3 @@ { - ".": "5.0.0-beta.1" + ".": "5.5.0-beta.4" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 25de72f..311eab8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,235 @@ # Changelog +## [5.5.0-beta.4](https://github.com/devicecloud-dev/dcd-cli/compare/v5.5.0-beta.3...v5.5.0-beta.4) (2026-09-14) + + +### Bug Fixes + +* register tsx's ESM loader only so mocha 12.0.1 can load find-up@8 ([#157](https://github.com/devicecloud-dev/dcd-cli/issues/157)) ([004b478](https://github.com/devicecloud-dev/dcd-cli/commit/004b47838983a6e1c6caca060e7c43f85a737f4a)) + + +### Dependencies + +* bump the minor-and-patch group with 7 updates ([#156](https://github.com/devicecloud-dev/dcd-cli/issues/156)) ([1e6e2c0](https://github.com/devicecloud-dev/dcd-cli/commit/1e6e2c087817218d9fc8da69a95089253b779d39)) + +## [5.5.0-beta.3](https://github.com/devicecloud-dev/dcd-cli/compare/v5.5.0-beta.2...v5.5.0-beta.3) (2026-09-14) + + +### Features + +* **cloud:** add --render-engine for the emulator software renderer ([#153](https://github.com/devicecloud-dev/dcd-cli/issues/153)) ([a2888fd](https://github.com/devicecloud-dev/dcd-cli/commit/a2888fd0154ce637a3a7fd02dc04431114df99e0)) + +## [5.5.0-beta.2](https://github.com/devicecloud-dev/dcd-cli/compare/v5.5.0-beta.1...v5.5.0-beta.2) (2026-09-14) + + +### Features + +* add Pixel 8, Pixel 10 family and Pixel 11 device slugs ([#151](https://github.com/devicecloud-dev/dcd-cli/issues/151)) ([dac5d1e](https://github.com/devicecloud-dev/dcd-cli/commit/dac5d1ec88c3488802e244338684cfc0f1e2d50f)) + +## [5.5.0-beta.1](https://github.com/devicecloud-dev/dcd-cli/compare/v5.4.1-beta.1...v5.5.0-beta.1) (2026-09-10) + + +### Features + +* **notices:** expose platform, device and Maestro version to notice … ([#147](https://github.com/devicecloud-dev/dcd-cli/issues/147)) ([9d9e413](https://github.com/devicecloud-dev/dcd-cli/commit/9d9e4135f4ca47dd51ae7118e038493e16c2a522)) +* **notices:** expose platform, device and Maestro version to notice targeting; include notices in --json output ([9d9e413](https://github.com/devicecloud-dev/dcd-cli/commit/9d9e4135f4ca47dd51ae7118e038493e16c2a522)) + +## [5.4.1-beta.1](https://github.com/devicecloud-dev/dcd-cli/compare/v5.4.0-beta.0...v5.4.1-beta.1) (2026-09-10) + + +### Features + +* **device:** add iOS 27 and the iPhone 17 family ([#131](https://github.com/devicecloud-dev/dcd-cli/issues/131)) ([1333960](https://github.com/devicecloud-dev/dcd-cli/commit/133396051511997bd03efe0aeaf2930dd74c83de)) +* **device:** drop the iPhone 17 family ([#134](https://github.com/devicecloud-dev/dcd-cli/issues/134)) ([38f6dfa](https://github.com/devicecloud-dev/dcd-cli/commit/38f6dfa8c6724be9ce709ef6845e97911a4ee0da)) + + +### Bug Fixes + +* **ci:** point the CLA check at our node24 fork ([#135](https://github.com/devicecloud-dev/dcd-cli/issues/135)) ([3222dda](https://github.com/devicecloud-dev/dcd-cli/commit/3222ddaabc6a133eb88b73e768d992a36ae89118)) +* **deps:** adopt bplist-parser 0.5 named exports ([#143](https://github.com/devicecloud-dev/dcd-cli/issues/143)) ([9b5f751](https://github.com/devicecloud-dev/dcd-cli/commit/9b5f7510ea1b9f9b1277763e040b84517c91b3c4)) +* **deps:** refresh audit overrides to the patched versions ([#141](https://github.com/devicecloud-dev/dcd-cli/issues/141)) ([7ccb0c9](https://github.com/devicecloud-dev/dcd-cli/commit/7ccb0c98e1834d2689f53c001383c8050c99e17f)) + + +### Dependencies + +* bump the minor-and-patch group across 1 directory with 4 updates ([#133](https://github.com/devicecloud-dev/dcd-cli/issues/133)) ([0d2234a](https://github.com/devicecloud-dev/dcd-cli/commit/0d2234a55965b4840c6de1a8ddcc3e4933aab237)) +* bump the minor-and-patch group across 1 directory with 5 updates ([#144](https://github.com/devicecloud-dev/dcd-cli/issues/144)) ([9a4ad49](https://github.com/devicecloud-dev/dcd-cli/commit/9a4ad49acad68d90f27c947bcb43db464bd1ef43)) + + +### Miscellaneous + +* release 5.4.1-beta.1 ([#137](https://github.com/devicecloud-dev/dcd-cli/issues/137)) ([fc43dc6](https://github.com/devicecloud-dev/dcd-cli/commit/fc43dc6e62a265b9860b92b5a8ace750bcead393)) + +## [5.4.0-beta.0](https://github.com/devicecloud-dev/dcd-cli/compare/v5.3.1-beta.2...v5.4.0-beta.0) (2026-08-24) + + +### ⚠ BREAKING CHANGES + +* remove iOS 16 ([#126](https://github.com/devicecloud-dev/dcd-cli/issues/126)) + +### Features + +* remove iOS 16 ([#126](https://github.com/devicecloud-dev/dcd-cli/issues/126)) ([fcfa524](https://github.com/devicecloud-dev/dcd-cli/commit/fcfa524ff5213f1ec8ff4b86ef61826c78d6e875)) + + +### Dependencies + +* bump the minor-and-patch group with 6 updates ([#121](https://github.com/devicecloud-dev/dcd-cli/issues/121)) ([1c07fc1](https://github.com/devicecloud-dev/dcd-cli/commit/1c07fc14a48e7cc29c6deb56f0ad4aa533794f25)) + + +### Miscellaneous + +* release the iOS 16 removal as 5.4.0, not 6.0.0 ([#127](https://github.com/devicecloud-dev/dcd-cli/issues/127)) ([a60611d](https://github.com/devicecloud-dev/dcd-cli/commit/a60611d388fd6c570a5548bd3ece997afc4ae926)) + +## [5.3.1-beta.2](https://github.com/devicecloud-dev/dcd-cli/compare/v5.3.1-beta.1...v5.3.1-beta.2) (2026-08-13) + + +### Bug Fixes + +* **cloud:** reject malformed executionOrder instead of silently runni… ([#117](https://github.com/devicecloud-dev/dcd-cli/issues/117)) ([1973a42](https://github.com/devicecloud-dev/dcd-cli/commit/1973a42a1c4c01d0db29a106aa9c6a8f82da6853)) +* **cloud:** reject malformed executionOrder instead of silently running in parallel ([1973a42](https://github.com/devicecloud-dev/dcd-cli/commit/1973a42a1c4c01d0db29a106aa9c6a8f82da6853)), closes [#110](https://github.com/devicecloud-dev/dcd-cli/issues/110) + +## [5.3.1-beta.1](https://github.com/devicecloud-dev/dcd-cli/compare/v5.2.0-beta.4...v5.3.1-beta.1) (2026-08-10) + + +### Bug Fixes + +* **cloud:** exclude config-shaped files from flow discovery ([#114](https://github.com/devicecloud-dev/dcd-cli/issues/114)) ([13d01ee](https://github.com/devicecloud-dev/dcd-cli/commit/13d01eee5d0146049373011f304be2647e7f510b)), closes [#99](https://github.com/devicecloud-dev/dcd-cli/issues/99) + + +### Dependencies + +* bump the minor-and-patch group with 5 updates ([#112](https://github.com/devicecloud-dev/dcd-cli/issues/112)) ([2223dd4](https://github.com/devicecloud-dev/dcd-cli/commit/2223dd48bcb8e19d28a842df807939f5bc9879c5)) + + +### Miscellaneous + +* pin the next dev beta to 5.3.1-beta.1 ([#116](https://github.com/devicecloud-dev/dcd-cli/issues/116)) ([6858220](https://github.com/devicecloud-dev/dcd-cli/commit/6858220e3dc54ae5e505e4dd84aa3fedf3ca2e6e)) + +## [5.2.0-beta.4](https://github.com/devicecloud-dev/dcd-cli/compare/v5.2.0-beta.3...v5.2.0-beta.4) (2026-08-06) + + +### Features + +* **artifacts:** prefer server-assembled bundle delivery for downloads ([#93](https://github.com/devicecloud-dev/dcd-cli/issues/93)) ([f6fcfaf](https://github.com/devicecloud-dev/dcd-cli/commit/f6fcfafa4ce5935decd15952107cd3ac1ebaad7b)) +* client-side envelope encryption of binaries, flow zips & env vars ([#94](https://github.com/devicecloud-dev/dcd-cli/issues/94)) ([a34d9d4](https://github.com/devicecloud-dev/dcd-cli/commit/a34d9d4516a985e92e4e07ec54f703358af45f6e)) +* **device:** add Android API level 37 (Android 17) ([#107](https://github.com/devicecloud-dev/dcd-cli/issues/107)) ([3d24435](https://github.com/devicecloud-dev/dcd-cli/commit/3d2443533119f9a41fe1aa46967c941818584c73)) +* **upload:** dedup encrypted binaries on the plaintext hash ([#101](https://github.com/devicecloud-dev/dcd-cli/issues/101)) ([6e244a9](https://github.com/devicecloud-dev/dcd-cli/commit/6e244a90dedff47c0bba5b1041fa6da4fe2e9d77)) + + +### Bug Fixes + +* **deps:** resolve pnpm audit failures in transitive dependencies ([#89](https://github.com/devicecloud-dev/dcd-cli/issues/89)) ([cc5ee4d](https://github.com/devicecloud-dev/dcd-cli/commit/cc5ee4d4595fd4b3e6abd9e3ad0d50b2a7db84ca)) +* **deps:** resolve three new transitive security advisories ([#106](https://github.com/devicecloud-dev/dcd-cli/issues/106)) ([f7934b0](https://github.com/devicecloud-dev/dcd-cli/commit/f7934b0743a4bf561f876082f124a87acc41041a)) + + +### Dependencies + +* bump chalk from 5.6.2 to 6.0.0 ([#98](https://github.com/devicecloud-dev/dcd-cli/issues/98)) ([1b29d2d](https://github.com/devicecloud-dev/dcd-cli/commit/1b29d2d5ec58d391c04dc29ea2e4c989a0a66e0c)) +* bump the minor-and-patch group across 1 directory with 5 updates ([#100](https://github.com/devicecloud-dev/dcd-cli/issues/100)) ([47b9d90](https://github.com/devicecloud-dev/dcd-cli/commit/47b9d905daa49d4d8491b1a8d710545e05822bb0)) +* bump the minor-and-patch group across 1 directory with 7 updates ([#92](https://github.com/devicecloud-dev/dcd-cli/issues/92)) ([f333be9](https://github.com/devicecloud-dev/dcd-cli/commit/f333be97d848e2cd40cbff124584de5af4f89a88)) +* patch js-yaml and brace-expansion DoS advisories ([#95](https://github.com/devicecloud-dev/dcd-cli/issues/95)) ([a4f11ff](https://github.com/devicecloud-dev/dcd-cli/commit/a4f11ff8d771de213b5acdb4f6fcf40f665e5d7b)) + + +### Code Refactoring + +* **cloud:** remove mitmproxy flags ([#102](https://github.com/devicecloud-dev/dcd-cli/issues/102)) ([3888fc5](https://github.com/devicecloud-dev/dcd-cli/commit/3888fc5cb0ea06828ed94c2d672e0215492f5147)) + +## [5.2.0-beta.3](https://github.com/devicecloud-dev/dcd-cli/compare/v5.2.0-beta.2...v5.2.0-beta.3) (2026-07-13) + + +### Bug Fixes + +* **cloud:** refuse a device matrix on an API that cannot honour it ([#80](https://github.com/devicecloud-dev/dcd-cli/issues/80)) ([5560158](https://github.com/devicecloud-dev/dcd-cli/commit/5560158389440735354be963098a2704a8610040)) + +## [5.2.0-beta.2](https://github.com/devicecloud-dev/dcd-cli/compare/v5.2.0-beta.1...v5.2.0-beta.2) (2026-07-13) + + +### Code Refactoring + +* **cloud:** rename --ios-config/--android-config to --ios-device-matrix/--android-device-matrix ([#77](https://github.com/devicecloud-dev/dcd-cli/issues/77)) ([fc3ea3f](https://github.com/devicecloud-dev/dcd-cli/commit/fc3ea3f9fa41f51a640827dbe845deca7b355924)) + +## [5.2.0-beta.1](https://github.com/devicecloud-dev/dcd-cli/compare/v5.0.1-beta.1...v5.2.0-beta.1) (2026-07-13) + + +### Features + +* **cloud:** device matrix via repeated --ios-config/--android-config… ([#75](https://github.com/devicecloud-dev/dcd-cli/issues/75)) ([b78a1dc](https://github.com/devicecloud-dev/dcd-cli/commit/b78a1dc92f61428e6c42a914c0779886d3713e79)) + + +### Bug Fixes + +* recover cleanly when the stored session is dead ([#72](https://github.com/devicecloud-dev/dcd-cli/issues/72)) ([7fd7748](https://github.com/devicecloud-dev/dcd-cli/commit/7fd7748632fdce2a0f417ee9af73243de5d4b1a8)) + + +### Dependencies + +* bump the minor-and-patch group across 1 directory with 9 updates ([#71](https://github.com/devicecloud-dev/dcd-cli/issues/71)) ([cfd9fe2](https://github.com/devicecloud-dev/dcd-cli/commit/cfd9fe23fcee003535f73ab72754eb5aeba5cec5)) + + +### Code Refactoring + +* route notice rendering through ui (add ui.deprecation) ([#66](https://github.com/devicecloud-dev/dcd-cli/issues/66)) ([1d331b4](https://github.com/devicecloud-dev/dcd-cli/commit/1d331b4e5e1e02cb86615df6e0315a5171e012ec)) + + +### Miscellaneous + +* release 5.2.0-beta.1 ([#76](https://github.com/devicecloud-dev/dcd-cli/issues/76)) ([bc9c61f](https://github.com/devicecloud-dev/dcd-cli/commit/bc9c61f2fc20074980798ce155d14b0571ddbecb)) + +## [5.0.1-beta.1](https://github.com/devicecloud-dev/dcd-cli/compare/v5.0.0-beta.4...v5.0.1-beta.1) (2026-06-26) + + +### Bug Fixes + +* notices render polish ([#60](https://github.com/devicecloud-dev/dcd-cli/issues/60)) ([c99f040](https://github.com/devicecloud-dev/dcd-cli/commit/c99f040baaf069de948c8c982b0dbbeb43218783)) + + +### Miscellaneous + +* release 5.0.1-beta.1 ([#62](https://github.com/devicecloud-dev/dcd-cli/issues/62)) ([5adec18](https://github.com/devicecloud-dev/dcd-cli/commit/5adec1855f523ee2396377d44416d09df0ef49e5)) + +## [5.0.0-beta.4](https://github.com/devicecloud-dev/dcd-cli/compare/v5.0.0-beta.3...v5.0.0-beta.4) (2026-06-26) + + +### Features + +* render DB-driven notices and forward CLI/CI identity ([#58](https://github.com/devicecloud-dev/dcd-cli/issues/58)) ([10dfdbf](https://github.com/devicecloud-dev/dcd-cli/commit/10dfdbf9a5d0ebf12568e90a1fad623213175368)) + +## [5.0.0-beta.3](https://github.com/devicecloud-dev/dcd-cli/compare/v5.0.0-beta.2...v5.0.0-beta.3) (2026-06-25) + + +### Features + +* **live:** add a beta warning to `dcd live start` ([#54](https://github.com/devicecloud-dev/dcd-cli/issues/54)) ([a02584f](https://github.com/devicecloud-dev/dcd-cli/commit/a02584fe574fd687539d0be424c658c743aaae8a)) + + +### Bug Fixes + +* stop CLA locking release PRs (breaks release pipeline) ([#52](https://github.com/devicecloud-dev/dcd-cli/issues/52)) ([bd60298](https://github.com/devicecloud-dev/dcd-cli/commit/bd6029847b9eccfe9078ae21b40ad548e4ef8985)) + +## [5.0.0-beta.2](https://github.com/devicecloud-dev/dcd-cli/compare/v5.0.0-beta.1...v5.0.0-beta.2) (2026-06-24) + + +### Features + +* **cloud:** drop legacy Maestro removed-versions block; soft-warn on deprecated 1.39.5/1.41.0 ([62c7672](https://github.com/devicecloud-dev/dcd-cli/commit/62c767295cb99339cbc3326c6bf319caf637d649)) +* **cloud:** Maestro deprecation — drop legacy hard-block, soft-warn 1.39.5/1.41.0 ([ee995e7](https://github.com/devicecloud-dev/dcd-cli/commit/ee995e7ebefb7ddfd3678bea27adf4751a39e879)) +* **cloud:** warn on deprecated iOS 16 (removal 2026-08-23) ([d794695](https://github.com/devicecloud-dev/dcd-cli/commit/d794695529c9dce938d16199e336d6698e21bff9)) +* **cloud:** warn on deprecated iOS 16 (removal 2026-08-23) ([ea62f72](https://github.com/devicecloud-dev/dcd-cli/commit/ea62f724653b3e1173036c4abe66aa4e110c0a0e)) + + +### Bug Fixes + +* **ci:** keep dependabot and fork PRs green ([#46](https://github.com/devicecloud-dev/dcd-cli/issues/46)) ([dc87257](https://github.com/devicecloud-dev/dcd-cli/commit/dc872572846fbe0d9760902cda3380edee1ef2ff)) +* **installer:** make beta opt-in, add stable/beta channels ([ec16bcc](https://github.com/devicecloud-dev/dcd-cli/commit/ec16bccd044f892f7fd1997aac977c77aa14376d)) +* **installer:** make beta opt-in, default to stable channel ([88c3532](https://github.com/devicecloud-dev/dcd-cli/commit/88c3532f8a3c6de7210c2d66d819838ea4c99fad)) +* suppress refresh countdown in quiet mode ([10eade4](https://github.com/devicecloud-dev/dcd-cli/commit/10eade42c80f42df05b165e3f83e1190aeabfd80)) +* suppress refresh countdown in quiet mode ([d543981](https://github.com/devicecloud-dev/dcd-cli/commit/d543981b0e2dc274d664bf378da4154a77a6d2e8)) +* **upgrade:** compare prerelease versions per SemVer ([f77841f](https://github.com/devicecloud-dev/dcd-cli/commit/f77841fae397e3b8d88b7ba6876b98f65026f089)) +* **upgrade:** compare prerelease versions per SemVer ([6c533e7](https://github.com/devicecloud-dev/dcd-cli/commit/6c533e7ae9e55b64e5ffe63a9c9a6934e9250938)) +* v5 release blockers — installer, binary version, repeated flags, upgrade, CI output ([d780e55](https://github.com/devicecloud-dev/dcd-cli/commit/d780e55093b314fe9855890db145188e2735beb3)) +* v5 release blockers — installer, binary version, repeated flags,… ([#51](https://github.com/devicecloud-dev/dcd-cli/issues/51)) ([d780e55](https://github.com/devicecloud-dev/dcd-cli/commit/d780e55093b314fe9855890db145188e2735beb3)) + ## [5.0.0-beta.1](https://github.com/devicecloud-dev/dcd-cli/compare/v5.0.0-beta.0...v5.0.0-beta.1) (2026-06-23) diff --git a/CLA.md b/CLA.md new file mode 100644 index 0000000..81ea8e4 --- /dev/null +++ b/CLA.md @@ -0,0 +1,140 @@ +# Contributor License Agreement (CLA) + +> [!IMPORTANT] +> **This is a starting-point template, not final legal text.** It is adapted from +> the Apache Software Foundation Individual and Corporate CLAs. **Have it reviewed +> by legal counsel and replace the bracketed placeholders before relying on it.** +> Once finalised, this document is what the CLA Assistant bot links contributors +> to when they sign on a pull request. + +Thank you for your interest in contributing to software projects managed by +**Moropo Ltd t/a DeviceCloud ("DeviceCloud", "we", "us")**. To clarify the +intellectual property licence granted with contributions from any person or +entity, we must have a Contributor License Agreement (CLA) on file that has been +signed by each contributor, indicating agreement to the licence terms below. + +This licence is for your protection as a contributor as well as the protection of +DeviceCloud and its users; it does not change your rights to use your own +contributions for any other purpose. + +By signing via the CLA Assistant bot on a pull request, you accept and agree to +the applicable terms below for your present and future contributions submitted to +DeviceCloud. + +--- + +## Individual Contributor License Agreement + +You accept and agree to the following terms and conditions for your present and +future Contributions submitted to DeviceCloud. Except for the licence granted +herein to DeviceCloud and recipients of software distributed by +DeviceCloud, you reserve all right, title, and interest in and to your +Contributions. + +1. **Definitions.** "You" (or "Your") means the copyright owner or legal entity + authorised by the copyright owner that is making this Agreement. "Contribution" + means any original work of authorship, including any modifications or additions + to an existing work, that is intentionally submitted by You to DeviceCloud + for inclusion in, or documentation of, any of the products owned or managed by + DeviceCloud (the "Work"). "Submitted" means any form of electronic, verbal, + or written communication sent to DeviceCloud or its representatives, + including but not limited to communication on electronic mailing lists, source + code control systems, and issue tracking systems that are managed by, or on + behalf of, DeviceCloud for the purpose of discussing and improving the + Work, but excluding communication that is conspicuously marked or otherwise + designated in writing by You as "Not a Contribution." + +2. **Grant of Copyright Licence.** Subject to the terms and conditions of this + Agreement, You hereby grant to DeviceCloud and to recipients of software + distributed by DeviceCloud a perpetual, worldwide, non-exclusive, + no-charge, royalty-free, irrevocable copyright licence to reproduce, prepare + derivative works of, publicly display, publicly perform, sublicense, and + distribute Your Contributions and such derivative works. + +3. **Grant of Patent Licence.** Subject to the terms and conditions of this + Agreement, You hereby grant to DeviceCloud and to recipients of software + distributed by DeviceCloud a perpetual, worldwide, non-exclusive, + no-charge, royalty-free, irrevocable (except as stated in this section) patent + licence to make, have made, use, offer to sell, sell, import, and otherwise + transfer the Work, where such licence applies only to those patent claims + licensable by You that are necessarily infringed by Your Contribution(s) alone + or by combination of Your Contribution(s) with the Work to which such + Contribution(s) was submitted. If any entity institutes patent litigation + against You or any other entity (including a cross-claim or counterclaim in a + lawsuit) alleging that Your Contribution, or the Work to which You have + contributed, constitutes direct or contributory patent infringement, then any + patent licences granted to that entity under this Agreement for that + Contribution or Work shall terminate as of the date such litigation is filed. + +4. **Representations.** You represent that You are legally entitled to grant the + above licence. If Your employer(s) has rights to intellectual property that You + create that includes Your Contributions, You represent that You have received + permission to make Contributions on behalf of that employer, that Your employer + has waived such rights for Your Contributions to DeviceCloud, or that Your + employer has executed a separate Corporate CLA with DeviceCloud. + +5. **Original Work.** You represent that each of Your Contributions is Your + original creation (see section 7 for submissions on behalf of others). You + represent that Your Contribution submissions include complete details of any + third-party licence or other restriction (including, but not limited to, + related patents and trademarks) of which You are personally aware and which are + associated with any part of Your Contributions. + +6. **No Warranty.** You are not expected to provide support for Your + Contributions, except to the extent You desire to provide support. You may + provide support for free, for a fee, or not at all. Unless required by + applicable law or agreed to in writing, You provide Your Contributions on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions of TITLE, + NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. + +7. **Third-Party Works.** Should You wish to submit work that is not Your original + creation, You may submit it to DeviceCloud separately from any + Contribution, identifying the complete details of its source and of any + licence or other restriction (including, but not limited to, related patents, + trademarks, and licence agreements) of which You are personally aware, and + conspicuously marking the work as "Submitted on behalf of a third-party: + [named here]". + +8. **Notification.** You agree to notify DeviceCloud of any facts or + circumstances of which You become aware that would make these representations + inaccurate in any respect. + +--- + +## Corporate Contributor License Agreement + +This version is for a corporation (or other legal entity) that wishes to authorise +employees to submit Contributions. It covers the same copyright and patent grants, +representations, and disclaimers as the Individual CLA above, made on behalf of the +entity, plus a schedule of authorised contributors. + +1. The definitions, copyright licence, patent licence, "no warranty", and + third-party works provisions in sections 1–3 and 6–7 of the Individual CLA + above apply equally to this Corporate CLA, with "You" referring to the + **Corporation** identified below. + +2. **Authorisation.** The Corporation represents that each employee designated on + **Schedule A** is authorised to submit Contributions on behalf of the + Corporation. The Corporation agrees to maintain Schedule A and to notify + DeviceCloud when an individual's authorisation to submit Contributions on + behalf of the Corporation is terminated. + +3. **Representations.** The Corporation represents that each Contribution is an + original creation (per section 5 of the Individual CLA) and that it is legally + entitled to grant the above licences. The Corporation agrees to notify + DeviceCloud of any facts or circumstances of which it becomes aware that + would make these representations inaccurate. + +**Schedule A — Designated Employees** + +| Full name | GitHub username | Email | +| --- | --- | --- | +| | | | + +**Corporation details** + +- Corporation name: ______________________________ +- Corporation address: ___________________________ +- Authorised signatory (name & title): ___________ +- Signature / date: ______________________________ diff --git a/CLAUDE.md b/CLAUDE.md index 426a23a..a9bf70d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,11 +5,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Commands - `pnpm dcd ` — run the CLI from source via `tsx`. -- `pnpm build` — clean, compile TypeScript to `dist/`, and `chmod +x dist/index.js` so the published binary is directly executable. +- `pnpm build` — clean, compile TypeScript to `dist/`, and `chmod +x dist/index.js dist/mcp/index.js` so both published binaries are directly executable. +- `pnpm build:binaries` — `scripts/build-binaries.mjs` produces the bun-compiled, self-contained `dcd--` binaries published to GitHub Releases (the install `dcd upgrade` self-updates). The platform/arch keys must stay in sync with `ASSET_BY_PLATFORM` in `src/commands/upgrade.ts`. - `pnpm lint` — ESLint over `src/` and `test/`. -- `pnpm typecheck` — `tsc --noEmit` over `src/` and `test/` (strict mode; `pnpm build` only compiles `src/`). -- `pnpm test` — runs `scripts/test-runner.mjs`: builds the CLI, boots a mock API, then runs all `test/**/*.test.ts` via mocha + ts-node. The mock API lives in the **sibling `dcd/` repo** (`../dcd/mock-api`). Override its location with `MOCK_API_DIR=/path/to/mock-api`. -- Run a single test: `pnpm mocha test/integration/cloud.integration.test.ts --timeout 60000` (picks up `.mocharc.json` which wires tsx; requires the mock API already running on its expected port). +- `pnpm typecheck` — `tsc --noEmit -p tsconfig.test.json` over `src/` and `test/` (strict mode; `pnpm build` only compiles `src/`). Requires Node `>=22`. +- `pnpm test` — runs `scripts/test-runner.mjs`: builds the CLI, boots the mock API if one is available, then runs mocha. TypeScript is loaded by **tsx** (`.mocharc.json`'s `node-option: ["import=tsx"]`), *not* ts-node — Mocha 11 imports specs as ESM, which bypasses the `require: ts-node/register` hook. The runner isolates `DCD_CONFIG_DIR` to a temp dir so tests never touch your real `dcd login` session. +- `pnpm test:unit` — the same runner with `--unit`: unit specs only, no mock API. **This is what CI runs.** +- Tests split into `test/unit/*` (pure, no backend) and `test/integration/*` (drive the built CLI against a Prism mock of the dcd API on port 3001). +- **There is no default mock API any more.** It used to live in the sibling private `dcd/` repo; dcd#1036 deleted it, and this repo — which is public — deliberately no longer reaches into that one (no deploy key, no `swagger.json` pull). So `pnpm test` with no `MOCK_API_DIR` set **silently degrades to the unit suite** and prints a notice. To run `test/integration/*`, stand up a Prism mock over the API's `swagger.json` and point `MOCK_API_DIR=/path/to/mock-api` at it (it needs a `start:auth` npm script serving port 3001). +- Consequence worth knowing: CI no longer catches **CLI↔swagger contract drift**, which used to surface as a Prism 422 from the integration specs. Nothing replaces that check yet. +- Run a single test: `pnpm mocha test/integration/cloud.integration.test.ts --timeout 60000` (picks up `.mocharc.json` which wires tsx; integration specs require the mock API already running on port 3001). ## Entry point @@ -19,7 +24,7 @@ The package ships a **second bin, `dcd-mcp`** (`bin.dcd-mcp` → `dist/mcp/index ## Architecture -Top-level `defineCommand` in `src/index.ts` wires ten subcommands (`cloud`, `upload`, `list`, `status`, `artifacts`, `live`, plus the auth-related `login`, `logout`, `whoami`, `switch-org`). `cloud` is the primary command and replicates `maestro cloud`. +Top-level `defineCommand` in `src/index.ts` wires eleven subcommands (`cloud`, `upload`, `list`, `status`, `artifacts`, `live`, `upgrade`, plus the auth-related `login`, `logout`, `whoami`, `switch-org`). `cloud` is the primary command and replicates `maestro cloud`; `upgrade` self-updates the standalone bun binary (no-op for npm installs, which it redirects to `npm install -g`). Note `src/index.ts` deliberately **reimplements** citty's `runMain` rather than calling it — see the Telemetry section for why. **Flag composition.** Flag definitions are split by domain in `src/config/flags/*.flags.ts` (api, binary, device, environment, execution, github, output) and re-exported as a single `flags` object from `src/constants.ts`. Commands that need the full cloud surface spread `...flags` into their citty `args`; subset commands import individual flag groups. @@ -38,10 +43,37 @@ Top-level `defineCommand` in `src/index.ts` wires ten subcommands (`cloud`, `upl **Auth.** Every command calls `resolveAuth({ apiKeyFlag })` (`src/utils/auth.ts`) once and threads the returned `AuthContext` into gateways/services. `ApiGateway` and `fetchCompatibilityData` spread `auth.headers` into fetch headers — they no longer accept a raw api key. Precedence: `--api-key` flag > `DEVICE_CLOUD_API_KEY` env > stored session from `dcd login`. `resolveAuth` refreshes expiring Supabase sessions via `CliAuthGateway.refresh` and rewrites the config atomically. -**Config store.** `dcd login` writes `$XDG_CONFIG_HOME/dcd/config.json` (fallback `~/.dcd/config.json`, 0600). Shape: `{ version, env, api_url, supabase_url, session: { access_token, refresh_token, expires_at, user_email, user_id }, current_org_id, current_org_name }`. `DCD_CONFIG_DIR` overrides the directory (used by tests). The login command itself (`src/commands/login.ts`) uses PKCE (S256) with a server rendezvous — no loopback server: it mints `state`, `code_verifier`, and `code_challenge`, opens `/cli-login?state=...&code_challenge=...`, then polls the dcd API's `POST /cli-login/claim` with `{state, code_verifier}` while the frontend POSTs the session to `POST /cli-login/handoff`; the API verifies `sha256(verifier) === challenge` and returns the session. After claiming, the CLI fetches `/me/orgs` and prompts for an org (the same picker `dcd switch-org` uses). The frontend lives in `../dcd/frontend/app/features/cli-login/CliLoginScreen.tsx`. +**Config store.** `dcd login` writes `$XDG_CONFIG_HOME/dcd/config.json` (fallback `~/.dcd/config.json`, 0600). Shape: `{ version, env, api_url, supabase_url, session: { access_token, refresh_token, expires_at, user_email, user_id }, current_org_id, current_org_name }`. `DCD_CONFIG_DIR` overrides the directory (used by tests). The login command itself (`src/commands/login.ts`) uses PKCE (S256) with a server rendezvous — no loopback server: it mints `state`, `code_verifier`, and `code_challenge`, opens `/cli-login?state=...&code_challenge=...`, then polls the dcd API's `POST /cli-login/claim` with `{state, code_verifier}` while the frontend POSTs proof of identity (the browser's access token) to `POST /cli-login/handoff`; the API verifies the token, **mints a dedicated Supabase session for the CLI** (its own refresh-token family — sharing the browser's tokens caused "Invalid Refresh Token: Already Used" whenever either client rotated them), stores it keyed by state, then on claim verifies `sha256(verifier) === challenge` and returns it. After claiming, the CLI fetches `/me/orgs` and prompts for an org (the same picker `dcd switch-org` uses). The frontend lives in `../dcd/frontend/app/features/cli-login/CliLoginScreen.tsx`. **Cross-repo auth surface.** The dcd API's `ApiKeyGuard` accepts either `x-app-api-key` (existing) or `Authorization: Bearer ` + `x-dcd-org: `. For Bearer it verifies the JWT, checks `user_org_profile` membership, and injects the org's api_key back into the request headers so existing `@Headers(APP_API_KEY_HEADER)` controller code keeps working unchanged. `dcd switch-org` calls `GET /me/orgs`, a JWT-only endpoint at `../dcd/api/src/apps/me/me.controller.ts`. **Telemetry.** `src/services/telemetry.service.ts` ships lifecycle (`command started` / `command completed` / `command failed`) and error events to the dcd API's `/cli/logs` proxy → Axiom `cli-dev` / `cli-prod`. Wired in at three points: `src/index.ts` replicates citty's `runMain` (which would otherwise swallow errors and exit 1) to record start/success/failure and honor `CliError.exitCode`; `src/utils/auth.ts` calls `telemetry.configure({ auth })` from `resolveAuth` so the token never has to be re-derived; `src/utils/cli.ts` `logger.error` calls `telemetry.flushSync()` (which shells out to `curl` because `process.exit` bypasses `beforeExit`) before exiting. Unauthenticated invocations (`--help`, `--version`, `dcd login` pre-success) buffer in memory and drop on exit — by design, since there's no identity to attach. Opt out per-invocation with `DCD_TELEMETRY_DISABLED=1`. **MCP server.** `src/mcp/` is a third front-end onto the same service layer (a sibling to `src/commands/`), shipped as the `dcd-mcp` bin over stdio transport (`@modelcontextprotocol/sdk`, `zod` schemas). `index.ts` boots the server; `server.ts` registers tools; `context.ts` resolves auth + API URL **lazily and once** (so `tools/list` works unauthenticated and auth errors surface as tool errors, not a boot crash) via the same `resolveAuth`/`resolveApiUrl` as the CLI — `DEVICE_CLOUD_API_KEY` env or stored `dcd login` session, with `DCD_API_URL` to override. **Critical invariant: stdout is the JSON-RPC channel** — tools must never call the `src/commands/*` layer or `utils/cli` `logger` (both write to stdout / can `process.exit`); they call services/gateways directly with `logStderr` and return data via `helpers.ts` `jsonResult`/`errorResult`. The `runTool` wrapper records `mcp tool …` telemetry and converts thrown errors to `isError` results. Tools: `dcd_list_devices`, `dcd_list_runs`, `dcd_get_status`, `dcd_download_artifacts` (read-only), and `dcd_run_cloud_test` (billable — gated out by `--read-only` / `DCD_MCP_READONLY=1`, annotated destructive, async-by-default). `dcd_run_cloud_test` reuses `computeCommonRoot`/`buildTestMetadataMap` from `src/services/flow-paths.ts` (extracted from `cloud.ts` so both build identical server-side paths). Registry manifest: `server.json` at repo root. + +## Contributing + +Full guide in `CONTRIBUTING.md`; the operationally important parts (the ones that gate a merge or affect a release): + +- **Branch off `dev`** (the default branch) and open PRs **against `dev`**. `production` is the maintainer-only stable track — never target it directly. +- PRs are **squash-merged**, so the **PR title becomes the commit** and must be a [Conventional Commit](https://www.conventionalcommits.org). The title — not the branch commits — is what release-please reads to compute the next version, so it matters even though individual commits are squashed away. A `PR Title` CI check enforces it. +- Type → bump: `feat` **minor**; `fix`/`perf`/`deps`/`revert`/`refactor` **patch**; `docs`/`chore`/`test`/`ci`/`build`/`style` are hidden and bump nothing. Allowed scopes are free-form. +- ⚠️ **A `!` (or `BREAKING CHANGE:` footer) bumps the MAJOR — do not use it casually.** The configs set `bump-minor-pre-major: true`, but that only applies **below 1.0.0**; we are on 5.x, so it is inert and a breaking marker means exactly what semver says. A `refactor(cloud)!:` PR title once produced a `6.0.0-beta.1` release PR for what was only a flag rename in an unconsumed beta. Because PRs are squash-merged, **the PR title IS the commit** — the `!` lands even if no branch commit carried it. +- **Never hand-edit `package.json` version, `CHANGELOG.md`, or the `.release-please-manifest*.json` files** — release-please owns all of them. `src/types/generated/schema.types.ts` is likewise generated (openapi-typescript). +- A first-time contributor must sign the CLA (the CLA Assistant bot comments on the first PR); the CLA check must be green to merge. +- **CI (`.github/workflows/cli-ci.yml`) runs the same steps on every PR** — fork, Dependabot and same-repo alike, with no privileged path: gitleaks secret scan, `pnpm lint`, `pnpm typecheck`, `pnpm test:unit`, `pnpm build`, `pnpm audit --audit-level moderate`. **`test/integration/*` is not run by CI at all** (see the Commands section: this public repo no longer reaches into the private `devicecloud-dev/dcd` repo for a mock API), so a green PR says nothing about the integration suite — run it locally with `MOCK_API_DIR` set if a change touches the API surface. gitleaks also runs as a pre-commit hook (allowlist in `.gitleaks.toml`); without the binary installed the hook self-skips and CI is the backstop. + +## Releases + +Fully automated by [release-please](https://github.com/googleapis/release-please) — no manual version bumping. `.github/workflows/release-please.yml` drives **two parallel tracks off two separate config+manifest pairs**: + +| Push to | Track | Config / manifest | Version | npm tag | +| --- | --- | --- | --- | --- | +| `dev` | **beta** (prerelease) | `release-please-config-beta.json` / `.release-please-manifest-beta.json` | `X.Y.Z-beta.N` | `beta` | +| `production` | **stable** | `release-please-config.json` / `.release-please-manifest.json` | `X.Y.Z` | `latest` | + +The two manifests track their versions **independently** (e.g. beta `5.0.0-beta.3` while stable is `5.0.0`). On each qualifying push release-please opens/updates a **Release PR** on that branch; merging the Release PR creates the git tag + GitHub Release, and the same workflow run **chains** (as `needs:` jobs, because a `GITHUB_TOKEN`-created release won't fire `release: published`) into: +1. `npm-publish.yml` — publishes to npm. Guards: a prod publish may only run from `production` and its version must **not** carry `-beta`; a beta version **must** carry `-beta`. +2. `release-binaries.yml` — bun-compiles the standalone binaries (`node scripts/build-binaries.mjs`) and uploads them to the GitHub Release. `get.devicecloud.dev` serves them by reading the GitHub Releases API at runtime, so there's no separate manifest to deploy. + +**Promoting beta → stable** is a maintainer merging `dev` into `production` via a PR (also gated by `cli-ci.yml`) — that push to `production` is what triggers the stable Release PR. The current `release/promote-v5` branch is exactly such a promotion. Releases prefer an automation GitHub App token (`BOT_APP_ID`) so the Release PR triggers the CI / PR-title / CLA checks that branch protection requires, falling back to `GITHUB_TOKEN` until the App secrets are configured. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..fd6b573 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,132 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +**conduct@devicecloud.dev**. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1560eac --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,157 @@ +# Contributing to the devicecloud.dev CLI + +Thanks for your interest in improving `@devicecloud.dev/dcd`! This guide covers +everything you need to land a change: local setup, our commit/PR conventions, and +how releases work. + +By participating you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md). + +## Contributor License Agreement (CLA) + +Before your first contribution can be merged, you must sign our Contributor +License Agreement. When you open your first pull request, the **CLA Assistant** +bot will comment with a link and instructions — signing takes under a minute and +is a one-time step. PRs cannot be merged until the CLA check is green. + +- Individuals: sign the [Individual CLA](CLA.md#individual-contributor-license-agreement). +- Contributing on behalf of an employer? Have an authorised signatory complete the + [Corporate CLA](CLA.md#corporate-contributor-license-agreement). + +## Getting started + +You need **Node.js 22+** and **[pnpm](https://pnpm.io)** (`packageManager` pins +the exact version; [Corepack](https://nodejs.org/api/corepack.html) will pick it +up automatically). + +```sh-session +$ git clone https://github.com/devicecloud-dev/dcd-cli.git +$ cd dcd-cli +$ pnpm install # installs deps, builds, and sets up git hooks +$ pnpm dcd # run the CLI from source +``` + +Useful scripts: + +| Command | What it does | +| --- | --- | +| `pnpm lint` | ESLint over `src/` and `test/` | +| `pnpm typecheck` | Strict `tsc --noEmit` over `src/` and `test/` | +| `pnpm build` | Compile to `dist/` | +| `pnpm test:unit` | Run the unit suite — no backend needed. **This is what CI runs.** | +| `pnpm test` | The same, plus the integration suite if `MOCK_API_DIR` points at a mock API | + +**Before pushing, make sure `pnpm lint`, `pnpm typecheck`, `pnpm test:unit`, and +`pnpm build` pass.** These run for every PR and are required to merge. + +### About the test suite + +Tests split in two. `test/unit/*` is pure — no network, no backend — and runs +everywhere, in CI and locally. + +`test/integration/*` drives the built CLI against a Prism mock of the dcd API on +port 3001. **CI does not run it**, on any PR, from a fork or otherwise: this repo +is public and deliberately holds no credentials for, and makes no requests to, +our private infrastructure. There is no default mock API — set +`MOCK_API_DIR=/path/to/mock-api` (a package exposing a `start:auth` script on +port 3001) and `pnpm test` picks the integration suite up. Without it the runner +prints a notice and runs the unit suite alone. + +So every contributor, maintainers included, gets the same CI signal, and you +don't need backend access to contribute. The flip side is worth knowing: a green +PR says nothing about the integration suite, so if your change touches the API +surface, say so in the PR and a maintainer will exercise it before merge. + +### Secret scanning + +A [gitleaks](https://github.com/gitleaks/gitleaks) scan runs as a pre-commit hook +and in CI (sharing the allowlist in `.gitleaks.toml`). Installing the binary +locally (`brew install gitleaks`) catches secrets before you commit; without it +the hook skips with a warning and CI remains the backstop. **Never commit real +credentials.** + +## Branching & pull requests + +1. Branch off **`dev`** (the default branch). Name it descriptively, e.g. + `fix/upload-retry` or `feat/json-output`. +2. Open your pull request **against `dev`**. (The `production` branch is the + stable release track and is maintainer-only — don't target it.) +3. Keep PRs focused. Smaller, single-purpose PRs are reviewed and merged faster. +4. Fill in the PR template, including the checklist. +5. PRs are merged via **squash merge**, so your PR ends up as a single commit on + `dev` whose message is your **PR title** — which is why the title must follow + the Conventional Commits format below. + +## Commit & PR title conventions + +We use [Conventional Commits](https://www.conventionalcommits.org). Because we +squash-merge, **only your PR title needs to follow the format** — individual +commit messages on your branch are squashed away, so commit however you like +while developing. A CI check (`PR Title`) validates the title and must pass to +merge. + +Format: + +``` +(): +``` + +Allowed types and how they affect the next release: + +| Type | Use for | Changelog | Version bump | +| --- | --- | --- | --- | +| `feat` | A new feature | **Features** | minor | +| `fix` | A bug fix | **Bug Fixes** | patch | +| `perf` | A performance improvement | **Performance** | patch | +| `deps` | Dependency updates | **Dependencies** | patch | +| `revert` | Reverting a previous change | **Reverts** | patch | +| `refactor` | Code change that neither fixes a bug nor adds a feature | **Code Refactoring** | patch | +| `docs` | Documentation only | hidden | none | +| `chore` | Tooling/maintenance | hidden | none | +| `test` | Adding or fixing tests | hidden | none | +| `ci` | CI configuration | hidden | none | +| `build` | Build system | hidden | none | +| `style` | Formatting, whitespace | hidden | none | + +**Breaking changes:** append `!` after the type (e.g. `feat!: drop Node 20`) or +add a `BREAKING CHANGE:` footer in the PR description. While the CLI is pre-1.0, +`feat` bumps the minor version and breaking changes bump the minor too. + +Examples: + +``` +feat(cloud): add --json output for run results +fix: retry binary upload on transient 5xx +docs: clarify dcd login flow in README +deps: bump @modelcontextprotocol/sdk to 1.x +``` + +## Code style + +- TypeScript, strict mode. Run `pnpm lint` and `pnpm typecheck` before pushing. +- Formatting is handled by Prettier (config in `.prettierrc`); an `.editorconfig` + keeps editors consistent. +- All human-facing CLI output goes through the rendering layer described in + [`STYLE_GUIDE.md`](STYLE_GUIDE.md) — please read it before adding output. Don't + hand-roll layouts or call `console.log` directly. + +## How releases work + +You don't need to do anything for releases — **do not bump the version in +`package.json` or edit `CHANGELOG.md`** in your PR. + +Releases are automated by [release-please](https://github.com/googleapis/release-please): + +- Merges to `dev` accumulate into a **beta** release (published to npm under the + `beta` tag). +- Maintainers promote `dev` → `production` for **stable** releases (npm `latest`). + +release-please reads the Conventional Commit titles of merged PRs to compute the +next version and generate the changelog — which is exactly why the PR title +convention matters. + +## Questions + +- General questions and help: [Discord](https://discord.gg/gm3mJwcNw8). +- Security vulnerabilities: **do not** open an issue — see [SECURITY.md](SECURITY.md). + +Thanks for contributing! 🎉 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..278d44f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Moropo Ltd t/a DeviceCloud + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index a656931..fdb4804 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,8 @@ $ pnpm install # install deps, build, set up git hooks $ pnpm dcd # run the CLI from source $ pnpm lint # ESLint $ pnpm typecheck # strict tsc, no emit -$ pnpm test # build + boot mock API + integration/unit tests +$ pnpm test:unit # unit tests, no backend needed — what CI runs +$ pnpm test # the above, plus integration tests if MOCK_API_DIR is set ``` ### Secret scanning @@ -92,3 +93,20 @@ A [gitleaks](https://github.com/gitleaks/gitleaks) scan runs in two places, both - **CI** — the `secret-scan` job scans the full history on every push and pull request, and is the enforced backstop regardless of local setup. +## Contributing + +Contributions are welcome! Read **[CONTRIBUTING.md](CONTRIBUTING.md)** for local +setup, our commit/PR conventions (Conventional Commit PR titles, squash-merge), +and how releases work. All contributors sign our +[Contributor License Agreement](CLA.md) — the bot prompts you on your first PR — +and follow our [Code of Conduct](CODE_OF_CONDUCT.md). + +Found a security issue? Please **don't** open a public issue — see +[SECURITY.md](SECURITY.md). + + +## License + +[MIT](LICENSE) © Moropo Ltd t/a DeviceCloud + + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..047affc --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,55 @@ +# Security Policy + +We take the security of the devicecloud.dev CLI (`@devicecloud.dev/dcd`) +seriously. Thank you for helping keep our users safe. + +## Supported Versions + +Security fixes are released against the latest published major version on npm. +Always upgrade to the newest release before reporting: + +```sh-session +$ npm install -g @devicecloud.dev/dcd@latest # npm install +$ dcd upgrade # binary install +``` + +| Version | Supported | +| -------------- | ------------------ | +| Latest `5.x` | :white_check_mark: | +| Older majors | :x: | + +## Reporting a Vulnerability + +**Please do not open a public GitHub issue, pull request, or Discord message for +security vulnerabilities.** Public reports put users at risk before a fix is +available. + +Instead, email **security@devicecloud.dev** with: + +- A description of the vulnerability and its impact. +- Steps to reproduce (a proof of concept is ideal). +- The CLI version (`dcd --version`), OS, and Node.js version where applicable. +- Any suggested remediation, if you have one. + +### What to expect + +- **Acknowledgement** within 3 business days. +- An initial assessment and severity triage within 7 business days. +- Coordinated disclosure: we will work with you on a fix and a disclosure + timeline, and credit you in the release notes if you wish. + +Please give us a reasonable opportunity to remediate before any public +disclosure. + +## Scope + +This policy covers the code in this repository — the `dcd` CLI and the `dcd-mcp` +server. Vulnerabilities in the devicecloud.dev backend or web console should also +be sent to **security@devicecloud.dev** and will be routed to the right team. + +## Secrets + +This repository is scanned for committed secrets by [gitleaks](https://github.com/gitleaks/gitleaks) +on every push and pull request, and via a local pre-commit hook. If you believe +a secret has been committed, email **security@devicecloud.dev** immediately +rather than opening an issue. diff --git a/eslint.config.cjs b/eslint.config.cjs index 7bbdba9..aded632 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -6,11 +6,9 @@ const js = require('@eslint/js'); const tseslint = require('typescript-eslint'); -// These plugins ship as ESM with a `default` export under CJS interop. +// This plugin ships as ESM with a `default` export under CJS interop. const unicornPlugin = require('eslint-plugin-unicorn').default ?? require('eslint-plugin-unicorn'); -const importPlugin = - require('eslint-plugin-import').default ?? require('eslint-plugin-import'); module.exports = tseslint.config( { @@ -23,12 +21,15 @@ module.exports = tseslint.config( js.configs.recommended, ...tseslint.configs.recommended, { - // `unicorn` and `import` are only registered so legacy + // `unicorn` is only registered so legacy // `// eslint-disable-next-line unicorn/...` comments scattered through - // the source resolve. We don't enable any rules from them. + // the source resolve. We don't enable any rules from it. + // `eslint-plugin-import` used to be registered here for the same reason, + // but it was dropped: it dragged in minimatch@3 -> brace-expansion@1.x, + // which has an unpatched DoS advisory (GHSA-mh99-v99m-4gvg, no 1.x + // backport) and failed `pnpm audit`. No rules from it were ever enabled. plugins: { unicorn: unicornPlugin, - import: importPlugin, }, languageOptions: { ecmaVersion: 2022, diff --git a/install.ps1 b/install.ps1 index f96cbb4..dab6b1f 100644 --- a/install.ps1 +++ b/install.ps1 @@ -4,7 +4,8 @@ # irm https://get.devicecloud.dev/install.ps1 | iex # # Env vars: -# DCD_VERSION Pin a specific version (default: latest) +# DCD_VERSION Pin a specific version, e.g. for rollback (default: latest stable) +# DCD_BETA Set to any value to install the latest beta/prerelease (opt-in) # DCD_INSTALL_DIR Override install location (default: $env:USERPROFILE\.dcd\bin) # DCD_DOWNLOAD_BASE Override the download host (default: https://get.devicecloud.dev) @@ -25,13 +26,39 @@ if ([Environment]::Is64BitOperatingSystem -ne $true) { $asset = 'dcd-windows-x64.exe' # --- resolve version --- +# Precedence: explicit DCD_VERSION pin > DCD_BETA opt-in > latest stable. if ($env:DCD_VERSION) { $version = $env:DCD_VERSION } else { - Write-Host 'Resolving latest version...' - $manifest = Invoke-RestMethod -Uri "$DownloadBase/latest.json" + if ($env:DCD_BETA) { + Write-Host 'Resolving latest beta version...' + $manifestUrl = "$DownloadBase/latest.json?channel=beta" + $channel = 'beta' + } else { + Write-Host 'Resolving latest version...' + $manifestUrl = "$DownloadBase/latest.json" + $channel = 'stable' + } + try { + $manifest = Invoke-RestMethod -Uri $manifestUrl + } catch { + throw "Could not reach $manifestUrl" + } + # A null version means the channel has no release yet (HTTP 200), as opposed + # to a transient failure (which throws above). Stable is the default and beta + # is strictly opt-in, so refuse to silently fall back to a prerelease. $version = $manifest.version - if (-not $version) { throw "Could not resolve latest version from $DownloadBase/latest.json" } + if (-not $version) { + if ($channel -eq 'stable') { + throw @" +No stable dcd release is available yet. + Install the latest beta: `$env:DCD_BETA=1; irm '$DownloadBase/install.ps1' | iex + Or pin a version: `$env:DCD_VERSION='5.0.0-beta.1'; irm '$DownloadBase/install.ps1' | iex +"@ + } else { + throw "No beta release is available yet from $manifestUrl" + } + } } $url = "$DownloadBase/download/$version/$asset" @@ -48,7 +75,15 @@ try { Invoke-WebRequest -Uri $url -OutFile $tmp -UseBasicParsing # --- verify checksum --- - $sums = (Invoke-WebRequest -Uri $sumsUrl -UseBasicParsing).Content + # GitHub serves SHA256SUMS as application/octet-stream, so under + # -UseBasicParsing on Windows PowerShell 5.x .Content comes back as a + # Byte[] (not a string) and -split would never match. Decode to UTF-8 text. + $sumsResp = Invoke-WebRequest -Uri $sumsUrl -UseBasicParsing + $sums = if ($sumsResp.Content -is [byte[]]) { + [System.Text.Encoding]::UTF8.GetString($sumsResp.Content) + } else { + [string]$sumsResp.Content + } $expected = ($sums -split "`n" | Where-Object { $_ -match "^([a-f0-9]{64})\s+$([regex]::Escape($asset))\s*$" } | ForEach-Object { $matches[1] } | @@ -56,7 +91,7 @@ try { if (-not $expected) { throw "SHA256SUMS has no entry for $asset" } $actual = (Get-FileHash -Path $tmp -Algorithm SHA256).Hash.ToLower() if ($expected -ne $actual) { - throw "Checksum mismatch for $asset: expected $expected, got $actual" + throw "Checksum mismatch for ${asset}: expected $expected, got $actual" } # --- install --- diff --git a/install.sh b/install.sh index ac94e43..4d25dc8 100755 --- a/install.sh +++ b/install.sh @@ -5,7 +5,8 @@ # curl -fsSL https://get.devicecloud.dev/install.sh | sh # # Env vars: -# DCD_VERSION Pin a specific version (default: latest) +# DCD_VERSION Pin a specific version, e.g. for rollback (default: latest stable) +# DCD_BETA Set to any value to install the latest beta/prerelease (opt-in) # DCD_INSTALL_DIR Override install location (default: $HOME/.dcd/bin) # DCD_DOWNLOAD_BASE Override the download host (default: https://get.devicecloud.dev) # @@ -23,6 +24,17 @@ info() { printf '%s\n' "$1" } +# Stable is the default channel and beta is strictly opt-in, so when no stable +# release exists yet (only prereleases published) we refuse to silently install a +# beta and instead point the user at the two explicit opt-ins. $DOWNLOAD_BASE is +# echoed so a custom host shows the right command. +no_stable_release_err() { + printf 'error: No stable dcd release is available yet.\n' >&2 + printf ' Install the latest beta: curl -fsSL %s/install.sh | DCD_BETA=1 sh\n' "$DOWNLOAD_BASE" >&2 + printf ' Or pin a version: curl -fsSL %s/install.sh | DCD_VERSION=5.0.0-beta.1 sh\n' "$DOWNLOAD_BASE" >&2 + exit 1 +} + # Find a dcd on PATH other than the one we just installed — usually a leftover # `npm install -g @devicecloud.dev/dcd` that can shadow this binary. Runs in a # subshell so the temporary IFS change never leaks back to the caller. @@ -121,17 +133,40 @@ main() { asset="dcd-${os_id}-${arch_id}" # --- resolve version --- + # Precedence: explicit DCD_VERSION pin > DCD_BETA opt-in > latest stable. if [ -n "${DCD_VERSION:-}" ]; then version="$DCD_VERSION" else - info "Resolving latest version..." - # /latest.json returns { "version": "5.1.0", ... } + if [ -n "${DCD_BETA:-}" ]; then + channel=beta + manifest_url="$DOWNLOAD_BASE/latest.json?channel=beta" + info "Resolving latest beta version..." + else + channel=stable + manifest_url="$DOWNLOAD_BASE/latest.json" + info "Resolving latest version..." + fi + + # Fetch the manifest separately from parsing so we can tell a transient + # network/proxy failure (curl -f returns non-zero → empty $manifest) apart + # from a channel that simply has no release yet (HTTP 200 with + # "version": null → $manifest non-empty but $version empty). + manifest=$(curl -fsSL "$manifest_url") || manifest="" + [ -z "$manifest" ] && err "Could not reach $manifest_url" + # /latest.json returns { "version": "5.1.0", ... }; a null version is unquoted + # and so won't match this quoted-string pattern. version=$( - curl -fsSL "$DOWNLOAD_BASE/latest.json" \ + printf '%s' "$manifest" \ | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ | head -n1 ) - [ -z "$version" ] && err "Could not resolve latest version from $DOWNLOAD_BASE/latest.json" + if [ -z "$version" ]; then + if [ "$channel" = stable ]; then + no_stable_release_err + else + err "No beta release is available yet from $manifest_url" + fi + fi fi url="$DOWNLOAD_BASE/download/${version}/${asset}" diff --git a/package.json b/package.json index 32464d8..bc46aa1 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,10 @@ "@clack/prompts": "^1.6.0", "@modelcontextprotocol/sdk": "^1.29.0", "@supabase/supabase-js": "^2.108.2", - "bplist-parser": "^0.3.2", - "chalk": "^5.6.2", + "bplist-parser": "^0.5.0", + "chalk": "^6.0.0", "citty": "^0.2.2", - "js-yaml": "^5.0.0", + "js-yaml": "^5.2.2", "node-apk": "^1.2.1", "node-stream-zip": "^1.15.0", "plist": "^5.0.0", @@ -31,10 +31,9 @@ "chai": "^6.2.2", "eslint": "^10.5.0", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-import": "^2.32.0", - "eslint-plugin-unicorn": "^68.0.0", + "eslint-plugin-unicorn": "^74.0.0", "husky": "^9.1.7", - "mocha": "^11.7.6", + "mocha": "^12.0.0", "prettier": "^3.8.4", "shx": "^0.4.0", "tsx": "^4.22.4", @@ -67,9 +66,10 @@ "lint": "eslint src test --ext .ts", "prepare": "pnpm build && husky", "test": "node scripts/test-runner.mjs", + "test:unit": "node scripts/test-runner.mjs --unit", "typecheck": "tsc --noEmit -p tsconfig.test.json" }, - "version": "5.0.0-beta.1", + "version": "5.5.0-beta.4", "bugs": { "url": "https://discord.gg/gm3mJwcNw8" }, @@ -87,12 +87,13 @@ "overrides": { "js-yaml@<3.14.2": ">=3.14.2", "js-yaml@>=4.0.0 <4.2.0": ">=4.2.0", + "js-yaml@>=5.0.0 <5.2.2": ">=5.2.2", "tar@<7.5.16": ">=7.5.16", "@isaacs/brace-expansion": ">=5.0.1", "fast-xml-parser": ">=5.5.7", "flatted": ">=3.4.2", "lodash": ">=4.18.0", - "@xmldom/xmldom": ">=0.9.10", + "@xmldom/xmldom": ">=0.9.12", "minimatch@<3.1.4": "3.1.4", "minimatch@>=5.0.0 <5.1.8": "5.1.8", "minimatch@>=9.0.0 <9.0.7": "9.0.7", @@ -103,13 +104,21 @@ "ajv@<7.0.0": "6.14.0", "diff@>=4.0.0 <6.0.0": "4.0.4", "diff@>=6.0.0": "8.0.3", - "brace-expansion@<1.1.13": "1.1.13", + "brace-expansion@<1.1.16": "1.1.16", "brace-expansion@>=2.0.0 <2.0.3": "2.0.3", - "brace-expansion@>=4.0.0 <5.0.6": "5.0.6", + "brace-expansion@>=3.0.0 <5.0.9": "5.0.9", "ws@>=8.0.0 <8.21.0": "8.21.0", "esbuild@<0.28.1": ">=0.28.1", + "qs@<6.16.0": "6.16.0", + "fast-uri@>=3.0.0 <3.1.6": "3.1.6", + "hono@>=4.0.0 <4.13.5": "4.13.5", "micromatch>picomatch": "^2.3.2", "tinyglobby>picomatch": "^4.0.4" + }, + "auditConfig": { + "ignoreGhsas": [ + "GHSA-frvp-7c67-39w9" + ] } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5db7755..02679d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,12 +7,13 @@ settings: overrides: js-yaml@<3.14.2: '>=3.14.2' js-yaml@>=4.0.0 <4.2.0: '>=4.2.0' + js-yaml@>=5.0.0 <5.2.2: '>=5.2.2' tar@<7.5.16: '>=7.5.16' '@isaacs/brace-expansion': '>=5.0.1' fast-xml-parser: '>=5.5.7' flatted: '>=3.4.2' lodash: '>=4.18.0' - '@xmldom/xmldom': '>=0.9.10' + '@xmldom/xmldom': '>=0.9.12' minimatch@<3.1.4: 3.1.4 minimatch@>=5.0.0 <5.1.8: 5.1.8 minimatch@>=9.0.0 <9.0.7: 9.0.7 @@ -23,11 +24,14 @@ overrides: ajv@<7.0.0: 6.14.0 diff@>=4.0.0 <6.0.0: 4.0.4 diff@>=6.0.0: 8.0.3 - brace-expansion@<1.1.13: 1.1.13 + brace-expansion@<1.1.16: 1.1.16 brace-expansion@>=2.0.0 <2.0.3: 2.0.3 - brace-expansion@>=4.0.0 <5.0.6: 5.0.6 + brace-expansion@>=3.0.0 <5.0.9: 5.0.9 ws@>=8.0.0 <8.21.0: 8.21.0 esbuild@<0.28.1: '>=0.28.1' + qs@<6.16.0: 6.16.0 + fast-uri@>=3.0.0 <3.1.6: 3.1.6 + hono@>=4.0.0 <4.13.5: 4.13.5 micromatch>picomatch: ^2.3.2 tinyglobby>picomatch: ^4.0.4 @@ -37,37 +41,37 @@ importers: dependencies: '@clack/prompts': specifier: ^1.6.0 - version: 1.6.0 + version: 1.8.0 '@modelcontextprotocol/sdk': specifier: ^1.29.0 - version: 1.29.0(zod@4.4.3) + version: 1.30.0(zod@4.6.2) '@supabase/supabase-js': specifier: ^2.108.2 - version: 2.108.2 + version: 2.116.0 bplist-parser: - specifier: ^0.3.2 - version: 0.3.2 + specifier: ^0.5.0 + version: 0.5.0 chalk: - specifier: ^5.6.2 - version: 5.6.2 + specifier: ^6.0.0 + version: 6.0.0 citty: specifier: ^0.2.2 version: 0.2.2 js-yaml: - specifier: ^5.0.0 - version: 5.0.0 + specifier: ^5.2.2 + version: 5.4.1 node-apk: specifier: ^1.2.1 version: 1.2.1 node-stream-zip: specifier: ^1.15.0 - version: 1.15.0 + version: 1.16.0 plist: specifier: ^5.0.0 version: 5.0.0 tar: specifier: ^7.5.16 - version: 7.5.16 + version: 7.5.22 tus-js-client: specifier: ^4.3.1 version: 4.3.1 @@ -76,11 +80,11 @@ importers: version: 3.3.1 zod: specifier: ^4.4.3 - version: 4.4.3 + version: 4.6.2 devDependencies: '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.5.0) + version: 10.0.1(eslint@10.10.0) '@types/chai': specifier: ^5.2.3 version: 5.2.3 @@ -92,7 +96,7 @@ importers: version: 10.0.10 '@types/node': specifier: ^26.0.0 - version: 26.0.0 + version: 26.5.1 '@types/yazl': specifier: ^3.3.1 version: 3.3.1 @@ -101,210 +105,209 @@ importers: version: 6.2.2 eslint: specifier: ^10.5.0 - version: 10.5.0 + version: 10.10.0 eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.5.0) - eslint-plugin-import: - specifier: ^2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.61.1(eslint@10.5.0)(typescript@6.0.3))(eslint@10.5.0) + version: 10.1.8(eslint@10.10.0) eslint-plugin-unicorn: - specifier: ^68.0.0 - version: 68.0.0(eslint@10.5.0) + specifier: ^74.0.0 + version: 74.0.0(eslint@10.10.0) husky: specifier: ^9.1.7 version: 9.1.7 mocha: - specifier: ^11.7.6 - version: 11.7.6 + specifier: ^12.0.0 + version: 12.0.1 prettier: specifier: ^3.8.4 - version: 3.8.4 + version: 3.9.6 shx: specifier: ^0.4.0 version: 0.4.0 tsx: specifier: ^4.22.4 - version: 4.22.4 + version: 4.23.13 typescript: specifier: ^6.0.3 version: 6.0.3 typescript-eslint: specifier: ^8.61.1 - version: 8.61.1(eslint@10.5.0)(typescript@6.0.3) + version: 8.70.0(eslint@10.10.0)(typescript@6.0.3) packages: - '@babel/helper-validator-identifier@7.29.7': - resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} - engines: {node: '>=6.9.0'} + '@cacheable/memory@2.2.0': + resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} - '@clack/core@1.4.2': - resolution: {integrity: sha512-0Ty/1Gfm+Kb07sXcuESjyKfwEhSy4Ns1AgeEisHb/bDY5fWme0tTeTkU14T1Gmcs17YIjB/teiDe4uaCghbYqQ==} + '@cacheable/utils@2.5.0': + resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} + + '@clack/core@1.5.0': + resolution: {integrity: sha512-zNikCcd8BbcEvzzG1sbXFrRHFk5kHPrpwZwksPvf9qyQO1Teb7JaXaOAxXZei9nZLDW0gaZawiuTCji88bTBhw==} engines: {node: '>= 20.12.0'} - '@clack/prompts@1.6.0': - resolution: {integrity: sha512-EYlRokl8szrP9Z25qT5aepMdBjzBvHF9ZEhzIiUBc9guz/T31EqRgvD0QSgZcpE93xiwrr+OkB4nz0BZyF6fSA==} + '@clack/prompts@1.8.0': + resolution: {integrity: sha512-PXzLZ8N34rxmuo4dJg3xtOXhcBse94qGjDqsteoEYrFrrZ5FSjIGwMAuOcv64ln8rHVBBD06XeVGr+/JX+plcA==} engines: {node: '>= 20.12.0'} - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 @@ -317,14 +320,18 @@ packages: resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/config-helpers@0.6.0': - resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + '@eslint/config-helpers@0.7.0': + resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} '@eslint/core@1.2.1': resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/css-tree@4.1.0': + resolution: {integrity: sha512-cg0ohyrAG3swyGqt8t1K/OK97DqBw/ftDvlvyY1fmEst5B40UOmsimwLENq74z2dyw5CDM+3zJIW+CV2nFNDdA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@eslint/js@10.0.1': resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -338,15 +345,15 @@ packages: resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/plugin-kit@0.7.2': - resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + '@eslint/plugin-kit@0.7.3': + resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@hono/node-server@1.19.14': - resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} - engines: {node: '>=18.14.1'} + '@hono/node-server@2.0.12': + resolution: {integrity: sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==} + engines: {node: '>=20'} peerDependencies: - hono: ^4 + hono: 4.13.5 '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} @@ -368,16 +375,21 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} - '@modelcontextprotocol/sdk@1.29.0': - resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + '@keyv/bigmap@1.3.1': + resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.6.0 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + + '@modelcontextprotocol/sdk@1.30.0': + resolution: {integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -398,39 +410,37 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} - - '@rtsao/scc@1.1.0': - resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} - - '@supabase/auth-js@2.108.2': - resolution: {integrity: sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag==} - engines: {node: '>=20.0.0'} + '@supabase/auth-js@2.116.0': + resolution: {integrity: sha512-Cmosty12gyKGK9N3bQb+lMmuAFev5nmUzaR1AsmZHqKOAGzqX1VQzmp49CNPwOx/pw0H9Qqk4rs9yhwTlKpfDg==} + engines: {node: '>=22.0.0'} - '@supabase/functions-js@2.108.2': - resolution: {integrity: sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw==} - engines: {node: '>=20.0.0'} + '@supabase/functions-js@2.116.0': + resolution: {integrity: sha512-E+VOc2QDcni/fySqkBFiZhnoB3SGydEdZgFI6/dEAGAHx6yEhB46TN9qb2wXs+E+RSzOBV0R6dasiSlw4xlZAA==} + engines: {node: '>=22.0.0'} - '@supabase/phoenix@0.4.4': - resolution: {integrity: sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==} + '@supabase/phoenix@0.4.5': + resolution: {integrity: sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==} - '@supabase/postgrest-js@2.108.2': - resolution: {integrity: sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==} - engines: {node: '>=20.0.0'} + '@supabase/postgrest-js@2.116.0': + resolution: {integrity: sha512-kGpVZTDHxFTJS3tu+rU0iTAZ+4U0bcLVjxwCk8f3gRhjw3qdCZjTBlgYvc4kGH2XccmAzbkKwXL/mrNHMGSc+A==} + engines: {node: '>=22.0.0'} - '@supabase/realtime-js@2.108.2': - resolution: {integrity: sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==} - engines: {node: '>=20.0.0'} + '@supabase/realtime-js@2.116.0': + resolution: {integrity: sha512-MHAnlXxi2s6yiJsZsQMfs2B3RFxeVfQWxerqYhIMqcCQV/FuY3LIeouPEkXw/ah7wUWMLYwempF9MOCUScyddg==} + engines: {node: '>=22.0.0'} - '@supabase/storage-js@2.108.2': - resolution: {integrity: sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==} - engines: {node: '>=20.0.0'} + '@supabase/storage-js@2.116.0': + resolution: {integrity: sha512-6/3hR6vccBP6oGM5B6RfbwZcTCKmQOodd/ZWQdsw8yJsU5zO/a//oBL6yLnmgxcjnHSrelW8rsO7hL5DPybyUQ==} + engines: {node: '>=22.0.0'} - '@supabase/supabase-js@2.108.2': - resolution: {integrity: sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==} - engines: {node: '>=20.0.0'} + '@supabase/supabase-js@2.116.0': + resolution: {integrity: sha512-YyWmKXt2NspV9iO8FPnlswUFJIRnrLd3oTCb+3ZyYRuKZtBH0xCUDgnUqoyA0fGUxpM/UhfwDjYf/dht/9bp7g==} + engines: {node: '>=22.0.0'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -450,79 +460,76 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/json5@0.0.29': - resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - '@types/mocha@10.0.10': resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==} - '@types/node@26.0.0': - resolution: {integrity: sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==} + '@types/node@26.5.1': + resolution: {integrity: sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==} '@types/yazl@3.3.1': resolution: {integrity: sha512-DIWfCKpsTp6hE5BDBHV3+fIL/bLUF9Bv13iDrWnMlmhQpH67buNvI291ZauQ1xcccxK3FqQ9honnXpq4R8NMuQ==} - '@typescript-eslint/eslint-plugin@8.61.1': - resolution: {integrity: sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==} + '@typescript-eslint/eslint-plugin@8.70.0': + resolution: {integrity: sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.61.1 + '@typescript-eslint/parser': ^8.70.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.61.1': - resolution: {integrity: sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==} + '@typescript-eslint/parser@8.70.0': + resolution: {integrity: sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.61.1': - resolution: {integrity: sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==} + '@typescript-eslint/project-service@8.70.0': + resolution: {integrity: sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.61.1': - resolution: {integrity: sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==} + '@typescript-eslint/scope-manager@8.70.0': + resolution: {integrity: sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.61.1': - resolution: {integrity: sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==} + '@typescript-eslint/tsconfig-utils@8.70.0': + resolution: {integrity: sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.61.1': - resolution: {integrity: sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==} + '@typescript-eslint/type-utils@8.70.0': + resolution: {integrity: sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.61.1': - resolution: {integrity: sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==} + '@typescript-eslint/types@8.70.0': + resolution: {integrity: sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.61.1': - resolution: {integrity: sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==} + '@typescript-eslint/typescript-estree@8.70.0': + resolution: {integrity: sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.61.1': - resolution: {integrity: sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==} + '@typescript-eslint/utils@8.70.0': + resolution: {integrity: sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.61.1': - resolution: {integrity: sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==} + '@typescript-eslint/visitor-keys@8.70.0': + resolution: {integrity: sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@xmldom/xmldom@0.9.10': - resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} + '@xmldom/xmldom@0.9.12': + resolution: {integrity: sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==} engines: {node: '>=14.6'} accepts@2.0.0: @@ -534,8 +541,8 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.17.0: - resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} hasBin: true @@ -553,91 +560,33 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - array-buffer-byte-length@1.0.2: - resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} - engines: {node: '>= 0.4'} - - array-includes@3.1.9: - resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} - engines: {node: '>= 0.4'} - - array.prototype.findlastindex@1.2.6: - resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} - engines: {node: '>= 0.4'} - - array.prototype.flat@1.3.3: - resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} - engines: {node: '>= 0.4'} - - array.prototype.flatmap@1.3.3: - resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} - engines: {node: '>= 0.4'} - - arraybuffer.prototype.slice@1.0.4: - resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} - engines: {node: '>= 0.4'} - assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - async-function@1.0.0: - resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} - engines: {node: '>= 0.4'} - - available-typed-arrays@1.0.7: - resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} - engines: {node: '>= 0.4'} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.10.38: - resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==} + baseline-browser-mapping@2.11.21: + resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} engines: {node: '>=6.0.0'} hasBin: true - big-integer@1.6.52: - resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} - engines: {node: '>=0.6'} - body-parser@2.3.0: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} - bplist-parser@0.3.2: - resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==} - engines: {node: '>= 5.10.0'} - - brace-expansion@1.1.13: - resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + bplist-parser@0.5.0: + resolution: {integrity: sha512-owgQ0RGbndIGNrQkpHe99HVSslIK59t31nVyAPDRqhq9mNDc2kRl5HwYO292GuKjHqiJRdJNiaI63a02hjLFIQ==} + engines: {node: '>=20.19.0'} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -646,8 +595,8 @@ packages: browser-stdout@1.3.1: resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==} - browserslist@4.28.4: - resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==} + browserslist@4.28.9: + resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -658,51 +607,42 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - builtin-modules@5.2.0: - resolution: {integrity: sha512-02yxLeyxF4dNl6SlY6/5HfRSrSdZ/sCPoxy2kZNP5dZZX8LSAD9aE2gtJIUgWrsQTiMPl3mxESyrobSwvRGisQ==} + builtin-modules@5.3.0: + resolution: {integrity: sha512-hMQUl2bUFG339QygPM97E+mc8OY1IAchORZxm4a/frcYwKzozMzRVDBwHW0NjOqGElLm2O37AVQE8ikxlZHrMQ==} engines: {node: '>=18.20'} bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + cacheable@2.5.0: + resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - call-bind@1.0.9: - resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} - engines: {node: '>= 0.4'} - call-bound@1.0.4: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - camelcase@6.3.0: - resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} - engines: {node: '>=10'} - - caniuse-lite@1.0.30001799: - resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - chalk@5.6.2: - resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chalk@6.0.0: + resolution: {integrity: sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==} + engines: {node: '>=22'} change-case@5.4.4: resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} - chokidar@4.0.3: - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} - engines: {node: '>= 14.16.0'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} @@ -715,23 +655,9 @@ packages: citty@0.2.2: resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - combine-errors@3.0.3: resolution: {integrity: sha512-C8ikRNRMygCwaTx+Ek3Yr+OuZzgZjduCOfSQBjbM8V3MfgcjSTeto/GXP6PAwKvJz/v15b7GHZvx5rOlczFw/Q==} - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - content-disposition@1.1.0: resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} @@ -744,6 +670,10 @@ packages: resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} engines: {node: '>=18'} + convert-hrtime@5.0.0: + resolution: {integrity: sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==} + engines: {node: '>=12'} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -752,8 +682,9 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} - core-js-compat@3.49.0: - resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + core-js-compat@3.50.0: + resolution: {integrity: sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==} + engines: {node: '>=6.4.0'} cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} @@ -770,26 +701,6 @@ packages: custom-error-instance@2.1.1: resolution: {integrity: sha512-p6JFxJc3M4OTD2li2qaHkDCw9SfMw82Ldr6OC9Je1aXiGfhx2W8p3GaoeaGrPJTUN9NirTM/KTxHWMUdR1rsUg==} - data-view-buffer@1.0.2: - resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} - engines: {node: '>= 0.4'} - - data-view-byte-length@1.0.2: - resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} - engines: {node: '>= 0.4'} - - data-view-byte-offset@1.0.1: - resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} - engines: {node: '>= 0.4'} - - debug@3.2.7: - resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -799,21 +710,9 @@ packages: supports-color: optional: true - decamelize@4.0.0: - resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==} - engines: {node: '>=10'} - deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} - - define-properties@1.2.1: - resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} - engines: {node: '>= 0.4'} - depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -826,28 +725,15 @@ packages: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} - doctrine@2.1.0: - resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} - engines: {node: '>=0.10.0'} - dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.376: - resolution: {integrity: sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + electron-to-chromium@1.5.422: + resolution: {integrity: sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==} encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} @@ -856,13 +742,9 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - es-abstract-get@1.0.0: - resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} - engines: {node: '>= 0.4'} - - es-abstract@1.24.2: - resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} - engines: {node: '>= 0.4'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} @@ -876,20 +758,8 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - - es-shim-unscopables@1.1.0: - resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} - engines: {node: '>= 0.4'} - - es-to-primitive@1.3.1: - resolution: {integrity: sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g==} - engines: {node: '>= 0.4'} - - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} hasBin: true @@ -910,42 +780,8 @@ packages: peerDependencies: eslint: '>=7.0.0' - eslint-import-resolver-node@0.3.10: - resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} - - eslint-module-utils@2.13.0: - resolution: {integrity: sha512-bLohSkT6469rRs8czj0tLTD8vaeIS/whvPRJVjDr7IuoTT1k5DYDERlNycjDj/HkOlvQdYurmfZ/g3fG5bgeLQ==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: '*' - eslint-import-resolver-node: '*' - eslint-import-resolver-typescript: '*' - eslint-import-resolver-webpack: '*' - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - eslint: - optional: true - eslint-import-resolver-node: - optional: true - eslint-import-resolver-typescript: - optional: true - eslint-import-resolver-webpack: - optional: true - - eslint-plugin-import@2.32.0: - resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - - eslint-plugin-unicorn@68.0.0: - resolution: {integrity: sha512-mHYWvX948Q4H3bGc39bsNMxD/leOuNI+Iws9NVsoSz5VA7EGP86wnz7mZ/SPSvRhefT8L4hd8DHfDuGC+lIoCQ==} + eslint-plugin-unicorn@74.0.0: + resolution: {integrity: sha512-AGnsGi2SxHg1HEAXxn9nSnZfyjvTWkxm8E8hpd/9tD6dLjBUdcD7+D6ZN64HmmCXTSXlrwVyUqe20Uyb2CaurA==} engines: {node: '>=22'} peerDependencies: eslint: '>=10.4' @@ -962,8 +798,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.5.0: - resolution: {integrity: sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==} + eslint@10.10.0: + resolution: {integrity: sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -1008,8 +844,8 @@ packages: resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==} engines: {node: '>=6'} - express-rate-limit@8.5.2: - resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + express-rate-limit@8.6.1: + resolution: {integrity: sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' @@ -1037,8 +873,8 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.2: - resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-uri@3.1.6: + resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} @@ -1055,9 +891,8 @@ packages: picomatch: optional: true - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} + file-entry-cache@11.1.5: + resolution: {integrity: sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==} fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} @@ -1075,24 +910,15 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} - - flat@5.0.2: - resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} - hasBin: true - - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + find-up@8.0.0: + resolution: {integrity: sha512-JGG8pvDi2C+JxidYdIwQDyS/CgcrIdh18cvgxcBge3wSHRQOrooMD3GlFBcmMJAN9M42SAZjDp5zv1dglJjwww==} + engines: {node: '>=20'} - for-each@0.3.5: - resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} - engines: {node: '>= 0.4'} + flat-cache@6.1.23: + resolution: {integrity: sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==} - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} @@ -1110,20 +936,9 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - function.prototype.name@1.2.0: - resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} - engines: {node: '>= 0.4'} - - functions-have-names@1.2.3: - resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - - generator-function@2.0.1: - resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} - engines: {node: '>= 0.4'} - - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} + function-timeout@1.0.2: + resolution: {integrity: sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==} + engines: {node: '>=18'} get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} @@ -1137,10 +952,6 @@ packages: resolution: {integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==} engines: {node: '>=6'} - get-symbol-description@1.1.0: - resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} - engines: {node: '>= 0.4'} - glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1149,19 +960,14 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@10.5.0: - resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} - globals@17.6.0: - resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} + globals@17.12.0: + resolution: {integrity: sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==} engines: {node: '>=18'} - globalthis@1.0.4: - resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} - engines: {node: '>= 0.4'} - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -1169,41 +975,32 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - has-bigints@1.1.0: - resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} - engines: {node: '>= 0.4'} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - - has-proto@1.2.0: - resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} - engines: {node: '>= 0.4'} - has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} + hashery@1.5.1: + resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} + engines: {node: '>=20'} hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} - he@1.2.0: - resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} - hasBin: true - - hono@4.12.26: - resolution: {integrity: sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==} + hono@4.13.5: + resolution: {integrity: sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==} engines: {node: '>=16.9.0'} + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + + hookified@2.2.0: + resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -1217,16 +1014,20 @@ packages: resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} engines: {node: '>=20.0.0'} - iconv-lite@0.7.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} + identifier-regex@1.1.0: + resolution: {integrity: sha512-SLX4H/vtcYlYnL7XqnuJKHU7Z8517TgsW9nmQiGOgMCjQ8V/deLYu6bEmbGoXe7WMMhc9+EUGyFFneHja8KabA==} + engines: {node: '>=18'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + ignore@7.0.9: + resolution: {integrity: sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==} engines: {node: '>= 4'} imurmurhash@0.1.4: @@ -1240,121 +1041,49 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - internal-slot@1.1.0: - resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} - engines: {node: '>= 0.4'} - interpret@1.4.0: resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} engines: {node: '>= 0.10'} - ip-address@10.2.0: - resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + ip-address@10.3.1: + resolution: {integrity: sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==} engines: {node: '>= 12'} ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} - is-array-buffer@3.0.5: - resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} - engines: {node: '>= 0.4'} - - is-async-function@2.1.1: - resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} - engines: {node: '>= 0.4'} - - is-bigint@1.1.0: - resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} - engines: {node: '>= 0.4'} - - is-boolean-object@1.2.2: - resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} - engines: {node: '>= 0.4'} - is-builtin-module@5.0.0: resolution: {integrity: sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==} engines: {node: '>=18.20'} - is-callable@1.2.7: - resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} - engines: {node: '>= 0.4'} - is-core-module@2.16.2: resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} engines: {node: '>= 0.4'} - is-data-view@1.0.2: - resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} - engines: {node: '>= 0.4'} - - is-date-object@1.1.0: - resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} - engines: {node: '>= 0.4'} - - is-document.all@1.0.0: - resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} - engines: {node: '>= 0.4'} - is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - is-finalizationregistry@1.1.1: - resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} - engines: {node: '>= 0.4'} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-generator-function@1.1.2: - resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} - engines: {node: '>= 0.4'} - is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - is-map@2.0.3: - resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} - engines: {node: '>= 0.4'} - - is-negative-zero@2.0.3: - resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} - engines: {node: '>= 0.4'} - - is-number-object@1.1.1: - resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} - engines: {node: '>= 0.4'} + is-identifier@1.1.0: + resolution: {integrity: sha512-NhOds0mDx9lJu+1lBRO0xbwFo5nobA7GCk/0e5xjr6+6XugX985+0OyGX35BNrTkPAsdLcIKg02HUQJOK8D8kw==} + engines: {node: '>=18'} is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - is-path-inside@3.0.3: - resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} - engines: {node: '>=8'} - - is-plain-obj@2.1.0: - resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} - engines: {node: '>=8'} + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} - is-regex@1.2.1: - resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} - engines: {node: '>= 0.4'} - - is-set@2.0.3: - resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} - engines: {node: '>= 0.4'} - - is-shared-array-buffer@1.0.4: - resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} - engines: {node: '>= 0.4'} - is-stream@1.1.0: resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} engines: {node: '>=0.10.0'} @@ -1363,51 +1092,21 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} - is-string@1.1.1: - resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} - engines: {node: '>= 0.4'} - - is-symbol@1.1.1: - resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} - engines: {node: '>= 0.4'} - - is-typed-array@1.1.15: - resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} - engines: {node: '>= 0.4'} - is-unicode-supported@0.1.0: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} - is-weakmap@2.0.2: - resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} - engines: {node: '>= 0.4'} - - is-weakref@1.1.1: - resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} - engines: {node: '>= 0.4'} - - is-weakset@2.0.4: - resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} - engines: {node: '>= 0.4'} - - isarray@2.0.5: - resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} - isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - - jose@6.2.3: - resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + jose@6.2.5: + resolution: {integrity: sha512-2E5L2yRp03FnwreJLJX8/r7mHiZICCf8kG7fAsTWkSQTDAcc46NIZoQLKy+EJ8sPoJlxyS4OQR5H70LjIZZlIQ==} js-base64@3.7.8: resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} - js-yaml@5.0.0: - resolution: {integrity: sha512-GSvaPUbk1U+FMZ7rJzF+F8e5YVtu7KnD40et/5rBXXRBv2jCO9L3qCewvIDDdudC0QycTFlf6EAA+h3kxBsuUw==} + js-yaml@5.4.1: + resolution: {integrity: sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==} hasBin: true jsesc@3.1.0: @@ -1415,9 +1114,6 @@ packages: engines: {node: '>=6'} hasBin: true - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -1430,12 +1126,8 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - json5@1.0.2: - resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} - hasBin: true - - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} @@ -1445,6 +1137,10 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + locate-path@8.0.0: + resolution: {integrity: sha512-XT9ewWAC43tiAV7xDAPflMkG0qOPn2QjHqlgX8FOqmWa/rxnyYDulF9T0F7tRy1u+TVTmK/M//6VIOye+2zDXg==} + engines: {node: '>=20'} + lodash._baseiteratee@4.7.0: resolution: {integrity: sha512-nqB9M+wITz0BX/Q2xg6fQ8mLkyfF7MU7eE+MNBNjTHFKeKaZAPEzEg+E8LWxKWf1DQVflNEn9N49yAuqKh2mWQ==} @@ -1469,19 +1165,23 @@ packages: lodash.uniqby@4.5.0: resolution: {integrity: sha512-IRt7cfTtHy6f1aRVA5n7kT8rgN3N1nH6MOWLcHfpWG2SH19E3JksLK38MktLxZDhlAjCP9jpIXkOnRXlu6oByQ==} - log-symbols@4.1.0: - resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} - engines: {node: '>=10'} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + make-asynchronous@1.1.0: + resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==} + engines: {node: '>=18'} math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - media-typer@1.1.0: - resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + mdn-data@2.34.0: + resolution: {integrity: sha512-OgIlLv0NxJKVW4GTSAoEgpRGd4F2XCqGinK0MsMlBCCS/Zcm2/LsbercNWNA7PeMMcjl75NnI97eqyo7zkdxWA==} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} engines: {node: '>= 0.8'} merge-descriptors@2.0.0: @@ -1508,17 +1208,10 @@ packages: resolution: {integrity: sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==} engines: {node: 18 || 20 || >=22} - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} - minimatch@3.1.4: - resolution: {integrity: sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==} - - minimatch@9.0.7: - resolution: {integrity: sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==} - engines: {node: '>=16 || 14 >=14.17'} - minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -1530,9 +1223,9 @@ packages: resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} engines: {node: '>= 18'} - mocha@11.7.6: - resolution: {integrity: sha512-nS9xOGbw2I3cjCpxwZAEJ9xK9lmJ08vEkQvLtz4du9ZrF9UrjRpeJGiIgl2Z+Qs++pmB4ecDe48Fwsh+j+j7xA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + mocha@12.0.1: + resolution: {integrity: sha512-/ILgtFHV+R7EJLwN0OkrPPOu6wbJyrtuRtTJ5szdFHv2A3mPmkbu3r1H2q6FhaAfywDJCulcQQ2S9qDGfZW6dw==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true ms@2.1.3: @@ -1551,20 +1244,16 @@ packages: node-apk@1.2.1: resolution: {integrity: sha512-I0TY1x5m1pkFzjYdaGrrAu/Mh9qnnk2/BoMAU6bvBxTTD/oNQyTWbu3LTdONgV2rnLHf23jJ00Y/VV4BzZ6YXQ==} - node-exports-info@1.6.0: - resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} - engines: {node: '>= 0.4'} - node-forge@1.4.0: resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} engines: {node: '>= 6.13.0'} - node-releases@2.0.48: - resolution: {integrity: sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==} + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} engines: {node: '>=18'} - node-stream-zip@1.15.0: - resolution: {integrity: sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==} + node-stream-zip@1.16.0: + resolution: {integrity: sha512-ObaRrRoR8T68wF6suxHd7R4XQNamij6ZQHrwG7Dx1D2zeHcDNLsIOBcWrIwtDm7AsCXBguaPHgXhcjxDa2szrg==} engines: {node: '>=0.12.0'} npm-run-path@2.0.2: @@ -1579,30 +1268,6 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} - object-keys@1.1.1: - resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} - engines: {node: '>= 0.4'} - - object.assign@4.1.7: - resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} - engines: {node: '>= 0.4'} - - object.entries@1.1.9: - resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} - engines: {node: '>= 0.4'} - - object.fromentries@2.0.8: - resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} - engines: {node: '>= 0.4'} - - object.groupby@1.0.3: - resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} - engines: {node: '>= 0.4'} - - object.values@1.2.1: - resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} - engines: {node: '>= 0.4'} - on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -1614,9 +1279,9 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - own-keys@1.0.1: - resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} - engines: {node: '>= 0.4'} + p-event@6.0.1: + resolution: {integrity: sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==} + engines: {node: '>=16.17'} p-finally@1.0.0: resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} @@ -1626,12 +1291,21 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + p-timeout@6.1.4: + resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} + engines: {node: '>=14.16'} parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} @@ -1652,9 +1326,9 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} @@ -1666,8 +1340,8 @@ packages: resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} pkce-challenge@5.0.1: @@ -1682,16 +1356,12 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} - possible-typed-array-names@1.1.0: - resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} - engines: {node: '>= 0.4'} - prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier@3.8.4: - resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==} + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} engines: {node: '>=14'} hasBin: true @@ -1709,8 +1379,12 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.15.2: - resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + qified@0.10.1: + resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} + engines: {node: '>=20'} + + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} querystringify@2.2.0: @@ -1719,38 +1393,30 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + quote-js-string@0.1.0: + resolution: {integrity: sha512-Y3NoRtprEEZQD8RfxMCfS0ZTqc4e+i18OrXEXAvpM6TfC/3y+0L5rNbZiSnbBBEkDfFzbpd8o+cE8q3/anjMGA==} + engines: {node: '>=22'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} engines: {node: '>= 0.6'} raw-body@3.0.2: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} - readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} - engines: {node: '>= 14.18.0'} + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} rechoir@0.6.2: resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} engines: {node: '>= 0.10'} - reflect.getprototypeof@1.0.10: - resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} - engines: {node: '>= 0.4'} - - regexp.prototype.flags@1.5.4: - resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} - engines: {node: '>= 0.4'} - regjsparser@0.13.2: resolution: {integrity: sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==} hasBin: true - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -1758,16 +1424,15 @@ packages: requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + reserved-identifiers@1.2.0: + resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} + engines: {node: '>=18'} + resolve@1.22.12: resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} engines: {node: '>= 0.4'} hasBin: true - resolve@2.0.0-next.7: - resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} - engines: {node: '>= 0.4'} - hasBin: true - retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -1783,18 +1448,6 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - safe-array-concat@1.1.4: - resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} - engines: {node: '>=0.4'} - - safe-push-apply@1.0.0: - resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} - engines: {node: '>= 0.4'} - - safe-regex-test@1.1.0: - resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} - engines: {node: '>= 0.4'} - safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -1802,10 +1455,6 @@ packages: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -1815,28 +1464,16 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} - serialize-javascript@7.0.6: - resolution: {integrity: sha512-ATTK5Q4gFVg0YDp1my2vqygyvhcklD/UV5GIlYHooGTn/NogJqIzpetkD6E5kmuVULqz/S9inUL25XcAgDRJQg==} + serialize-javascript@7.1.1: + resolution: {integrity: sha512-k3CMsaIvvdSwm8oLB4MXSl0wH2/cwlH7xGcnRd2DaeRmBkbzYmyT8j0tsX60DwD1eRwHTpNpH8ljKu9oUT1MeQ==} engines: {node: '>=20.0.0'} serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} - set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} - - set-function-name@2.0.2: - resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} - engines: {node: '>= 0.4'} - - set-proto@1.0.0: - resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} - engines: {node: '>= 0.4'} - - setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} shebang-command@1.2.0: resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} @@ -1883,53 +1520,17 @@ packages: signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - stop-iteration-iterator@1.1.0: - resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} - engines: {node: '>= 0.4'} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} - - string.prototype.trim@1.2.11: - resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} - engines: {node: '>= 0.4'} - - string.prototype.trimend@1.0.10: - resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} - engines: {node: '>= 0.4'} - - string.prototype.trimstart@1.0.8: - resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} - engines: {node: '>= 0.4'} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-ansi@7.2.0: - resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} - engines: {node: '>=12'} - - strip-bom@3.0.0: - resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} - engines: {node: '>=4'} - strip-eof@1.0.0: resolution: {integrity: sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==} engines: {node: '>=0.10.0'} @@ -1938,13 +1539,13 @@ packages: resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} engines: {node: '>=12'} - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} + super-regex@1.1.0: + resolution: {integrity: sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==} + engines: {node: '>=18'} supports-color@8.1.1: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} @@ -1954,10 +1555,14 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - tar@7.5.16: - resolution: {integrity: sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==} + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} engines: {node: '>=18'} + time-span@5.1.0: + resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} + engines: {node: '>=12'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} @@ -1976,14 +1581,11 @@ packages: peerDependencies: typescript: '>=4.8.4' - tsconfig-paths@3.15.0: - resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.22.4: - resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + tsx@4.23.13: + resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==} engines: {node: '>=18.0.0'} hasBin: true @@ -1995,28 +1597,16 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + type-is@2.1.0: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} - typed-array-buffer@1.0.3: - resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} - engines: {node: '>= 0.4'} - - typed-array-byte-length@1.0.3: - resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} - engines: {node: '>= 0.4'} - - typed-array-byte-offset@1.0.4: - resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} - engines: {node: '>= 0.4'} - - typed-array-length@1.0.8: - resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} - engines: {node: '>= 0.4'} - - typescript-eslint@8.61.1: - resolution: {integrity: sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==} + typescript-eslint@8.70.0: + resolution: {integrity: sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2027,19 +1617,19 @@ packages: engines: {node: '>=14.17'} hasBin: true - unbox-primitive@1.1.0: - resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} - engines: {node: '>= 0.4'} + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} - undici-types@8.3.0: - resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -2054,21 +1644,8 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - which-boxed-primitive@1.1.1: - resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} - engines: {node: '>= 0.4'} - - which-builtin-type@1.2.1: - resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} - engines: {node: '>= 0.4'} - - which-collection@1.0.2: - resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} - engines: {node: '>= 0.4'} - - which-typed-array@1.1.22: - resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} - engines: {node: '>= 0.4'} + web-worker@1.5.0: + resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} which@1.3.1: resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} @@ -2083,16 +1660,8 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - workerpool@9.3.4: - resolution: {integrity: sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==} - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} + workerpool@10.0.3: + resolution: {integrity: sha512-6z2Iis68Wqth93/G/wJP9u+R3O+d2XTlgWChGCwuT1qLbBsOYueGRZuJ++v3mtDP5KjYdy+WzvWC+VWETSVXJA==} wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -2101,25 +1670,14 @@ packages: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - yallist@5.0.0: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs-unparser@2.0.0: - resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} - engines: {node: '>=10'} - - yargs@17.7.3: - resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} - engines: {node: '>=12'} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true yazl@3.3.1: resolution: {integrity: sha512-BbETDVWG+VcMUle37k5Fqp//7SDOK2/1+T7X8TD96M3D9G8jK5VLUdQVdVjGi8im7FGkazX7kk5hkU8X4L5Bng==} @@ -2128,111 +1686,125 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: zod: ^3.25.28 || ^4 - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.6.2: + resolution: {integrity: sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ==} snapshots: - '@babel/helper-validator-identifier@7.29.7': {} + '@cacheable/memory@2.2.0': + dependencies: + '@cacheable/utils': 2.5.0 + '@keyv/bigmap': 1.3.1(keyv@5.6.0) + hookified: 1.15.1 + keyv: 5.6.0 - '@clack/core@1.4.2': + '@cacheable/utils@2.5.0': + dependencies: + hashery: 1.5.1 + keyv: 5.6.0 + + '@clack/core@1.5.0': dependencies: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@clack/prompts@1.6.0': + '@clack/prompts@1.8.0': dependencies: - '@clack/core': 1.4.2 + '@clack/core': 1.5.0 fast-string-width: 3.0.2 fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@esbuild/aix-ppc64@0.28.1': + '@esbuild/aix-ppc64@0.28.2': optional: true - '@esbuild/android-arm64@0.28.1': + '@esbuild/android-arm64@0.28.2': optional: true - '@esbuild/android-arm@0.28.1': + '@esbuild/android-arm@0.28.2': optional: true - '@esbuild/android-x64@0.28.1': + '@esbuild/android-x64@0.28.2': optional: true - '@esbuild/darwin-arm64@0.28.1': + '@esbuild/darwin-arm64@0.28.2': optional: true - '@esbuild/darwin-x64@0.28.1': + '@esbuild/darwin-x64@0.28.2': optional: true - '@esbuild/freebsd-arm64@0.28.1': + '@esbuild/freebsd-arm64@0.28.2': optional: true - '@esbuild/freebsd-x64@0.28.1': + '@esbuild/freebsd-x64@0.28.2': optional: true - '@esbuild/linux-arm64@0.28.1': + '@esbuild/linux-arm64@0.28.2': optional: true - '@esbuild/linux-arm@0.28.1': + '@esbuild/linux-arm@0.28.2': optional: true - '@esbuild/linux-ia32@0.28.1': + '@esbuild/linux-ia32@0.28.2': optional: true - '@esbuild/linux-loong64@0.28.1': + '@esbuild/linux-loong64@0.28.2': optional: true - '@esbuild/linux-mips64el@0.28.1': + '@esbuild/linux-mips64el@0.28.2': optional: true - '@esbuild/linux-ppc64@0.28.1': + '@esbuild/linux-ppc64@0.28.2': optional: true - '@esbuild/linux-riscv64@0.28.1': + '@esbuild/linux-riscv64@0.28.2': optional: true - '@esbuild/linux-s390x@0.28.1': + '@esbuild/linux-s390x@0.28.2': optional: true - '@esbuild/linux-x64@0.28.1': + '@esbuild/linux-x64@0.28.2': optional: true - '@esbuild/netbsd-arm64@0.28.1': + '@esbuild/netbsd-arm64@0.28.2': optional: true - '@esbuild/netbsd-x64@0.28.1': + '@esbuild/netbsd-x64@0.28.2': optional: true - '@esbuild/openbsd-arm64@0.28.1': + '@esbuild/openbsd-arm64@0.28.2': optional: true - '@esbuild/openbsd-x64@0.28.1': + '@esbuild/openbsd-x64@0.28.2': optional: true - '@esbuild/openharmony-arm64@0.28.1': + '@esbuild/openharmony-arm64@0.28.2': optional: true - '@esbuild/sunos-x64@0.28.1': + '@esbuild/sunos-x64@0.28.2': optional: true - '@esbuild/win32-arm64@0.28.1': + '@esbuild/win32-arm64@0.28.2': optional: true - '@esbuild/win32-ia32@0.28.1': + '@esbuild/win32-ia32@0.28.2': optional: true - '@esbuild/win32-x64@0.28.1': + '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.5.0)': + '@eslint-community/eslint-utils@4.10.1(eslint@10.10.0)': dependencies: - eslint: 10.5.0 + eslint: 10.10.0 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -2241,11 +1813,11 @@ snapshots: dependencies: '@eslint/object-schema': 3.0.5 debug: 4.4.3(supports-color@8.1.1) - minimatch: 10.2.5 + minimatch: 10.2.6 transitivePeerDependencies: - supports-color - '@eslint/config-helpers@0.6.0': + '@eslint/config-helpers@0.7.0': dependencies: '@eslint/core': 1.2.1 @@ -2253,20 +1825,25 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.5.0)': + '@eslint/css-tree@4.1.0': + dependencies: + mdn-data: 2.34.0 + source-map-js: 1.2.1 + + '@eslint/js@10.0.1(eslint@10.10.0)': optionalDependencies: - eslint: 10.5.0 + eslint: 10.10.0 '@eslint/object-schema@3.0.5': {} - '@eslint/plugin-kit@0.7.2': + '@eslint/plugin-kit@0.7.3': dependencies: '@eslint/core': 1.2.1 levn: 0.4.1 - '@hono/node-server@1.19.14(hono@4.12.26)': + '@hono/node-server@2.0.12(hono@4.13.5)': dependencies: - hono: 4.12.26 + hono: 4.13.5 '@humanfs/core@0.19.2': dependencies: @@ -2284,22 +1861,21 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.2.0 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 - '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.3 - '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + '@keyv/bigmap@1.3.1(keyv@5.6.0)': + dependencies: + hashery: 1.5.1 + hookified: 1.15.1 + keyv: 5.6.0 + + '@keyv/serialize@1.1.1': {} + + '@modelcontextprotocol/sdk@1.30.0(zod@4.6.2)': dependencies: - '@hono/node-server': 1.19.14(hono@4.12.26) + '@hono/node-server': 2.0.12(hono@4.13.5) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -2308,14 +1884,14 @@ snapshots: eventsource: 3.0.7 eventsource-parser: 3.1.0 express: 5.2.1 - express-rate-limit: 8.5.2(express@5.2.1) - hono: 4.12.26 - jose: 6.2.3 + express-rate-limit: 8.6.1(express@5.2.1) + hono: 4.13.5 + jose: 6.2.5 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 4.4.3 - zod-to-json-schema: 3.25.2(zod@4.4.3) + zod: 4.6.2 + zod-to-json-schema: 3.25.2(zod@4.6.2) transitivePeerDependencies: - supports-color @@ -2331,42 +1907,37 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@pkgjs/parseargs@0.11.0': - optional: true - - '@rtsao/scc@1.1.0': {} - - '@supabase/auth-js@2.108.2': + '@supabase/auth-js@2.116.0': dependencies: tslib: 2.8.1 - '@supabase/functions-js@2.108.2': + '@supabase/functions-js@2.116.0': dependencies: tslib: 2.8.1 - '@supabase/phoenix@0.4.4': {} + '@supabase/phoenix@0.4.5': {} - '@supabase/postgrest-js@2.108.2': + '@supabase/postgrest-js@2.116.0': dependencies: tslib: 2.8.1 - '@supabase/realtime-js@2.108.2': + '@supabase/realtime-js@2.116.0': dependencies: - '@supabase/phoenix': 0.4.4 + '@supabase/phoenix': 0.4.5 tslib: 2.8.1 - '@supabase/storage-js@2.108.2': + '@supabase/storage-js@2.116.0': dependencies: iceberg-js: 0.8.1 tslib: 2.8.1 - '@supabase/supabase-js@2.108.2': + '@supabase/supabase-js@2.116.0': dependencies: - '@supabase/auth-js': 2.108.2 - '@supabase/functions-js': 2.108.2 - '@supabase/postgrest-js': 2.108.2 - '@supabase/realtime-js': 2.108.2 - '@supabase/storage-js': 2.108.2 + '@supabase/auth-js': 2.116.0 + '@supabase/functions-js': 2.116.0 + '@supabase/postgrest-js': 2.116.0 + '@supabase/realtime-js': 2.116.0 + '@supabase/storage-js': 2.116.0 '@types/chai@5.2.3': dependencies: @@ -2383,84 +1954,82 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/json5@0.0.29': {} - '@types/mocha@10.0.10': {} - '@types/node@26.0.0': + '@types/node@26.5.1': dependencies: - undici-types: 8.3.0 + undici-types: 8.9.0 '@types/yazl@3.3.1': dependencies: - '@types/node': 26.0.0 + '@types/node': 26.5.1 - '@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0)(typescript@6.0.3))(eslint@10.5.0)(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@10.10.0)(typescript@6.0.3))(eslint@10.10.0)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.61.1(eslint@10.5.0)(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.61.1 - '@typescript-eslint/type-utils': 8.61.1(eslint@10.5.0)(typescript@6.0.3) - '@typescript-eslint/utils': 8.61.1(eslint@10.5.0)(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.61.1 - eslint: 10.5.0 - ignore: 7.0.5 + '@typescript-eslint/parser': 8.70.0(eslint@10.10.0)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.70.0 + '@typescript-eslint/type-utils': 8.70.0(eslint@10.10.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.70.0(eslint@10.10.0)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.70.0 + eslint: 10.10.0 + ignore: 7.0.9 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.61.1(eslint@10.5.0)(typescript@6.0.3)': + '@typescript-eslint/parser@8.70.0(eslint@10.10.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.61.1 - '@typescript-eslint/types': 8.61.1 - '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.61.1 + '@typescript-eslint/scope-manager': 8.70.0 + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/typescript-estree': 8.70.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.70.0 debug: 4.4.3(supports-color@8.1.1) - eslint: 10.5.0 + eslint: 10.10.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.61.1(typescript@6.0.3)': + '@typescript-eslint/project-service@8.70.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@6.0.3) - '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@6.0.3) + '@typescript-eslint/types': 8.70.0 debug: 4.4.3(supports-color@8.1.1) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.61.1': + '@typescript-eslint/scope-manager@8.70.0': dependencies: - '@typescript-eslint/types': 8.61.1 - '@typescript-eslint/visitor-keys': 8.61.1 + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/visitor-keys': 8.70.0 - '@typescript-eslint/tsconfig-utils@8.61.1(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.70.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.61.1(eslint@10.5.0)(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.70.0(eslint@10.10.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.61.1 - '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3) - '@typescript-eslint/utils': 8.61.1(eslint@10.5.0)(typescript@6.0.3) + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/typescript-estree': 8.70.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.70.0(eslint@10.10.0)(typescript@6.0.3) debug: 4.4.3(supports-color@8.1.1) - eslint: 10.5.0 + eslint: 10.10.0 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.61.1': {} + '@typescript-eslint/types@8.70.0': {} - '@typescript-eslint/typescript-estree@8.61.1(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.70.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.61.1(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.61.1(typescript@6.0.3) - '@typescript-eslint/types': 8.61.1 - '@typescript-eslint/visitor-keys': 8.61.1 + '@typescript-eslint/project-service': 8.70.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@6.0.3) + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/visitor-keys': 8.70.0 debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.3 semver: 7.8.5 @@ -2470,34 +2039,34 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.61.1(eslint@10.5.0)(typescript@6.0.3)': + '@typescript-eslint/utils@8.70.0(eslint@10.10.0)(typescript@6.0.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0) - '@typescript-eslint/scope-manager': 8.61.1 - '@typescript-eslint/types': 8.61.1 - '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3) - eslint: 10.5.0 + '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0) + '@typescript-eslint/scope-manager': 8.70.0 + '@typescript-eslint/types': 8.70.0 + '@typescript-eslint/typescript-estree': 8.70.0(typescript@6.0.3) + eslint: 10.10.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.61.1': + '@typescript-eslint/visitor-keys@8.70.0': dependencies: - '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/types': 8.70.0 eslint-visitor-keys: 5.0.1 - '@xmldom/xmldom@0.9.10': {} + '@xmldom/xmldom@0.9.12': {} accepts@2.0.0: dependencies: mime-types: 3.0.2 negotiator: 1.0.0 - acorn-jsx@5.3.2(acorn@8.17.0): + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: - acorn: 8.17.0 + acorn: 8.18.0 - acorn@8.17.0: {} + acorn@8.18.0: {} ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: @@ -2513,87 +2082,17 @@ snapshots: ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 + fast-uri: 3.1.6 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - ansi-regex@5.0.1: {} - - ansi-regex@6.2.2: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - ansi-styles@6.2.3: {} - argparse@2.0.1: {} - array-buffer-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - is-array-buffer: 3.0.5 - - array-includes@3.1.9: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-object-atoms: 1.1.2 - get-intrinsic: 1.3.0 - is-string: 1.1.1 - math-intrinsics: 1.1.0 - - array.prototype.findlastindex@1.2.6: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - es-shim-unscopables: 1.1.0 - - array.prototype.flat@1.3.3: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-shim-unscopables: 1.1.0 - - array.prototype.flatmap@1.3.3: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-shim-unscopables: 1.1.0 - - arraybuffer.prototype.slice@1.0.4: - dependencies: - array-buffer-byte-length: 1.0.2 - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - is-array-buffer: 3.0.5 - assertion-error@2.0.1: {} - async-function@1.0.0: {} - - available-typed-arrays@1.0.7: - dependencies: - possible-typed-array-names: 1.1.0 - - balanced-match@1.0.2: {} - balanced-match@4.0.4: {} - baseline-browser-mapping@2.10.38: {} - - big-integer@1.6.52: {} + baseline-browser-mapping@2.11.21: {} body-parser@2.3.0: dependencies: @@ -2601,24 +2100,17 @@ snapshots: content-type: 2.0.0 debug: 4.4.3(supports-color@8.1.1) http-errors: 2.0.1 - iconv-lite: 0.7.2 + iconv-lite: 0.7.3 on-finished: 2.4.1 - qs: 6.15.2 + qs: 6.16.0 raw-body: 3.0.2 type-is: 2.1.0 transitivePeerDependencies: - supports-color - bplist-parser@0.3.2: - dependencies: - big-integer: 1.6.52 - - brace-expansion@1.1.13: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 + bplist-parser@0.5.0: {} - brace-expansion@5.0.6: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -2628,57 +2120,51 @@ snapshots: browser-stdout@1.3.1: {} - browserslist@4.28.4: + browserslist@4.28.9: dependencies: - baseline-browser-mapping: 2.10.38 - caniuse-lite: 1.0.30001799 - electron-to-chromium: 1.5.376 - node-releases: 2.0.48 - update-browserslist-db: 1.2.3(browserslist@4.28.4) + baseline-browser-mapping: 2.11.21 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.422 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.9) buffer-crc32@1.0.0: {} buffer-from@1.1.2: {} - builtin-modules@5.2.0: {} + builtin-modules@5.3.0: {} bytes@3.1.2: {} + cacheable@2.5.0: + dependencies: + '@cacheable/memory': 2.2.0 + '@cacheable/utils': 2.5.0 + hookified: 1.15.1 + keyv: 5.6.0 + qified: 0.10.1 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 function-bind: 1.1.2 - call-bind@1.0.9: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - get-intrinsic: 1.3.0 - set-function-length: 1.2.2 - call-bound@1.0.4: dependencies: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - camelcase@6.3.0: {} - - caniuse-lite@1.0.30001799: {} + caniuse-lite@1.0.30001810: {} chai@6.2.2: {} - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - chalk@5.6.2: {} + chalk@6.0.0: {} change-case@5.4.4: {} - chokidar@4.0.3: + chokidar@5.0.0: dependencies: - readdirp: 4.1.2 + readdirp: 5.1.1 chownr@3.0.0: {} @@ -2686,38 +2172,26 @@ snapshots: citty@0.2.2: {} - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - combine-errors@3.0.3: dependencies: custom-error-instance: 2.1.1 lodash.uniqby: 4.5.0 - concat-map@0.0.1: {} - content-disposition@1.1.0: {} content-type@1.0.5: {} content-type@2.0.0: {} + convert-hrtime@5.0.0: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} - core-js-compat@3.49.0: + core-js-compat@3.50.0: dependencies: - browserslist: 4.28.4 + browserslist: 4.28.9 cors@2.8.6: dependencies: @@ -2740,75 +2214,29 @@ snapshots: custom-error-instance@2.1.1: {} - data-view-buffer@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-offset@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - debug@3.2.7: - dependencies: - ms: 2.1.3 - debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3 optionalDependencies: supports-color: 8.1.1 - decamelize@4.0.0: {} - deep-is@0.1.4: {} - define-data-property@1.1.4: - dependencies: - es-define-property: 1.0.1 - es-errors: 1.3.0 - gopd: 1.2.0 - - define-properties@1.2.1: - dependencies: - define-data-property: 1.1.4 - has-property-descriptors: 1.0.2 - object-keys: 1.1.1 - depd@2.0.0: {} detect-indent@7.0.2: {} diff@8.0.3: {} - doctrine@2.1.0: - dependencies: - esutils: 2.0.3 - dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 es-errors: 1.3.0 gopd: 1.2.0 - eastasianwidth@0.2.0: {} - ee-first@1.1.1: {} - electron-to-chromium@1.5.376: {} - - emoji-regex@8.0.0: {} - - emoji-regex@9.2.2: {} + electron-to-chromium@1.5.422: {} encodeurl@2.0.0: {} @@ -2816,69 +2244,7 @@ snapshots: dependencies: once: 1.4.0 - es-abstract-get@1.0.0: - dependencies: - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - is-callable: 1.2.7 - object-inspect: 1.13.4 - - es-abstract@1.24.2: - dependencies: - array-buffer-byte-length: 1.0.2 - arraybuffer.prototype.slice: 1.0.4 - available-typed-arrays: 1.0.7 - call-bind: 1.0.9 - call-bound: 1.0.4 - data-view-buffer: 1.0.2 - data-view-byte-length: 1.0.2 - data-view-byte-offset: 1.0.1 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - es-set-tostringtag: 2.1.0 - es-to-primitive: 1.3.1 - function.prototype.name: 1.2.0 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - get-symbol-description: 1.1.0 - globalthis: 1.0.4 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - has-proto: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.4 - internal-slot: 1.1.0 - is-array-buffer: 3.0.5 - is-callable: 1.2.7 - is-data-view: 1.0.2 - is-negative-zero: 2.0.3 - is-regex: 1.2.1 - is-set: 2.0.3 - is-shared-array-buffer: 1.0.4 - is-string: 1.1.1 - is-typed-array: 1.1.15 - is-weakref: 1.1.1 - math-intrinsics: 1.1.0 - object-inspect: 1.13.4 - object-keys: 1.1.1 - object.assign: 4.1.7 - own-keys: 1.0.1 - regexp.prototype.flags: 1.5.4 - safe-array-concat: 1.1.4 - safe-push-apply: 1.0.0 - safe-regex-test: 1.1.0 - set-proto: 1.0.0 - stop-iteration-iterator: 1.1.0 - string.prototype.trim: 1.2.11 - string.prototype.trimend: 1.0.10 - string.prototype.trimstart: 1.0.8 - typed-array-buffer: 1.0.3 - typed-array-byte-length: 1.0.3 - typed-array-byte-offset: 1.0.4 - typed-array-length: 1.0.8 - unbox-primitive: 1.1.0 - which-typed-array: 1.1.22 + entities@8.0.0: {} es-define-property@1.0.1: {} @@ -2888,53 +2254,34 @@ snapshots: dependencies: es-errors: 1.3.0 - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.4 - - es-shim-unscopables@1.1.0: - dependencies: - hasown: 2.0.4 - - es-to-primitive@1.3.1: - dependencies: - es-abstract-get: 1.0.0 - es-errors: 1.3.0 - is-callable: 1.2.7 - is-date-object: 1.1.0 - is-symbol: 1.1.1 - - esbuild@0.28.1: + esbuild@0.28.2: optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 escalade@3.2.0: {} @@ -2942,76 +2289,33 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.5.0): - dependencies: - eslint: 10.5.0 - - eslint-import-resolver-node@0.3.10: - dependencies: - debug: 3.2.7 - is-core-module: 2.16.2 - resolve: 2.0.0-next.7 - transitivePeerDependencies: - - supports-color - - eslint-module-utils@2.13.0(@typescript-eslint/parser@8.61.1(eslint@10.5.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.5.0): + eslint-config-prettier@10.1.8(eslint@10.10.0): dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 8.61.1(eslint@10.5.0)(typescript@6.0.3) - eslint: 10.5.0 - eslint-import-resolver-node: 0.3.10 - transitivePeerDependencies: - - supports-color + eslint: 10.10.0 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.1(eslint@10.5.0)(typescript@6.0.3))(eslint@10.5.0): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 10.5.0 - eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.61.1(eslint@10.5.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.5.0) - hasown: 2.0.4 - is-core-module: 2.16.2 - is-glob: 4.0.3 - minimatch: 3.1.4 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.10 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.61.1(eslint@10.5.0)(typescript@6.0.3) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - - eslint-plugin-unicorn@68.0.0(eslint@10.5.0): + eslint-plugin-unicorn@74.0.0(eslint@10.10.0): dependencies: - '@babel/helper-validator-identifier': 7.29.7 - '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0) - browserslist: 4.28.4 + '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0) + '@eslint/css-tree': 4.1.0 + browserslist: 4.28.9 change-case: 5.4.4 ci-info: 4.4.0 - core-js-compat: 3.49.0 + core-js-compat: 3.50.0 detect-indent: 7.0.2 - eslint: 10.5.0 + entities: 8.0.0 + eslint: 10.10.0 find-up-simple: 1.0.1 - globals: 17.6.0 + globals: 17.12.0 indent-string: 5.0.0 is-builtin-module: 5.0.0 - jsesc: 3.1.0 + is-identifier: 1.1.0 pluralize: 8.0.0 + quote-js-string: 0.1.0 regjsparser: 0.13.2 + reserved-identifiers: 1.2.0 semver: 7.8.5 strip-indent: 4.1.1 + yaml: 2.9.0 eslint-scope@9.1.2: dependencies: @@ -3024,14 +2328,14 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.5.0: + eslint@10.10.0: dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0) + '@eslint-community/eslint-utils': 4.10.1(eslint@10.10.0) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5 - '@eslint/config-helpers': 0.6.0 + '@eslint/config-helpers': 0.7.0 '@eslint/core': 1.2.1 - '@eslint/plugin-kit': 0.7.2 + '@eslint/plugin-kit': 0.7.3 '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 @@ -3046,14 +2350,14 @@ snapshots: esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 + file-entry-cache: 11.1.5 find-up: 5.0.0 glob-parent: 6.0.2 ignore: 5.3.2 imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.5 + minimatch: 10.2.6 natural-compare: 1.4.0 optionator: 0.9.4 transitivePeerDependencies: @@ -3061,8 +2365,8 @@ snapshots: espree@11.2.0: dependencies: - acorn: 8.17.0 - acorn-jsx: 5.3.2(acorn@8.17.0) + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) eslint-visitor-keys: 5.0.1 esquery@1.7.0: @@ -3095,10 +2399,13 @@ snapshots: signal-exit: 3.0.7 strip-eof: 1.0.0 - express-rate-limit@8.5.2(express@5.2.1): + express-rate-limit@8.6.1(express@5.2.1): dependencies: + debug: 4.4.3(supports-color@8.1.1) express: 5.2.1 - ip-address: 10.2.0 + ip-address: 10.3.1 + transitivePeerDependencies: + - supports-color express@5.2.1: dependencies: @@ -3122,8 +2429,8 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.15.2 - range-parser: 1.2.1 + qs: 6.16.0 + range-parser: 1.3.0 router: 2.2.0 send: 1.2.1 serve-static: 2.2.1 @@ -3153,7 +2460,7 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.2: {} + fast-uri@3.1.6: {} fast-wrap-ansi@0.2.2: dependencies: @@ -3163,13 +2470,13 @@ snapshots: dependencies: reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.4): + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: - picomatch: 4.0.4 + picomatch: 4.0.7 - file-entry-cache@8.0.0: + file-entry-cache@11.1.5: dependencies: - flat-cache: 4.0.1 + flat-cache: 6.1.23 fill-range@7.1.1: dependencies: @@ -3193,23 +2500,18 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 - flat-cache@4.0.1: + find-up@8.0.0: dependencies: - flatted: 3.4.2 - keyv: 4.5.4 - - flat@5.0.2: {} - - flatted@3.4.2: {} + locate-path: 8.0.0 + unicorn-magic: 0.3.0 - for-each@0.3.5: + flat-cache@6.1.23: dependencies: - is-callable: 1.2.7 + cacheable: 2.5.0 + flatted: 3.4.4 + hookified: 1.15.1 - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 + flatted@3.4.4: {} forwarded@0.2.0: {} @@ -3220,23 +2522,7 @@ snapshots: function-bind@1.1.2: {} - function.prototype.name@1.2.0: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - es-define-property: 1.0.1 - es-errors: 1.3.0 - functions-have-names: 1.2.3 - has-property-descriptors: 1.0.2 - hasown: 2.0.4 - is-callable: 1.2.7 - is-document.all: 1.0.0 - - functions-have-names@1.2.3: {} - - generator-function@2.0.1: {} - - get-caller-file@2.0.5: {} + function-timeout@1.0.2: {} get-intrinsic@1.3.0: dependencies: @@ -3260,12 +2546,6 @@ snapshots: dependencies: pump: 3.0.4 - get-symbol-description@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -3274,51 +2554,35 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@10.5.0: + glob@13.0.6: dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.7 + minimatch: 10.2.3 minipass: 7.1.3 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - - globals@17.6.0: {} + path-scurry: 2.0.2 - globalthis@1.0.4: - dependencies: - define-properties: 1.2.1 - gopd: 1.2.0 + globals@17.12.0: {} gopd@1.2.0: {} graceful-fs@4.2.11: {} - has-bigints@1.1.0: {} - has-flag@4.0.0: {} - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.1 - - has-proto@1.2.0: - dependencies: - dunder-proto: 1.0.1 - has-symbols@1.1.0: {} - has-tostringtag@1.0.2: + hashery@1.5.1: dependencies: - has-symbols: 1.1.0 + hookified: 1.15.1 hasown@2.0.4: dependencies: function-bind: 1.1.2 - he@1.2.0: {} + hono@4.13.5: {} - hono@4.12.26: {} + hookified@1.15.1: {} + + hookified@2.2.0: {} http-errors@2.0.1: dependencies: @@ -3332,13 +2596,17 @@ snapshots: iceberg-js@0.8.1: {} - iconv-lite@0.7.2: + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 + identifier-regex@1.1.0: + dependencies: + reserved-identifiers: 1.2.0 + ignore@5.3.2: {} - ignore@7.0.5: {} + ignore@7.0.9: {} imurmurhash@0.1.4: {} @@ -3346,170 +2614,55 @@ snapshots: inherits@2.0.4: {} - internal-slot@1.1.0: - dependencies: - es-errors: 1.3.0 - hasown: 2.0.4 - side-channel: 1.1.1 - interpret@1.4.0: {} - ip-address@10.2.0: {} + ip-address@10.3.1: {} ipaddr.js@1.9.1: {} - is-array-buffer@3.0.5: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - - is-async-function@2.1.1: - dependencies: - async-function: 1.0.0 - call-bound: 1.0.4 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - - is-bigint@1.1.0: - dependencies: - has-bigints: 1.1.0 - - is-boolean-object@1.2.2: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - is-builtin-module@5.0.0: dependencies: - builtin-modules: 5.2.0 - - is-callable@1.2.7: {} + builtin-modules: 5.3.0 is-core-module@2.16.2: dependencies: hasown: 2.0.4 - is-data-view@1.0.2: - dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - is-typed-array: 1.1.15 - - is-date-object@1.1.0: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-document.all@1.0.0: - dependencies: - call-bound: 1.0.4 - is-extglob@2.1.1: {} - is-finalizationregistry@1.1.1: - dependencies: - call-bound: 1.0.4 - - is-fullwidth-code-point@3.0.0: {} - - is-generator-function@1.1.2: - dependencies: - call-bound: 1.0.4 - generator-function: 2.0.1 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - is-glob@4.0.3: dependencies: is-extglob: 2.1.1 - is-map@2.0.3: {} - - is-negative-zero@2.0.3: {} - - is-number-object@1.1.1: + is-identifier@1.1.0: dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 + identifier-regex: 1.1.0 + super-regex: 1.1.0 is-number@7.0.0: {} - is-path-inside@3.0.3: {} - - is-plain-obj@2.1.0: {} + is-path-inside@4.0.0: {} is-promise@4.0.0: {} - is-regex@1.2.1: - dependencies: - call-bound: 1.0.4 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - hasown: 2.0.4 - - is-set@2.0.3: {} - - is-shared-array-buffer@1.0.4: - dependencies: - call-bound: 1.0.4 - is-stream@1.1.0: {} is-stream@2.0.1: {} - is-string@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-symbol@1.1.1: - dependencies: - call-bound: 1.0.4 - has-symbols: 1.1.0 - safe-regex-test: 1.1.0 - - is-typed-array@1.1.15: - dependencies: - which-typed-array: 1.1.22 - is-unicode-supported@0.1.0: {} - is-weakmap@2.0.2: {} - - is-weakref@1.1.1: - dependencies: - call-bound: 1.0.4 - - is-weakset@2.0.4: - dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - - isarray@2.0.5: {} - isexe@2.0.0: {} - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - - jose@6.2.3: {} + jose@6.2.5: {} js-base64@3.7.8: {} - js-yaml@5.0.0: + js-yaml@5.4.1: dependencies: argparse: 2.0.1 jsesc@3.1.0: {} - json-buffer@3.0.1: {} - json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -3518,13 +2671,9 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} - json5@1.0.2: - dependencies: - minimist: 1.2.8 - - keyv@4.5.4: + keyv@5.6.0: dependencies: - json-buffer: 3.0.1 + '@keyv/serialize': 1.1.1 levn@0.4.1: dependencies: @@ -3535,6 +2684,10 @@ snapshots: dependencies: p-locate: 5.0.0 + locate-path@8.0.0: + dependencies: + p-locate: 6.0.0 + lodash._baseiteratee@4.7.0: dependencies: lodash._stringtopath: 4.8.0 @@ -3561,16 +2714,19 @@ snapshots: lodash._baseiteratee: 4.7.0 lodash._baseuniq: 4.6.0 - log-symbols@4.1.0: - dependencies: - chalk: 4.1.2 - is-unicode-supported: 0.1.0 + lru-cache@11.5.2: {} - lru-cache@10.4.3: {} + make-asynchronous@1.1.0: + dependencies: + p-event: 6.0.1 + type-fest: 4.41.0 + web-worker: 1.5.0 math-intrinsics@1.1.0: {} - media-typer@1.1.0: {} + mdn-data@2.34.0: {} + + media-typer@1.1.1: {} merge-descriptors@2.0.0: {} @@ -3589,19 +2745,11 @@ snapshots: minimatch@10.2.3: dependencies: - brace-expansion: 5.0.6 - - minimatch@10.2.5: - dependencies: - brace-expansion: 5.0.6 - - minimatch@3.1.4: - dependencies: - brace-expansion: 1.1.13 + brace-expansion: 5.0.9 - minimatch@9.0.7: + minimatch@10.2.6: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.9 minimist@1.2.8: {} @@ -3611,29 +2759,24 @@ snapshots: dependencies: minipass: 7.1.3 - mocha@11.7.6: + mocha@12.0.1: dependencies: browser-stdout: 1.3.1 - chokidar: 4.0.3 + chokidar: 5.0.0 debug: 4.4.3(supports-color@8.1.1) diff: 8.0.3 - escape-string-regexp: 4.0.0 - find-up: 5.0.0 - glob: 10.5.0 - he: 1.2.0 - is-path-inside: 3.0.3 - js-yaml: 5.0.0 - log-symbols: 4.1.0 - minimatch: 9.0.7 + find-up: 8.0.0 + glob: 13.0.6 + is-path-inside: 4.0.0 + is-unicode-supported: 0.1.0 + js-yaml: 5.4.1 + minimatch: 10.2.3 ms: 2.1.3 picocolors: 1.1.1 - serialize-javascript: 7.0.6 - strip-json-comments: 3.1.1 + serialize-javascript: 7.1.1 + strip-json-comments: 5.0.3 supports-color: 8.1.1 - workerpool: 9.3.4 - yargs: 17.7.3 - yargs-parser: 21.1.1 - yargs-unparser: 2.0.0 + workerpool: 10.0.3 ms@2.1.3: {} @@ -3647,18 +2790,11 @@ snapshots: dependencies: node-forge: 1.4.0 - node-exports-info@1.6.0: - dependencies: - array.prototype.flatmap: 1.3.3 - es-errors: 1.3.0 - object.entries: 1.1.9 - semver: 6.3.1 - node-forge@1.4.0: {} - node-releases@2.0.48: {} + node-releases@2.0.54: {} - node-stream-zip@1.15.0: {} + node-stream-zip@1.16.0: {} npm-run-path@2.0.2: dependencies: @@ -3668,44 +2804,6 @@ snapshots: object-inspect@1.13.4: {} - object-keys@1.1.1: {} - - object.assign@4.1.7: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.2 - has-symbols: 1.1.0 - object-keys: 1.1.1 - - object.entries@1.1.9: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.2 - - object.fromentries@2.0.8: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-object-atoms: 1.1.2 - - object.groupby@1.0.3: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - - object.values@1.2.1: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.2 - on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -3723,11 +2821,9 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - own-keys@1.0.1: + p-event@6.0.1: dependencies: - get-intrinsic: 1.3.0 - object-keys: 1.1.1 - safe-push-apply: 1.0.0 + p-timeout: 6.1.4 p-finally@1.0.0: {} @@ -3735,11 +2831,19 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-limit@4.0.0: + dependencies: + yocto-queue: 1.2.2 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 - package-json-from-dist@1.0.1: {} + p-locate@6.0.0: + dependencies: + p-limit: 4.0.0 + + p-timeout@6.1.4: {} parseurl@1.3.3: {} @@ -3751,9 +2855,9 @@ snapshots: path-parse@1.0.7: {} - path-scurry@1.11.1: + path-scurry@2.0.2: dependencies: - lru-cache: 10.4.3 + lru-cache: 11.5.2 minipass: 7.1.3 path-to-regexp@8.4.2: {} @@ -3762,22 +2866,20 @@ snapshots: picomatch@2.3.2: {} - picomatch@4.0.4: {} + picomatch@4.0.7: {} pkce-challenge@5.0.1: {} plist@5.0.0: dependencies: - '@xmldom/xmldom': 0.9.10 + '@xmldom/xmldom': 0.9.12 xmlbuilder: 15.1.1 pluralize@8.0.0: {} - possible-typed-array-names@1.1.0: {} - prelude-ls@1.2.1: {} - prettier@3.8.4: {} + prettier@3.9.6: {} proper-lockfile@4.1.2: dependencies: @@ -3797,72 +2899,50 @@ snapshots: punycode@2.3.1: {} - qs@6.15.2: + qified@0.10.1: + dependencies: + hookified: 2.2.0 + + qs@6.16.0: dependencies: + es-define-property: 1.0.1 side-channel: 1.1.1 querystringify@2.2.0: {} queue-microtask@1.2.3: {} - range-parser@1.2.1: {} + quote-js-string@0.1.0: {} + + range-parser@1.3.0: {} raw-body@3.0.2: dependencies: bytes: 3.1.2 http-errors: 2.0.1 - iconv-lite: 0.7.2 + iconv-lite: 0.7.3 unpipe: 1.0.0 - readdirp@4.1.2: {} + readdirp@5.1.1: {} rechoir@0.6.2: dependencies: resolve: 1.22.12 - reflect.getprototypeof@1.0.10: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - which-builtin-type: 1.2.1 - - regexp.prototype.flags@1.5.4: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-errors: 1.3.0 - get-proto: 1.0.1 - gopd: 1.2.0 - set-function-name: 2.0.2 - regjsparser@0.13.2: dependencies: jsesc: 3.1.0 - require-directory@2.1.1: {} - require-from-string@2.0.2: {} requires-port@1.0.0: {} - resolve@1.22.12: - dependencies: - es-errors: 1.3.0 - is-core-module: 2.16.2 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 + reserved-identifiers@1.2.0: {} - resolve@2.0.0-next.7: + resolve@1.22.12: dependencies: es-errors: 1.3.0 is-core-module: 2.16.2 - node-exports-info: 1.6.0 - object-keys: 1.1.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 @@ -3884,31 +2964,10 @@ snapshots: dependencies: queue-microtask: 1.2.3 - safe-array-concat@1.1.4: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - has-symbols: 1.1.0 - isarray: 2.0.5 - - safe-push-apply@1.0.0: - dependencies: - es-errors: 1.3.0 - isarray: 2.0.5 - - safe-regex-test@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-regex: 1.2.1 - safer-buffer@2.1.2: {} semver@5.7.2: {} - semver@6.3.1: {} - semver@7.8.5: {} send@1.2.1: @@ -3922,12 +2981,12 @@ snapshots: mime-types: 3.0.2 ms: 2.1.3 on-finished: 2.4.1 - range-parser: 1.2.1 + range-parser: 1.3.0 statuses: 2.0.2 transitivePeerDependencies: - supports-color - serialize-javascript@7.0.6: {} + serialize-javascript@7.1.1: {} serve-static@2.2.1: dependencies: @@ -3938,28 +2997,6 @@ snapshots: transitivePeerDependencies: - supports-color - set-function-length@1.2.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - - set-function-name@2.0.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - functions-have-names: 1.2.3 - has-property-descriptors: 1.0.2 - - set-proto@1.0.0: - dependencies: - dunder-proto: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.2 - setprototypeof@1.2.0: {} shebang-command@1.2.0: @@ -4016,72 +3053,23 @@ snapshots: signal-exit@3.0.7: {} - signal-exit@4.1.0: {} - sisteransi@1.0.5: {} - statuses@2.0.2: {} - - stop-iteration-iterator@1.1.0: - dependencies: - es-errors: 1.3.0 - internal-slot: 1.1.0 + source-map-js@1.2.1: {} - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.2.0 - - string.prototype.trim@1.2.11: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-data-property: 1.1.4 - define-properties: 1.2.1 - es-abstract: 1.24.2 - es-object-atoms: 1.1.2 - has-property-descriptors: 1.0.2 - safe-regex-test: 1.1.0 - - string.prototype.trimend@1.0.10: - dependencies: - call-bind: 1.0.9 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.2 - - string.prototype.trimstart@1.0.8: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-object-atoms: 1.1.2 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-ansi@7.2.0: - dependencies: - ansi-regex: 6.2.2 - - strip-bom@3.0.0: {} + statuses@2.0.2: {} strip-eof@1.0.0: {} strip-indent@4.1.1: {} - strip-json-comments@3.1.1: {} + strip-json-comments@5.0.3: {} - supports-color@7.2.0: + super-regex@1.1.0: dependencies: - has-flag: 4.0.0 + function-timeout: 1.0.2 + make-asynchronous: 1.1.0 + time-span: 5.1.0 supports-color@8.1.1: dependencies: @@ -4089,7 +3077,7 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - tar@7.5.16: + tar@7.5.22: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -4097,10 +3085,14 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 + time-span@5.1.0: + dependencies: + convert-hrtime: 5.0.0 + tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 to-regex-range@5.0.1: dependencies: @@ -4112,18 +3104,11 @@ snapshots: dependencies: typescript: 6.0.3 - tsconfig-paths@3.15.0: - dependencies: - '@types/json5': 0.0.29 - json5: 1.0.2 - minimist: 1.2.8 - strip-bom: 3.0.0 - tslib@2.8.1: {} - tsx@4.22.4: + tsx@4.23.13: dependencies: - esbuild: 0.28.1 + esbuild: 0.28.2 optionalDependencies: fsevents: 2.3.3 @@ -4141,72 +3126,36 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@4.41.0: {} + type-is@2.1.0: dependencies: content-type: 2.0.0 - media-typer: 1.1.0 + media-typer: 1.1.1 mime-types: 3.0.2 - typed-array-buffer@1.0.3: + typescript-eslint@8.70.0(eslint@10.10.0)(typescript@6.0.3): dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-typed-array: 1.1.15 - - typed-array-byte-length@1.0.3: - dependencies: - call-bind: 1.0.9 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - - typed-array-byte-offset@1.0.4: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.9 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - reflect.getprototypeof: 1.0.10 - - typed-array-length@1.0.8: - dependencies: - call-bind: 1.0.9 - for-each: 0.3.5 - gopd: 1.2.0 - is-typed-array: 1.1.15 - possible-typed-array-names: 1.1.0 - reflect.getprototypeof: 1.0.10 - - typescript-eslint@8.61.1(eslint@10.5.0)(typescript@6.0.3): - dependencies: - '@typescript-eslint/eslint-plugin': 8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0)(typescript@6.0.3))(eslint@10.5.0)(typescript@6.0.3) - '@typescript-eslint/parser': 8.61.1(eslint@10.5.0)(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.61.1(typescript@6.0.3) - '@typescript-eslint/utils': 8.61.1(eslint@10.5.0)(typescript@6.0.3) - eslint: 10.5.0 + '@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(eslint@10.10.0)(typescript@6.0.3))(eslint@10.10.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.70.0(eslint@10.10.0)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.70.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.70.0(eslint@10.10.0)(typescript@6.0.3) + eslint: 10.10.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color typescript@6.0.3: {} - unbox-primitive@1.1.0: - dependencies: - call-bound: 1.0.4 - has-bigints: 1.1.0 - has-symbols: 1.1.0 - which-boxed-primitive: 1.1.1 + undici-types@8.9.0: {} - undici-types@8.3.0: {} + unicorn-magic@0.3.0: {} unpipe@1.0.0: {} - update-browserslist-db@1.2.3(browserslist@4.28.4): + update-browserslist-db@1.3.2(browserslist@4.28.9): dependencies: - browserslist: 4.28.4 + browserslist: 4.28.9 escalade: 3.2.0 picocolors: 1.1.1 @@ -4221,46 +3170,7 @@ snapshots: vary@1.1.2: {} - which-boxed-primitive@1.1.1: - dependencies: - is-bigint: 1.1.0 - is-boolean-object: 1.2.2 - is-number-object: 1.1.1 - is-string: 1.1.1 - is-symbol: 1.1.1 - - which-builtin-type@1.2.1: - dependencies: - call-bound: 1.0.4 - function.prototype.name: 1.2.0 - has-tostringtag: 1.0.2 - is-async-function: 2.1.1 - is-date-object: 1.1.0 - is-finalizationregistry: 1.1.1 - is-generator-function: 1.1.2 - is-regex: 1.2.1 - is-weakref: 1.1.1 - isarray: 2.0.5 - which-boxed-primitive: 1.1.1 - which-collection: 1.0.2 - which-typed-array: 1.1.22 - - which-collection@1.0.2: - dependencies: - is-map: 2.0.3 - is-set: 2.0.3 - is-weakmap: 2.0.2 - is-weakset: 2.0.4 - - which-typed-array@1.1.22: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.9 - call-bound: 1.0.4 - for-each: 0.3.5 - get-proto: 1.0.1 - gopd: 1.2.0 - has-tostringtag: 1.0.2 + web-worker@1.5.0: {} which@1.3.1: dependencies: @@ -4272,46 +3182,15 @@ snapshots: word-wrap@1.2.5: {} - workerpool@9.3.4: {} - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 5.1.2 - strip-ansi: 7.2.0 + workerpool@10.0.3: {} wrappy@1.0.2: {} xmlbuilder@15.1.1: {} - y18n@5.0.8: {} - yallist@5.0.0: {} - yargs-parser@21.1.1: {} - - yargs-unparser@2.0.0: - dependencies: - camelcase: 6.3.0 - decamelize: 4.0.0 - flat: 5.0.2 - is-plain-obj: 2.1.0 - - yargs@17.7.3: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 + yaml@2.9.0: {} yazl@3.3.1: dependencies: @@ -4319,8 +3198,10 @@ snapshots: yocto-queue@0.1.0: {} - zod-to-json-schema@3.25.2(zod@4.4.3): + yocto-queue@1.2.2: {} + + zod-to-json-schema@3.25.2(zod@4.6.2): dependencies: - zod: 4.4.3 + zod: 4.6.2 - zod@4.4.3: {} + zod@4.6.2: {} diff --git a/scripts/build-binaries.mjs b/scripts/build-binaries.mjs index 008a52f..f9dae78 100644 --- a/scripts/build-binaries.mjs +++ b/scripts/build-binaries.mjs @@ -23,6 +23,13 @@ import { fileURLToPath } from 'node:url'; const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); const outDir = join(repoRoot, 'dist-bin'); +// The compiled binary can't read package.json off disk (it isn't bundled), so +// stamp the version in at compile time via `bun --define`. getCliVersion() +// prefers this constant and falls back to reading package.json on the npm path. +const { version } = JSON.parse( + readFileSync(join(repoRoot, 'package.json'), 'utf8'), +); + // Each entry maps a Bun cross-compile target to the GitHub Release asset name. // outName excludes `.exe` because Bun appends it automatically for windows targets. const targets = [ @@ -41,7 +48,19 @@ for (const { target, outName, asset } of targets) { console.log(`→ ${target}`); execFileSync( 'bun', - ['build', '--compile', `--target=${target}`, 'src/index.ts', '--outfile', out], + [ + 'build', + '--compile', + `--target=${target}`, + // bun wants the space-separated `--define KEY=value` form (the colon form + // `--define:KEY=value` silently no-ops). JSON.stringify supplies the + // surrounding quotes bun expects for a string-literal replacement. + '--define', + `__DCD_CLI_VERSION__=${JSON.stringify(version)}`, + 'src/index.ts', + '--outfile', + out, + ], { cwd: repoRoot, stdio: 'inherit' }, ); const produced = join(outDir, asset); diff --git a/scripts/test-runner.mjs b/scripts/test-runner.mjs index 33adcba..9e873ca 100755 --- a/scripts/test-runner.mjs +++ b/scripts/test-runner.mjs @@ -8,13 +8,20 @@ import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// mock-api lives in the sibling dcd/ repo while the oclif→citty migration settles. -// Override with MOCK_API_DIR=/path/to/mock-api if it moves. -const mockApiDir = - process.env.MOCK_API_DIR ?? - path.resolve(__dirname, '../../dcd/mock-api'); +// The integration suite drives the built CLI against a Prism mock of the dcd API. +// That mock used to live in the sibling private dcd/ repo, which no longer ships +// one (dcd#1036), so there is no default location any more: point MOCK_API_DIR at +// a mock to run those specs. Without one — which includes CI, where this repo is +// public and deliberately does not reach into the private repo — the runner falls +// back to the unit suite, which is pure and needs no backend. +const mockApiDir = process.env.MOCK_API_DIR ?? null; const cliDir = path.resolve(__dirname, '..'); +const unitOnly = + process.argv.includes('--unit') || + mockApiDir === null || + !fs.existsSync(mockApiDir); + const MOCK_API_URL = 'http://localhost:3001/'; const READY_DEADLINE_MS = 30_000; const READY_POLL_INTERVAL_MS = 500; @@ -112,52 +119,69 @@ async function runTests() { }); }); - // Start mock API with authentication - console.log('Starting mock API with authentication...'); - mockApiProcess = spawn('npm', ['run', 'start:auth'], { - cwd: mockApiDir, - stdio: ['ignore', 'pipe', 'pipe'], - shell: true, - // Own process group on POSIX so killMockApi() can signal `npm run` - // *and* the server it spawns, not just the wrapper. - detached: process.platform !== 'win32', - }); + if (unitOnly) { + console.log( + 'Running the unit suite only — no mock API available. ' + + 'Set MOCK_API_DIR=/path/to/mock-api to include test/integration/*.' + ); + } else { + // Start mock API with authentication + console.log('Starting mock API with authentication...'); + mockApiProcess = spawn('npm', ['run', 'start:auth'], { + cwd: mockApiDir, + stdio: ['ignore', 'pipe', 'pipe'], + shell: true, + // Own process group on POSIX so killMockApi() can signal `npm run` + // *and* the server it spawns, not just the wrapper. + detached: process.platform !== 'win32', + }); - forwardOutput(mockApiProcess.stdout, (text) => process.stdout.write(text)); - forwardOutput(mockApiProcess.stderr, (text) => process.stderr.write(text)); + forwardOutput(mockApiProcess.stdout, (text) => process.stdout.write(text)); + forwardOutput(mockApiProcess.stderr, (text) => process.stderr.write(text)); - mockApiProcess.on('error', (error) => { - console.error('Mock API failed to start:', error); - if (!testsFinished) { - process.exit(1); - } - }); + mockApiProcess.on('error', (error) => { + console.error('Mock API failed to start:', error); + if (!testsFinished) { + process.exit(1); + } + }); - mockApiProcess.on('exit', (code, signal) => { - mockApiExited = true; - if (!testsFinished) { - console.error( - `Mock API exited before tests finished (code ${code}, signal ${signal})` - ); - process.exit(1); - } - }); + mockApiProcess.on('exit', (code, signal) => { + mockApiExited = true; + if (!testsFinished) { + console.error( + `Mock API exited before tests finished (code ${code}, signal ${signal})` + ); + process.exit(1); + } + }); - console.log('Waiting for mock API to be ready...'); - await waitForMockApi(); - console.log('Mock API is ready.'); + console.log('Waiting for mock API to be ready...'); + await waitForMockApi(); + console.log('Mock API is ready.'); + } // Run tests. Mocha + .mocharc.json handle TypeScript loading via `tsx` - // (see `node-option: ["import=tsx"]` there). Mocha 11 imports files as + // (see `node-option: ["import=tsx/esm"]` there). Mocha imports files as // ESM, so the `require: ts-node/register` hook doesn't get applied; tsx // registers an ESM loader that resolves TS relative imports correctly. + // It has to be `tsx/esm` rather than bare `tsx`: the latter also patches + // the CJS resolver, which rewrites an ESM dependency's `import` into a + // `require` and then cannot resolve an import-only `exports` map. Mocha's + // own lib/cli/*.cjs requires ESM-only find-up@8, whose unicorn-magic + // dependency exports nothing under the `require` condition — plain Node + // loads that fine via require(esm); tsx's CJS hook throws + // ERR_PACKAGE_PATH_NOT_EXPORTED before a single test runs. console.log('Running tests...'); - const testProcess = spawn('npx', [ + const mochaArgs = [ 'mocha', '--no-warnings', 'test/**/*.test.ts', '--timeout', '60000' - ], { + ]; + // Quoted so the shell hands mocha the literal glob instead of expanding it. + if (unitOnly) mochaArgs.push('--ignore', '"test/integration/**"'); + const testProcess = spawn('npx', mochaArgs, { cwd: cliDir, stdio: 'inherit', shell: true, diff --git a/src/commands/cloud.ts b/src/commands/cloud.ts index 3476c0d..3b8320d 100644 --- a/src/commands/cloud.ts +++ b/src/commands/cloud.ts @@ -1,5 +1,6 @@ /* eslint-disable complexity */ import { defineCommand } from 'citty'; +import { existsSync } from 'node:fs'; import * as path from 'node:path'; import { flags as allFlags } from '../constants.js'; @@ -19,6 +20,7 @@ import { import { MoropoService } from '../services/moropo.service.js'; import { ReportDownloadService } from '../services/report-download.service.js'; import { + deviceFromResultRow, ResultsPollingService, RunFailedError, } from '../services/results-polling.service.js'; @@ -30,12 +32,20 @@ import { EAndroidDevices, EiOSDevices, EiOSVersions, + isIosMatrixConfig, } from '../types/domain/device.types.js'; import { resolveAuth } from '../utils/auth.js'; -import { isCI } from '../utils/ci.js'; +import { + assertMatrixSupported, + matrixIsIos, + parseDeviceMatrix, +} from '../utils/device-matrix.js'; +import { isEncryptionEnabled } from '../utils/envelope.js'; +import { detectCiContext, isCI } from '../utils/ci.js'; import { CliError, coerceArray, + collectRepeatedFlag, getCliVersion, getUpgradeCommand, logger, @@ -46,6 +56,7 @@ import { CompatibilityData, fetchCompatibilityData, } from '../utils/compatibility.js'; +import { platformFromAppFile, renderNotices } from '../services/notices.service.js'; import { resolveApiUrl } from '../utils/config-store.js'; import { downloadExpoUrl, extractTarGz, findAppBundle, isUrl } from '../utils/expo.js'; import { @@ -75,6 +86,7 @@ const DOWNLOAD_OPTIONS = ['ALL', 'FAILED'] as const; const REPORT_OPTIONS = ['allure', 'html', 'html-detailed', 'junit'] as const; const ORIENTATION_OPTIONS = ['0', '90'] as const; const RUNNER_TYPE_OPTIONS = ['default', 'm4', 'm1', 'gpu1', 'cpu1'] as const; +const RENDER_ENGINE_OPTIONS = ['lavapipe', 'swiftshader'] as const; /** * Primary CLI command for executing tests on DeviceCloud. @@ -109,7 +121,7 @@ export const cloudCommand = defineCommand({ }, }, // eslint-disable-next-line complexity - async run({ args }) { + async run({ args, rawArgs }) { const cliVersion = getCliVersion(); const deviceValidationService = new DeviceValidationService(); const moropoService = new MoropoService(); @@ -119,12 +131,16 @@ export const cloudCommand = defineCommand({ const versionService = new VersionService(); const versionCheck = async () => { - const latestVersion = await versionService.checkLatestCliVersion(); - if (latestVersion && versionService.isOutdated(cliVersion, latestVersion)) { + const result = await versionService.checkLatestCliVersion(cliVersion); + if ( + result.ok && + result.version && + versionService.isOutdated(cliVersion, result.version) + ) { out(ui.warn(colors.bold('Update available'))); out( ui.branch([ - `A new version of the DeviceCloud CLI is available: ${colors.highlight(latestVersion)}`, + `A new version of the DeviceCloud CLI is available: ${colors.highlight(result.version)}`, `${colors.dim('Run:')} ${colors.info(getUpgradeCommand())}`, ]), ); @@ -165,18 +181,29 @@ export const cloudCommand = defineCommand({ 'download-artifacts', ); const dryRun = Boolean(args['dry-run']); - const env = coerceArray(args.env as string | string[] | undefined, false); + // Repeatable flags are collected from rawArgs: citty/parseArgs only keeps + // the last occurrence, so reading args.* directly drops earlier values. + const env = coerceArray( + collectRepeatedFlag(rawArgs, ['--env', '-e']), + false, + ); const excludeFlows = coerceArray( - args['exclude-flows'] as string | string[] | undefined, + collectRepeatedFlag(rawArgs, ['--exclude-flows']), ); const excludeTags = coerceArray( - args['exclude-tags'] as string | string[] | undefined, + collectRepeatedFlag(rawArgs, ['--exclude-tags']), ); let flows = args.flows as string | undefined; const googlePlay = Boolean(args['google-play']); const ignoreShaCheck = Boolean(args['ignore-sha-check']); + // Single opt-in for client-side envelope encryption of every sensitive + // artifact — the binary (#1138), the flow zip (#1151), and env vars + // (#1152). Flag wins; otherwise DCD_ENCRYPT / DCD_ENCRYPT_BINARIES. + const encrypt = isEncryptionEnabled( + args['encrypt'] ? true : undefined, + ); const includeTags = coerceArray( - args['include-tags'] as string | string[] | undefined, + collectRepeatedFlag(rawArgs, ['--include-tags']), ); const iOSDevice = validateEnum( args['ios-device'] as string | undefined, @@ -199,16 +226,18 @@ export const cloudCommand = defineCommand({ 'android-device', ); const androidNoSnapshot = Boolean(args['android-no-snapshot']); + // Repeatable device-matrix flags: one validated cell each, no cross-product. + const iosMatrixFlags = collectRepeatedFlag(rawArgs, ['--ios-device-matrix']); + const androidMatrixFlags = collectRepeatedFlag(rawArgs, ['--android-device-matrix']); + const deviceMatrix = parseDeviceMatrix(iosMatrixFlags, androidMatrixFlags); const json = Boolean(args.json); const jsonFileFlag = Boolean(args['json-file']); const jsonFileName = args['json-file-name'] as string | undefined; const maestroVersion = args['maestro-version'] as string | undefined; const metadata = coerceArray( - args.metadata as string | string[] | undefined, + collectRepeatedFlag(rawArgs, ['--metadata', '-m']), false, ); - const mitmHost = args.mitmHost as string | undefined; - const mitmPath = args.mitmPath as string | undefined; const moropoApiKey = args['moropo-v1-api-key'] as string | undefined; const name = args.name as string | undefined; const orientation = validateEnum( @@ -229,6 +258,11 @@ export const cloudCommand = defineCommand({ RUNNER_TYPE_OPTIONS, 'runner-type', ) ?? 'default'; + const renderEngine = validateEnum( + args['render-engine'] as string | undefined, + RENDER_ENGINE_OPTIONS, + 'render-engine', + ); const showCrosshairs = Boolean(args['show-crosshairs']); const maestroChromeOnboarding = Boolean(args['maestro-chrome-onboarding']); const disableAnimations = Boolean(args['disable-animations']); @@ -256,10 +290,6 @@ export const cloudCommand = defineCommand({ ); } - if (mitmPath && !mitmHost) { - throw new CliError('--mitmPath requires --mitmHost to be set'); - } - if (jsonFileName && !jsonFileFlag) { throw new CliError('--json-file-name requires --json-file'); } @@ -335,9 +365,14 @@ export const cloudCommand = defineCommand({ ); } + const ciContext = detectCiContext(); let compatibilityData: CompatibilityData; try { - compatibilityData = await fetchCompatibilityData(apiUrl, auth); + compatibilityData = await fetchCompatibilityData(apiUrl, auth, { + cliVersion, + ciProvider: ciContext.provider, + ciWrapperVersion: ciContext.wrapperVersion, + }); if (debug) { out('[DEBUG] Successfully fetched compatibility data from API'); } @@ -367,14 +402,6 @@ export const cloudCommand = defineCommand({ }, ); - const REMOVED_MAESTRO_VERSIONS = ['1.39.1', '1.39.2', '1.39.7', '2.0.3', '2.4.0']; - if (REMOVED_MAESTRO_VERSIONS.includes(resolvedMaestroVersion)) { - throw new CliError( - `Maestro version ${resolvedMaestroVersion} is no longer supported. ` + - `Please upgrade to a newer version. See: https://docs.devicecloud.dev/configuration/maestro-versions`, - ); - } - if (retry !== undefined && retry > 2) { out( ui.warn( @@ -395,7 +422,7 @@ export const cloudCommand = defineCommand({ if (runnerType === 'm1') { out( ui.info( - 'runnerType m1 is experimental and currently supports Android (Pixel 7, API Level 34) only.', + 'runnerType m1 is experimental and currently supports Android only (all devices, API level 34-36).', ), ); } @@ -403,7 +430,7 @@ export const cloudCommand = defineCommand({ if (runnerType === 'gpu1') { out( ui.info( - 'runnerType gpu1 is Android-only (all devices, API Level 34 or 35), available to all users.', + 'runnerType gpu1 is Android-only (all devices, API level 34+), available to all users.', ), ); } @@ -431,6 +458,14 @@ export const cloudCommand = defineCommand({ out(`[DEBUG] Found .app bundle at: ${finalAppFile}`); } } + + // Validate the resolved local app file early — dry-run otherwise skips + // the upload that would surface a missing file, so a typo'd path would + // pass a dry-run and only fail on the real run. (URL/.tar.gz inputs are + // already resolved to existing temp paths by this point.) + if (!existsSync(finalAppFile)) { + throw new CliError(`App file does not exist: ${finalAppFile}`); + } } if (debug) { @@ -461,6 +496,37 @@ export const cloudCommand = defineCommand({ logger: (m: string) => out(m), }); + // Render DB-driven notices (deprecation/warn/info/marketing) the API + // returned with the compatibility data. Replaces the previously hardcoded + // iOS-16 deprecation warning — that is now a seeded notice gated on the + // selected iOS version below. Honours --json via out/warnOut. + const visibleNotices = renderNotices( + compatibilityData.notices, + { + platform: platformFromAppFile(finalAppFile), + ios_version: iOSVersion, + ios_device: iOSDevice, + android_api_level: androidApiLevel, + android_device: androidDevice, + // Resolved (what the run will use) and requested (undefined when the + // customer relied on the default), so a notice can target either. + maestro_version: resolvedMaestroVersion, + requested_maestro_version: maestroVersion, + cli_version: cliVersion, + ci_provider: ciContext.provider, + ci_wrapper_version: ciContext.wrapperVersion, + }, + { out }, + ); + // --json suppresses the rendered lines, so the payload carries them instead. + const noticesForJson = visibleNotices.map((n) => ({ + slug: n.slug, + level: n.level, + title: n.title, + body: n.body, + learnMoreUrl: n.learnMoreUrl, + })); + deviceValidationService.validateAndroidDevice( androidApiLevel, androidDevice, @@ -469,6 +535,28 @@ export const cloudCommand = defineCommand({ { debug, logger: (m: string) => out(m) }, ); + // Validate every device-matrix cell up front — each on its own, against + // the same compatibility matrix — so an unsupported cell fails fast, + // naming it, before anything is uploaded. + for (const cfg of deviceMatrix) { + if (isIosMatrixConfig(cfg)) { + deviceValidationService.validateiOSDevice( + cfg.iOSVersion, + cfg.iOSDevice, + compatibilityData, + { debug, logger: (m: string) => out(m) }, + ); + } else { + deviceValidationService.validateAndroidDevice( + cfg.androidApiLevel, + cfg.androidDevice, + cfg.googlePlay ?? googlePlay, + compatibilityData, + { debug, logger: (m: string) => out(m) }, + ); + } + } + if (maestroChromeOnboarding && !androidApiLevel && !androidDevice) { warnOut( 'The --maestro-chrome-onboarding flag only applies to Android tests and will be ignored for iOS tests.', @@ -502,6 +590,9 @@ export const cloudCommand = defineCommand({ excludeFlows, configFile, debug, + // Not warnOut: config problems are worth surfacing even under --json, + // and logger.warn writes to stderr so stdout stays parseable. + warn: (m: string) => logger.warn(m), }); if (debug) { @@ -598,14 +689,29 @@ export const cloudCommand = defineCommand({ ]); // Only log canonical flag keys (skip citty-populated alias duplicates like apiURL/apiUrl). const canonicalFlagKeys = new Set(Object.keys(allFlags)); + // Repeatable flags are recovered from rawArgs (args.* only holds the last + // occurrence), so echo the fully-collected values rather than args.*. + const repeatableDisplay: Record = { + env, + metadata, + 'include-tags': includeTags, + 'exclude-tags': excludeTags, + 'exclude-flows': excludeFlows, + 'ios-device-matrix': iosMatrixFlags, + 'android-device-matrix': androidMatrixFlags, + }; for (const [k, v] of Object.entries(args)) { if (!canonicalFlagKeys.has(k)) continue; + if (k in repeatableDisplay) continue; if (v === undefined || v === null || v === false) continue; const asString = String(v); if (asString.length > 0 && !sensitiveFlags.has(k)) { flagLogs.push(`${k}: ${asString}`); } } + for (const [k, values] of Object.entries(repeatableDisplay)) { + if (values.length > 0) flagLogs.push(`${k}: ${values.join(', ')}`); + } const overridesEntries = Object.entries(flowOverrides); const hasOverrides = overridesEntries.some( @@ -681,6 +787,7 @@ export const cloudCommand = defineCommand({ auth, apiUrl, debug, + encrypt, filePath: finalAppFile, ignoreShaCheck, log: !json, @@ -710,12 +817,15 @@ export const cloudCommand = defineCommand({ androidApiLevel, androidDevice, androidNoSnapshot, + apiUrl, appBinaryId: finalBinaryId, cliVersion, commonRoot, continueOnFailure, debug, deviceLocale, + deviceMatrix, + encrypt, env, executionPlan, flowFile, @@ -725,8 +835,6 @@ export const cloudCommand = defineCommand({ logger: (m: string) => out(m), maestroVersion: resolvedMaestroVersion, metadata: mergedMetadata, - mitmHost, - mitmPath, name, orientation, raw: [], @@ -736,8 +844,56 @@ export const cloudCommand = defineCommand({ showCrosshairs, maestroChromeOnboarding, disableAnimations, + renderEngine, }); + // Device-matrix cost preview: the server prices the exact fan-out (quote + // == charge) so the user sees the cell count and estimated cost before the + // flow zip is uploaded. An unsupported cell fails fast here. Skipped when + // there is no matrix, and tolerant of older APIs that lack the endpoint. + if (deviceMatrix.length > 0) { + const estimate = await ApiGateway.estimateMatrix(apiUrl, auth, fields); + // A null estimate means the API predates the matrix (404/405). It would + // silently strip deviceMatrix and run one device — refuse rather than + // hand back a green single-device run the user reads as a matrix. + assertMatrixSupported(deviceMatrix, estimate); + if (estimate) { + const osPrefix = matrixIsIos(deviceMatrix) ? 'iOS' : 'API'; + const rows = ui.fields([ + ['cells', colors.highlight(String(estimate.cellCount))], + ['est. cost', colors.highlight(`$${estimate.totalCost.toFixed(2)}`)], + ]); + for (const col of estimate.columns) { + const label = [ + col.deviceName, + col.osVersion && `${osPrefix} ${col.osVersion}`, + col.googlePlay && 'Play', + ] + .filter(Boolean) + .join(' · '); + rows.push( + ...ui.fields([ + [ + label, + colors.dim( + `${col.flowCount} flow${col.flowCount === 1 ? '' : 's'} · $${col.cost.toFixed(2)}`, + ), + ], + ]), + ); + } + if (estimate.excludedFlows.length > 0) { + rows.push( + colors.dim( + `${estimate.excludedFlows.length} flow${estimate.excludedFlows.length === 1 ? '' : 's'} target their own device (excluded from the matrix)`, + ), + ); + } + out(ui.section('Device matrix')); + out(ui.branch(rows)); + } + } + // New path: upload the zip directly to storage, then submit a JSON test // referencing it. Older API deployments lack these endpoints — a real API // 404s (route undefined), some proxies 405 (path/method not allowed); in @@ -819,6 +975,7 @@ export const cloudCommand = defineCommand({ consoleUrl: url, status: 'PENDING', tests: results.map((r) => ({ + device: deviceFromResultRow(r), fileName: r.test_file_name, flowName: testMetadataMap[r.test_file_name]?.flowName || @@ -828,6 +985,7 @@ export const cloudCommand = defineCommand({ tags: testMetadataMap[r.test_file_name]?.tags || [], })), uploadId: results[0].test_upload_id, + notices: noticesForJson, }; if (jsonFileFlag) { @@ -952,7 +1110,7 @@ export const cloudCommand = defineCommand({ }); } - const jsonOutput = pollingResult; + const jsonOutput = { ...pollingResult, notices: noticesForJson }; if (jsonFileFlag) { const jsonFilePath = jsonFileName || `${results[0].test_upload_id}_dcd.json`; writeJSONFile(jsonFilePath, jsonOutput, { diff --git a/src/commands/list.ts b/src/commands/list.ts index 669acc0..c057b38 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -1,6 +1,7 @@ import { defineCommand } from 'citty'; import { apiFlags } from '../config/flags/api.flags.js'; +import { resolveFrontendUrl } from '../config/environments.js'; import { ApiGateway } from '../gateways/api-gateway.js'; import { resolveAuth } from '../utils/auth.js'; import { CliError, logger, parseIntFlag } from '../utils/cli.js'; @@ -41,8 +42,12 @@ function detectShellExpansion(name: string): void { } } -function displayResults(response: ListResponse): void { +function displayResults(response: ListResponse, apiUrl: string): void { const { uploads, total, limit, offset } = response; + // Build console links from the env the CLI is pointed at, rather than the + // API-supplied consoleUrl (which is hardcoded to prod) — so dev/staging users + // get links that actually resolve. + const frontendUrl = resolveFrontendUrl(apiUrl); if (uploads.length === 0) { logger.log(ui.info('No uploads found matching your criteria.')); @@ -70,7 +75,7 @@ function displayResults(response: ListResponse): void { ...ui.fields([ ['id', formatId(upload.id)], ['created', formattedDate], - ['console', formatUrl(upload.consoleUrl)], + ['console', formatUrl(`${frontendUrl}/results?upload=${upload.id}`)], ]), ]), ); @@ -169,7 +174,7 @@ export const listCommand = defineCommand({ return; } - displayResults(response); + displayResults(response, apiUrl); } catch (error) { throw new CliError( `Failed to list uploads: ${(error as Error).message}`, diff --git a/src/commands/live.ts b/src/commands/live.ts index 2e2e017..23a8e57 100644 --- a/src/commands/live.ts +++ b/src/commands/live.ts @@ -139,7 +139,7 @@ const startSub = defineCommand({ 'android-device': { type: 'string', description: - '[Android] Device profile to match a cloud run (options: pixel-6, pixel-6-pro, pixel-7, pixel-7-pro)', + '[Android] Device profile to match a cloud run (options: pixel-6, pixel-6-pro, pixel-7, pixel-7-pro, pixel-8, pixel-10, pixel-10-pro, pixel-10-pro-xl, pixel-11)', }, 'android-api-level': { type: 'string', @@ -171,6 +171,14 @@ const startSub = defineCommand({ throw new CliError('--android-device and --android-api-level must be provided together.'); } + logger.log(ui.warn(colors.bold('Live is in beta'))); + logger.log( + ui.branch([ + 'Live device sessions are a beta feature, billed at $0.03/min.', + `${colors.dim('Not enrolled?')} Contact support to request access.`, + ]), + ); + logger.log(ui.running(`Starting ${platform} live session…`)); const session = await ApiGateway.startLiveSession(apiUrl, auth, { diff --git a/src/commands/login.ts b/src/commands/login.ts index b451813..ba6e9dc 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -10,8 +10,11 @@ * 2. CLI opens /cli-login?state=...&code_challenge=... in the browser. * 3. User signs in (OTP or SSO) and explicitly authorizes the handoff on * the frontend. - * 4. Frontend POSTs {state, code_challenge, session...} to the dcd api at - * POST /cli-login/handoff. The api stores a short-TTL row keyed by state. + * 4. Frontend POSTs {state, code_challenge, access_token} to the dcd api at + * POST /cli-login/handoff — the access token is proof of identity only. + * The api verifies it, mints a *dedicated* Supabase session for the CLI + * (its own refresh-token family, so browser token rotation can't + * invalidate it), and stores it in a short-TTL row keyed by state. * 5. Meanwhile, the CLI polls POST /cli-login/claim with {state, code_verifier}. * Once the api has the row, it verifies sha256(verifier) === challenge, * deletes the row, and returns the session. @@ -81,20 +84,32 @@ export const loginCommand = defineCommand({ // If there's an existing stored session, make the user confirm before we // overwrite it. Silent clobber is fine for power users but surprising if - // someone runs `dcd login` by mistake while already authenticated. + // someone runs `dcd login` by mistake while already authenticated. An + // already-expired session gets no confirm: the user is here because a + // command told them to re-login, and "Already logged in… keep session?" + // would dead-end them on a session that no longer works. const existing = readConfig(); if (existing?.session) { - const currentOrg = existing.current_org_name ?? existing.current_org_id; - const ok = await p.confirm({ - message: - `Already logged in as ${existing.session.user_email}` + - (currentOrg ? ` (org ${currentOrg})` : '') + - `. Sign out and log in again?`, - initialValue: false, - }); - if (p.isCancel(ok) || !ok) { - logger.log(ui.info('Keeping existing session.')); - return; + const now = Math.floor(Date.now() / 1000); + if (existing.session.expires_at <= now) { + logger.log( + ui.info( + `Your session for ${existing.session.user_email} has expired — signing in again.`, + ), + ); + } else { + const currentOrg = existing.current_org_name ?? existing.current_org_id; + const ok = await p.confirm({ + message: + `Already logged in as ${existing.session.user_email}` + + (currentOrg ? ` (org ${currentOrg})` : '') + + `. Sign out and log in again?`, + initialValue: true, + }); + if (p.isCancel(ok) || !ok) { + logger.log(ui.info('Keeping existing session.')); + return; + } } } diff --git a/src/commands/status.ts b/src/commands/status.ts index 6deb425..9724897 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -1,6 +1,7 @@ import { defineCommand } from 'citty'; import { apiFlags } from '../config/flags/api.flags.js'; +import { resolveFrontendUrl } from '../config/environments.js'; import { ApiGateway } from '../gateways/api-gateway.js'; import { formatDurationSeconds } from '../methods.js'; import { resolveAuth } from '../utils/auth.js'; @@ -248,8 +249,14 @@ async function statusMain({ if (status.createdAt) { fields.push(['created', formatDateTime(status.createdAt)]); } - if (status.consoleUrl) { - fields.push(['console', formatUrl(status.consoleUrl)]); + // Prefer a console link built from the env the CLI is pointed at (the + // API-supplied consoleUrl is hardcoded to prod, so it misdirects + // dev/staging users); fall back to the API value if we have no uploadId. + const consoleUrl = status.uploadId + ? `${resolveFrontendUrl(apiUrl)}/results?upload=${status.uploadId}` + : status.consoleUrl; + if (consoleUrl) { + fields.push(['console', formatUrl(consoleUrl)]); } logger.log(ui.section('Upload Status')); diff --git a/src/commands/upgrade.ts b/src/commands/upgrade.ts index 0aa2232..719ab60 100644 --- a/src/commands/upgrade.ts +++ b/src/commands/upgrade.ts @@ -57,14 +57,24 @@ export const upgradeCommand = defineCommand({ const current = getCliVersion(); const versionService = new VersionService(); - const latest = await versionService.checkLatestCliVersion(); + const result = await versionService.checkLatestCliVersion(current); - if (!latest) { + if (!result.ok) { throw new CliError( - 'Could not reach the update manifest. Check your network connection and try again.', + `Could not reach the update manifest (${result.error}). Check your network connection and try again.`, ); } + // Reachable, but nothing published on this channel yet (e.g. a stable + // install while only betas exist). Not an error — just nothing to do. + if (result.version === null) { + logger.log( + ui.info(`No newer release available on the ${result.channel} channel.`), + ); + return; + } + + const latest = result.version; if (!versionService.isOutdated(current, latest)) { logger.log( ui.success(`Already on the latest version (${colors.highlight(current)})`), @@ -85,8 +95,11 @@ export const upgradeCommand = defineCommand({ if (process.platform === 'win32') { // Windows can't replace a running .exe; defer to a re-run of the installer. const base = process.env.DCD_DOWNLOAD_BASE ?? DEFAULT_DOWNLOAD_BASE; + // Prerelease users need the beta channel opt-in or the installer resolves + // the (currently non-existent) stable release. + const betaHint = result.channel === 'beta' ? '$env:DCD_BETA=1; ' : ''; throw new CliError( - `Automatic upgrade on Windows is not yet supported. Re-run the installer:\n irm ${base}/install.ps1 | iex`, + `Automatic upgrade on Windows is not yet supported. Re-run the installer:\n ${betaHint}irm ${base}/install.ps1 | iex`, ); } diff --git a/src/commands/upload.ts b/src/commands/upload.ts index cdb01eb..f56cb83 100644 --- a/src/commands/upload.ts +++ b/src/commands/upload.ts @@ -21,6 +21,7 @@ export const uploadCommand = defineCommand({ ...apiFlags, 'app-url': binaryFlags['app-url'], 'ignore-sha-check': binaryFlags['ignore-sha-check'], + encrypt: binaryFlags.encrypt, debug: outputFlags.debug, json: outputFlags.json, appFile: { @@ -42,6 +43,7 @@ export const uploadCommand = defineCommand({ const apiUrl = resolveApiUrl(args['api-url'] as string | undefined); const appUrl = args['app-url'] as string | undefined; const ignoreShaCheck = Boolean(args['ignore-sha-check']); + const encryptBinary = Boolean(args['encrypt']); const debug = Boolean(args.debug); const positional = args.appFile as string | undefined; @@ -88,6 +90,7 @@ export const uploadCommand = defineCommand({ auth, apiUrl, debug, + encrypt: encryptBinary, filePath: resolvedFile, ignoreShaCheck, log: !json, diff --git a/src/config/environments.ts b/src/config/environments.ts index 9dd7011..ccf06e5 100644 --- a/src/config/environments.ts +++ b/src/config/environments.ts @@ -19,6 +19,20 @@ export interface DcdEnvironment { projectRef: string; anonKey: string; }; + /** + * Pinned KEK public key for client-side binary envelope encryption (dcd#1138). + * `key` is base64 of the raw 32-byte X25519 public key; `version` selects which + * KEK the platform API unwraps with. **Public — safe to embed** (like the anon + * key above): it can only *encrypt*; the private half lives solely on the API. + * + * Left `null` until the KEK is generated and provisioned. Generate a keypair + * with, e.g.: + * node -e 'const c=require("crypto");const{publicKey,privateKey}=c.generateKeyPairSync("x25519");const pub=publicKey.export({type:"spki",format:"der"}).subarray(12);const priv=privateKey.export({type:"pkcs8",format:"der"}).subarray(16);console.log("public :",pub.toString("base64"));console.log("private:",priv.toString("base64"))' + * Pin `public` here; set `private` as `BINARY_KEK_PRIVATE_KEYS={"":""}` + * on the API service and keep one offline escrow copy. Until then, encryption + * can be exercised via the `DCD_BINARY_KEK_PUBLIC` env override (see envelope.ts). + */ + kekPublicKey: { version: number; key: string } | null; } export const ENVIRONMENTS: Record = { @@ -31,6 +45,7 @@ export const ENVIRONMENTS: Record = { anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBneWRucGhiaW1ldGluc2dma2JvIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDc1OTQzNDYsImV4cCI6MjAyMzE3MDM0Nn0.hAYOMFxxwX1exkQkY9xyQJGC_GhGnyogkj2N-kBkMI8', }, + kekPublicKey: { version: 1, key: 'wtfyWEwK7nJzwI4PD+9RAW8jxIR1u8kMQq2IhsrVnH4=' }, }, dev: { apiUrl: 'https://api.dev.devicecloud.dev', @@ -41,6 +56,7 @@ export const ENVIRONMENTS: Record = { anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxibXNvd2VodGp3bnFsdXJwZW1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDkyMTg0ODcsImV4cCI6MjAyNDc5NDQ4N30.zeLTMAuZ_WwYvGdeP0kdvL_Zrs-RQee5APPyxmWq7qQ', }, + kekPublicKey: { version: 1, key: 'RgcToF/OJpcQI9koYvSvtj/WLaebfcN4v5GJoqtr/00=' }, }, }; diff --git a/src/config/flags/binary.flags.ts b/src/config/flags/binary.flags.ts index c855790..32dde91 100644 --- a/src/config/flags/binary.flags.ts +++ b/src/config/flags/binary.flags.ts @@ -24,4 +24,9 @@ export const binaryFlags = { description: 'Ignore the sha hash check and upload the binary regardless of whether it already exists (not recommended)', }, + encrypt: { + type: 'boolean', + description: + 'Encrypt the app binary, flow zip, and env vars before upload (client-side envelope encryption; each gets its own key). The binary is still deduplicated across runs, so an unchanged app is not re-uploaded. Can also be enabled with DCD_ENCRYPT=1.', + }, } as const satisfies ArgsDef; diff --git a/src/config/flags/device.flags.ts b/src/config/flags/device.flags.ts index 37135d4..4b9e6d9 100644 --- a/src/config/flags/device.flags.ts +++ b/src/config/flags/device.flags.ts @@ -24,6 +24,11 @@ export const deviceFlags = { type: 'string', description: `[Android only] Android device to run your flow against (options: ${androidDevices})`, }, + 'render-engine': { + type: 'string', + description: + '[Android only] Software renderer the emulator boots with (options: lavapipe, swiftshader). Defaults to lavapipe; pick swiftshader if your app fails to render or the device drops out mid-run. Apps shipping the Flutter engine are detected and switched automatically, so this is only needed to override that.', + }, 'device-locale': { type: 'string', description: @@ -42,6 +47,14 @@ export const deviceFlags = { type: 'string', description: `[iOS only] iOS version to run your flow against (options: ${iosVersions})`, }, + 'ios-device-matrix': { + type: 'string', + description: `[iOS only] Device-matrix cell as :, e.g. iphone-16:18. Repeatable — every flow runs once per cell (no cross-product). Cannot be combined with --android-device-matrix.`, + }, + 'android-device-matrix': { + type: 'string', + description: `[Android only] Device-matrix cell as : (append :play for Google Play), e.g. pixel-7:34 or pixel-7:34:play. Repeatable — every flow runs once per cell (no cross-product). Cannot be combined with --ios-device-matrix.`, + }, orientation: { type: 'string', description: diff --git a/src/config/flags/environment.flags.ts b/src/config/flags/environment.flags.ts index e15c3b3..f20ba7d 100644 --- a/src/config/flags/environment.flags.ts +++ b/src/config/flags/environment.flags.ts @@ -17,16 +17,6 @@ export const environmentFlags = { description: 'Arbitrary key-value metadata to include with your test run (format: key=value, may be repeated)', }, - mitmHost: { - type: 'string', - description: - 'used for mitmproxy support, enterprise only, contact support if interested', - }, - mitmPath: { - type: 'string', - description: - 'used for mitmproxy support, enterprise only, contact support if interested', - }, 'moropo-v1-api-key': { type: 'string', description: 'API key for Moropo v1 integration', diff --git a/src/gateways/api-gateway.ts b/src/gateways/api-gateway.ts index 7ba37b4..2c3dfd9 100644 --- a/src/gateways/api-gateway.ts +++ b/src/gateways/api-gateway.ts @@ -179,14 +179,110 @@ export const ApiGateway = { ); }, + /** + * Prefer server-assembled bundle delivery. The API returns a signed manifest + * plus a URL to a delivery service that streams the ZIP straight from + * storage, so large downloads don't flow through the API itself. The client + * just relays the signed `{ manifest, sig }` to that URL — no auth header, + * because the manifest is already signed. + * + * Returns true once the bundle has been streamed to disk. Returns false when + * the bundle path is unavailable or anything about it doesn't pan out — a + * `501` (deployment doesn't offer it), a non-OK manifest response, a body + * that isn't a manifest, or a delivery-service error — so the caller can fall + * back to the inline download endpoint. Definitive errors (e.g. not found) + * surface through that inline path instead. + */ + async tryBundleDownload( + baseUrl: string, + auth: AuthContext, + manifestEndpoint: string, + destinationPath: string, + operation: string, + ): Promise { + let manifestRes: Response; + try { + manifestRes = await fetch(`${baseUrl}${manifestEndpoint}`, { + headers: { ...auth.headers }, + method: 'GET', + }); + } catch { + return false; + } + + // 501 => this deployment has no bundle delivery; any other non-OK => let + // the inline path re-request and surface the real error. + if (!manifestRes.ok) { + return false; + } + + let bundle: { + bundleUrl?: string; + manifest?: string; + sig?: string; + }; + try { + bundle = (await manifestRes.json()) as typeof bundle; + } catch { + return false; + } + if (!bundle?.bundleUrl || !bundle?.manifest || !bundle?.sig) { + return false; + } + + let zipRes: Response; + try { + zipRes = await fetch(bundle.bundleUrl, { + body: JSON.stringify({ manifest: bundle.manifest, sig: bundle.sig }), + // No auth header: the manifest is signed and is the access token. + headers: { 'content-type': 'application/json' }, + method: 'POST', + }); + } catch { + return false; + } + if (!zipRes.ok) { + return false; + } + + // A mid-stream failure (dropped/truncated connection) or a null body on an + // otherwise-OK response throws here — fall back to the inline path rather + // than let it escape past the caller's fallback. The inline path re-opens + // the destination with flags: 'w', truncating any partial file left behind. + try { + await this.streamResponseToFile(zipRes, destinationPath, operation); + } catch { + return false; + } + return true; + }, + + /** + * Look for an already-uploaded binary to skip re-uploading. + * + * Two lookup keys, because encryption changes what is stable (dcd#1168). + * Unencrypted uploads pass `sha` (the hash of exactly what gets stored). + * Encrypted uploads pass `shaPlain` plus `encrypted: true`: their ciphertext is + * freshly keyed on every upload, so its hash never matches, and the plaintext + * hash is the only stable key. `sha` is deliberately not sent in that case — + * see the caller in methods.ts for why sending it would be unsafe. + * + * `encrypted` in the response reports whether the *matched* binary is stored + * encrypted, so the caller can verify the invariant it asked for instead of + * trusting the server to have applied the right predicate. + */ async checkForExistingUpload( baseUrl: string, auth: AuthContext, - sha: string, + lookup: { encrypted?: boolean; sha?: string; shaPlain?: string } | string, ) { + // Historically this took a bare sha string; keep that shape working. + const body = + typeof lookup === 'string' ? { sha: lookup } : { ...lookup }; + try { const res = await fetch(`${baseUrl}/uploads/checkForExistingUpload`, { - body: JSON.stringify({ sha }), + body: JSON.stringify(body), headers: { 'content-type': 'application/json', ...auth.headers, @@ -199,7 +295,9 @@ export const ApiGateway = { } return await parseJsonResponse< - paths['/uploads/checkForExistingUpload']['post']['responses']['201']['content']['application/json'] + paths['/uploads/checkForExistingUpload']['post']['responses']['201']['content']['application/json'] & { + encrypted?: boolean; + } >(res, 'Failed to check for existing upload'); } catch (error) { // Handle network-level errors (DNS, connection refused, timeout, etc.) @@ -219,6 +317,19 @@ export const ApiGateway = { results: 'ALL' | 'FAILED', artifactsPath: string = './artifacts.zip', ) { + // Prefer bundle delivery; fall back to the inline download below. + if ( + await this.tryBundleDownload( + baseUrl, + auth, + `/results/${uploadId}/artifacts-bundle?results=${results}`, + artifactsPath, + 'Failed to download artifacts', + ) + ) { + return; + } + try { const res = await fetch(`${baseUrl}/results/${uploadId}/download`, { body: JSON.stringify({ results }), @@ -251,9 +362,15 @@ export const ApiGateway = { metadata: TAppMetadata; path: string; sha?: string; + /** + * Hash of the PLAINTEXT, sent only for encrypted uploads (dcd#1168). Stored + * as `binaries.sha_plain` so later encrypted uploads of the same input can + * dedup; `sha` remains the ciphertext hash. + */ + shaPlain?: string; supabaseSuccess: boolean; }) { - const { baseUrl, auth, id, metadata, path, sha, supabaseSuccess, backblazeSuccess, bytes } = config; + const { baseUrl, auth, id, metadata, path, sha, shaPlain, supabaseSuccess, backblazeSuccess, bytes } = config; try { const res = await fetch(`${baseUrl}/uploads/finaliseUpload`, { body: JSON.stringify({ @@ -263,6 +380,7 @@ export const ApiGateway = { metadata, path, // This is tempPath for TUS uploads ...(sha ? { sha } : {}), + ...(shaPlain ? { shaPlain } : {}), supabaseSuccess, }), headers: { @@ -588,6 +706,52 @@ export const ApiGateway = { } }, + /** + * Dry-run cost + cell-count estimate for a (possibly device-matrix) + * submission. Runs the same resolve → validate → fan-out → price core as the + * submit path server-side, without persisting. The preview is best-effort: + * an API that predates this endpoint 404s (route undefined) or 405s (route + * matched a sibling path, no POST) — in either case return null so the caller + * proceeds without a preview. A 400 (an invalid config) still surfaces as a + * normal API error so the CLI can fail fast before uploading the flow zip. + */ + async estimateMatrix(baseUrl: string, auth: AuthContext, body: Record) { + try { + const res = await fetch(`${baseUrl}/uploads/estimateMatrix`, { + body: JSON.stringify(body), + headers: { + 'content-type': 'application/json', + ...auth.headers, + }, + method: 'POST', + }); + if (res.status === 404 || res.status === 405) { + return null; + } + if (!res.ok) { + await this.handleApiError(res, 'Failed to estimate device matrix'); + } + return await parseJsonResponse<{ + cellCount: number; + totalCost: number; + excludedFlows: string[]; + columns: Array<{ + deviceName: string; + osVersion: string; + googlePlay: boolean; + flowCount: number; + cost: number; + }>; + }>(res, 'Failed to estimate device matrix'); + } catch (error) { + if (error instanceof TypeError && error.message === 'fetch failed') { + throw this.enhanceFetchError(error, `${baseUrl}/uploads/estimateMatrix`); + } + + throw error; + } + }, + /** * Generic report download method that handles both junit and allure reports @@ -631,6 +795,22 @@ export const ApiGateway = { const finalReportPath = reportPath || path.resolve(process.cwd(), defaultFilename); const url = `${baseUrl}${endpoint}`; + // The HTML report is a ZIP bundle; prefer bundle delivery when available. + // (junit is a single small file and allure has its own endpoint — both stay + // on the inline path.) + if ( + reportType === 'html' && + (await this.tryBundleDownload( + baseUrl, + auth, + `/results/${uploadId}/report-bundle`, + finalReportPath, + errorPrefix, + )) + ) { + return; + } + try { // Make the download request const res = await fetch(url, { diff --git a/src/gateways/cli-auth-gateway.ts b/src/gateways/cli-auth-gateway.ts index 247609a..35b10cf 100644 --- a/src/gateways/cli-auth-gateway.ts +++ b/src/gateways/cli-auth-gateway.ts @@ -3,12 +3,28 @@ * and sign-out (best-effort revocation on the Supabase side). * * Does not talk to the dcd API — the dcd-side session exchange lives in the - * login command's loopback flow, where the frontend POSTs ciphertext back. + * login command's PKCE rendezvous flow (see src/commands/login.ts). */ -import { createClient } from '@supabase/supabase-js'; +import { createClient, isAuthApiError } from '@supabase/supabase-js'; import type { StoredSession } from '../utils/config-store.js'; +/** + * Thrown when a session refresh fails. `definitive` distinguishes "Supabase + * rejected this refresh token" (revoked, already used, malformed — re-login + * is the only fix) from transient failures (network, GoTrue 5xx) where the + * stored session may still be good on the next attempt. + */ +export class SessionRefreshError extends Error { + constructor( + message: string, + readonly definitive: boolean, + ) { + super(message); + this.name = 'SessionRefreshError'; + } +} + export interface RefreshedSession { access_token: string; refresh_token: string; @@ -35,9 +51,17 @@ export const CliAuthGateway = { refresh_token: session.refresh_token, }); if (error || !data.session || !data.user) { - throw new Error( + // 4xx from GoTrue means the token itself was rejected; anything else + // (fetch failure, 5xx) could succeed on retry with the same token. + const definitive = + error != null && + isAuthApiError(error) && + error.status >= 400 && + error.status < 500; + throw new SessionRefreshError( `Failed to refresh session: ${error?.message ?? 'no session returned'}. ` + `Run \`dcd login\` again.`, + definitive, ); } const s = data.session; diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 0000000..bfa4354 --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,10 @@ +/** + * Build-time constants injected by `bun --define` (see + * scripts/build-binaries.mjs). Declared as an ambient global — this file must + * stay free of top-level import/export or it stops being a global declaration. + * + * `__DCD_CLI_VERSION__` holds the package version stamped into the compiled + * standalone binary. On the npm/tsx path it is never defined; `getCliVersion()` + * guards every read with `typeof`. + */ +declare const __DCD_CLI_VERSION__: string | undefined; diff --git a/src/mcp/tools/run-cloud-test.ts b/src/mcp/tools/run-cloud-test.ts index 1ba76f3..5c94dca 100644 --- a/src/mcp/tools/run-cloud-test.ts +++ b/src/mcp/tools/run-cloud-test.ts @@ -12,6 +12,7 @@ import { VersionService } from '../../services/version.service.js'; import { uploadBinary, uploadFlowZip, verifyAppZip } from '../../methods.js'; import { getCliVersion } from '../../utils/cli.js'; import { fetchCompatibilityData } from '../../utils/compatibility.js'; +import { isEncryptionEnabled } from '../../utils/envelope.js'; import { getConsoleUrl } from '../../utils/styling.js'; import { getContext, logStderr } from '../context.js'; import { jsonResult, runTool } from '../helpers.js'; @@ -33,7 +34,7 @@ const sleep = (ms: number) => * `waitTimeoutSeconds`. * * Mirrors the `dcd cloud` command's submission path but headless: no Expo URL - * download, mitm, GitHub metadata, or JSON-file output. Use the CLI for those. + * download, GitHub metadata, or JSON-file output. Use the CLI for those. */ export function registerRunCloudTest(server: McpServer): void { server.registerTool( @@ -150,6 +151,8 @@ export function registerRunCloudTest(server: McpServer): void { excludeTags: args.excludeTags ?? [], excludeFlows: args.excludeFlows, configFile: args.configFile, + // stdout is the JSON-RPC channel — config warnings must go to stderr. + warn: logStderr, }); const commonRoot = computeCommonRoot( @@ -173,6 +176,10 @@ export function registerRunCloudTest(server: McpServer): void { }); } + // Client-side envelope encryption (binary/flow/env). No MCP flag, so + // it's env-driven: DCD_ENCRYPT / DCD_ENCRYPT_BINARIES. + const encrypt = isEncryptionEnabled(); + // Resolve the binary: existing id, or upload the local file. let appBinaryId = args.appBinaryId; if (!appBinaryId) { @@ -190,6 +197,7 @@ export function registerRunCloudTest(server: McpServer): void { appBinaryId = await uploadBinary({ auth, apiUrl, + encrypt, filePath: args.appFile, ignoreShaCheck: Boolean(args.ignoreShaCheck), log: false, @@ -199,10 +207,12 @@ export function registerRunCloudTest(server: McpServer): void { const { continueOnFailure = true } = executionPlan.sequence ?? {}; const testSubmissionService = new TestSubmissionService(); const { buffer, fields } = await testSubmissionService.buildTestPayload({ + apiUrl, appBinaryId, cliVersion, commonRoot, continueOnFailure, + encrypt, executionPlan, flowFile, env: args.env ?? [], diff --git a/src/methods.ts b/src/methods.ts index 42ab30d..e3401f8 100644 --- a/src/methods.ts +++ b/src/methods.ts @@ -20,6 +20,14 @@ import { SupabaseGateway } from './gateways/supabase-gateway.js'; import { MetadataExtractorService } from './services/metadata-extractor.service.js'; import { TAppMetadata } from './types.js'; import type { AuthContext } from './types/domain/auth.types.js'; +import { + type BinaryEnvelope, + encryptFileToPath, + generateDek, + isEncryptionEnabled, + resolveKekPublicKey, + wrapDek, +} from './utils/envelope.js'; import { colors, formatId } from './utils/styling.js'; const mimeTypeLookupByExtension: Record = { @@ -94,7 +102,7 @@ export const compressFilesFromRelativePath = async ( }; export const verifyAppZip = async (zipPath: string) => { - // eslint-disable-next-line import/namespace, new-cap + // eslint-disable-next-line new-cap const zip = await new StreamZip.async({ file: zipPath, storeEntries: true, @@ -129,6 +137,12 @@ interface UploadBinaryConfig { auth: AuthContext; apiUrl: string; debug?: boolean; + /** + * Encrypt the binary before upload (client-side envelope encryption, #1138). + * Defaults to `DCD_ENCRYPT` / `DCD_ENCRYPT_BINARIES` when unset (see + * {@link isEncryptionEnabled}). + */ + encrypt?: boolean; filePath: string; ignoreShaCheck?: boolean; log?: boolean; @@ -136,6 +150,7 @@ interface UploadBinaryConfig { export const uploadBinary = async (config: UploadBinaryConfig) => { const { filePath, apiUrl, auth, ignoreShaCheck = false, log = true, debug = false } = config; + const encrypt = isEncryptionEnabled(config.encrypt); if (log) { ux.action.start(colors.bold('Checking and uploading binary'), colors.dim('Initializing'), { stdout: true, @@ -151,17 +166,28 @@ export const uploadBinary = async (config: UploadBinaryConfig) => { const startTime = Date.now(); let source: UploadSource | undefined; + let encCleanupDir: string | undefined; + let enc: BinaryEnvelope | undefined; try { // Prepare file for upload source = await prepareFileForUpload(filePath, debug, startTime); - // Calculate SHA hash - const sha = await calculateFileHash(source, debug, log); + // Hash the PLAINTEXT first, before any encryption (dcd#1168). Encryption + // uses a fresh random DEK per upload, so the ciphertext hash differs every + // time and cannot dedup — the plaintext hash is the only stable key. Doing it + // in this order also means a dedup hit skips the encryption work entirely, + // not just the upload. + const shaPlain = await calculateFileHash(source, debug, log); - // Check for existing upload with same SHA - if (!ignoreShaCheck && sha) { - const { exists, binaryId } = await checkExistingUpload(apiUrl, auth, sha, debug); + // Check for an existing upload before spending anything on encryption. + if (!ignoreShaCheck && shaPlain) { + const { exists, binaryId } = await checkExistingUpload( + apiUrl, + auth, + encrypt ? { encrypted: true, shaPlain } : { sha: shaPlain }, + debug, + ); if (exists && binaryId) { if (log) { @@ -175,8 +201,35 @@ export const uploadBinary = async (config: UploadBinaryConfig) => { } } + // Encrypt after the dedup check, so the SHA sent at finalise, and both + // uploaders, operate on ciphertext (binaries.sha = ciphertext hash, #1138). + if (encrypt) { + const encrypted = await encryptUploadSource(source, apiUrl, debug); + enc = encrypted.enc; + encCleanupDir = encrypted.cleanupDir; + if (log) { + ux.info(colors.dim(`Encrypting binary before upload (KEK v${enc.kek})`)); + } + } + + // Re-hash once encrypted: what lands in storage is the ciphertext, and every + // downstream verifySha hashes the bytes it actually holds (no DEK required). + const sha = encrypt ? await calculateFileHash(source, debug, false) : shaPlain; + // Perform the upload - const uploadId = await performUpload({ auth, apiUrl, debug, filePath, sha, source, startTime }); + const uploadId = await performUpload({ + auth, + apiUrl, + debug, + enc, + filePath, + sha, + // Only encrypted uploads record a plaintext hash; it is what makes the + // next encrypted run of this binary dedupable. + shaPlain: encrypt ? shaPlain : undefined, + source, + startTime, + }); if (log) { ux.action.stop(colors.success('\n✓ Binary uploaded with ID: ') + formatId(uploadId)); @@ -205,9 +258,54 @@ export const uploadBinary = async (config: UploadBinaryConfig) => { if (source?.cleanupDir) { await rm(source.cleanupDir, { recursive: true, force: true }).catch(() => {}); } + if (encCleanupDir) { + await rm(encCleanupDir, { recursive: true, force: true }).catch(() => {}); + } } }; +/** + * Encrypt the prepared upload source in place (dcd#1138): generate a per-upload + * DEK, stream-encrypt `source.diskPath` into a temp ciphertext file, wrap the + * DEK with the environment's pinned KEK public key, and repoint `source` at the + * ciphertext so the SHA, dedup check, and both uploaders operate on ciphertext. + * Returns the envelope metadata (for `binaries.metadata.enc`) plus the temp dir + * to clean up. Throws if no KEK is available for the environment. + */ +async function encryptUploadSource( + source: UploadSource, + apiUrl: string, + debug: boolean, +): Promise<{ enc: BinaryEnvelope; cleanupDir: string }> { + const kek = resolveKekPublicKey(apiUrl); + if (!kek) { + throw new Error( + 'Binary encryption was requested but no KEK public key is configured for this environment. ' + + 'Set DCD_BINARY_KEK_PUBLIC=: or pin one in src/config/environments.ts.', + ); + } + + const dek = generateDek(); + const enc = wrapDek(dek, kek); + const cleanupDir = await mkdtemp(path.join(os.tmpdir(), 'dcd-enc-')); + const cipherPath = path.join(cleanupDir, 'binary.enc'); + + if (debug) { + console.log(`[DEBUG] Encrypting binary with KEK v${kek.version} -> ${cipherPath}`); + } + + await encryptFileToPath(source.diskPath, cipherPath, dek, kek.version); + const { size } = await stat(cipherPath); + source.diskPath = cipherPath; + source.size = size; + + if (debug) { + console.log(`[DEBUG] Ciphertext size: ${(size / 1024 / 1024).toFixed(2)} MB`); + } + + return { enc, cleanupDir }; +} + /** * Disk-backed description of the binary to upload. Every upload path streams * from `diskPath` instead of materializing the file in memory — a 1.5 GB iOS @@ -352,30 +450,43 @@ async function calculateFileHash( } /** - * Checks if an upload with the same SHA already exists + * Checks whether a matching binary has already been uploaded. + * + * `lookup` is `{ sha }` for a plaintext upload, or `{ shaPlain, encrypted: true }` + * for an encrypted one (dcd#1168) — see {@link ApiGateway.checkForExistingUpload}. + * + * When asking as an encrypting client, a hit is only honoured if the server + * confirms the matched binary is itself encrypted. Any binary is a *plausible* + * match on plaintext hash, including a previously-uploaded plaintext copy of the + * same app, and reusing that would hand back an unencrypted binary while the user + * had asked for encryption. The server applies the same predicate; this is the + * client refusing to depend on that, so an older or misbehaving deployment + * degrades into a redundant upload rather than a silent loss of encryption. + * * @param apiUrl API base URL * @param auth AuthContext carrying request headers - * @param sha SHA-256 hash to check + * @param lookup Dedup key — plaintext hash for encrypted uploads, else the sha * @param debug Whether debug logging is enabled * @returns Promise resolving to object with exists flag and optional binaryId */ async function checkExistingUpload( apiUrl: string, auth: AuthContext, - sha: string, + lookup: { encrypted?: boolean; sha?: string; shaPlain?: string }, debug: boolean, ): Promise<{ binaryId?: string; exists: boolean }> { try { if (debug) { console.log('[DEBUG] Checking for existing upload with matching SHA...'); + console.log(`[DEBUG] Lookup: ${JSON.stringify(lookup)}`); console.log(`[DEBUG] Target endpoint: ${apiUrl}/uploads/checkForExistingUpload`); } const shaCheckStartTime = Date.now(); - const { appBinaryId, exists } = await ApiGateway.checkForExistingUpload( + const { appBinaryId, encrypted, exists } = await ApiGateway.checkForExistingUpload( apiUrl, auth, - sha as string, + lookup, ); if (debug) { @@ -386,6 +497,16 @@ async function checkExistingUpload( } } + if (exists && lookup.encrypted && encrypted !== true) { + if (debug) { + console.log( + '[DEBUG] Ignoring dedup hit: encryption was requested but the matched binary is not encrypted', + ); + } + + return { exists: false }; + } + return { binaryId: appBinaryId, exists }; } catch (error) { // Invalid credentials will fail every subsequent request — surface now @@ -416,8 +537,15 @@ interface PerformUploadConfig { auth: AuthContext; apiUrl: string; debug: boolean; + /** Envelope metadata when the binary was encrypted (#1138); undefined otherwise. */ + enc?: BinaryEnvelope; filePath: string; sha: string | undefined; + /** + * Hash of the plaintext, set only for encrypted uploads (#1168). Persisted as + * `binaries.sha_plain` so the next encrypted upload of this binary can dedup. + */ + shaPlain?: string; source: UploadSource; startTime: number; } @@ -694,13 +822,17 @@ function validateUploadResults( * @returns Promise resolving to upload ID */ async function performUpload(config: PerformUploadConfig): Promise { - const { filePath, apiUrl, auth, source, sha, debug, startTime } = config; + const { filePath, apiUrl, auth, enc, source, sha, shaPlain, debug, startTime } = config; // Request upload URL and paths const { id, tempPath, finalPath, b2 } = await requestUploadPaths(apiUrl, auth, filePath, source.size, debug); - // Extract app metadata + // Extract app metadata from the original (plaintext) file. Attach the + // envelope so it lands on binaries.metadata.enc (#1138). const metadata = await extractBinaryMetadata(filePath, debug); + if (enc) { + metadata.enc = enc; + } const env = inferEnvFromApiUrl(apiUrl); @@ -753,6 +885,7 @@ async function performUpload(config: PerformUploadConfig): Promise { path: tempPath, // sha is undefined when hash calculation failed — omit it explicitly ...(sha ? { sha } : {}), + ...(shaPlain ? { shaPlain } : {}), supabaseSuccess: supabaseResult.success, }); @@ -924,8 +1057,9 @@ async function uploadToBackblaze( console.error(`[DEBUG] Backblaze upload failed with status ${response.status}: ${errorText}`); } - // Don't throw - we don't want Backblaze failures to block the primary upload - console.warn(`Warning: Backblaze upload failed with status ${response.status}`); + // Don't throw and don't warn — Backblaze is the primary attempt and the + // Supabase fallback usually recovers. A user-facing error is raised only + // if every strategy fails (see validateUploadResults). return false; } @@ -950,13 +1084,10 @@ async function uploadToBackblaze( if (debug) { console.error('[DEBUG] Network error detected - could be DNS, connection, or SSL issue'); } - - console.warn('Warning: Backblaze upload failed due to network error'); - } else { - // Don't throw - we don't want Backblaze failures to block the primary upload - console.warn(`Warning: Backblaze upload failed: ${error instanceof Error ? error.message : String(error)}`); } + // Don't throw and don't warn — the Supabase fallback usually recovers, and + // validateUploadResults raises a user-facing error only if all fail. return false; } } @@ -1088,11 +1219,9 @@ function logBackblazeUploadError(error: unknown, debug: boolean): void { } } - if (error instanceof Error && error.message.includes('network error')) { - console.warn('Warning: Backblaze large file upload failed due to network error'); - } else { - console.warn(`Warning: Backblaze large file upload failed: ${error instanceof Error ? error.message : String(error)}`); - } + // No user-facing warning: Backblaze is the primary attempt and the Supabase + // fallback usually recovers; validateUploadResults raises the only + // user-facing error, and only when every strategy fails. } /** diff --git a/src/services/execution-plan.service.ts b/src/services/execution-plan.service.ts index 777f72b..9c1284c 100644 --- a/src/services/execution-plan.service.ts +++ b/src/services/execution-plan.service.ts @@ -4,45 +4,13 @@ import * as path from 'node:path'; import { getFlowsToRunInSequence, isFlowFile, + isWorkspaceConfigFile, + loadWorkspaceConfig, processDependencies, readDirectory, readTestYamlFileAsJson, - readYamlFileAsJson, } from './execution-plan.utils.js'; - -/** Email notification configuration */ -interface INotificationsConfig { - email?: { - enabled?: boolean; - onSuccess?: boolean; - recipients?: string[]; - }; -} - -/** Workspace configuration from config.yaml */ -interface IWorkspaceConfig { - excludeTags?: null | string[]; - executionOrder?: IExecutionOrder | null; - flows?: null | string[]; - includeTags?: null | string[]; - local?: ILocal | null; - notifications?: INotificationsConfig; - platform?: { - android?: { disableAnimations?: boolean }; - ios?: { disableAnimations?: boolean }; - }; -} - -/** Local execution configuration */ -interface ILocal { - deterministicOrder: boolean | null; -} - -/** Sequential execution configuration */ -interface IExecutionOrder { - continueOnFailure: boolean; - flowsOrder: string[]; -} +import { IWorkspaceConfig } from './workspace-config.schema.js'; /** Options for execution plan generation */ export interface PlanOptions { @@ -52,6 +20,12 @@ export interface PlanOptions { excludeTags?: string[]; includeTags?: string[]; input: string; + /** + * Sink for non-fatal config problems. Injected rather than imported so the + * MCP server can route warnings to stderr — its stdout is the JSON-RPC + * channel. + */ + warn?: (message: string) => void; } /** Execution plan containing all flows to run with metadata and dependencies */ @@ -145,11 +119,13 @@ function filterFlowFiles( * Load workspace configuration from config.yaml/yml if present * @param input - Input directory path * @param unfilteredFlowFiles - List of discovered flow files + * @param warn - Sink for non-fatal config problems * @returns Workspace configuration object (empty if no config file found) */ function getWorkspaceConfig( input: string, unfilteredFlowFiles: string[], + warn: (message: string) => void, ): IWorkspaceConfig { const possibleConfigPaths = new Set( [path.join(input, 'config.yaml'), path.join(input, 'config.yml')].map((p) => @@ -161,11 +137,7 @@ function getWorkspaceConfig( possibleConfigPaths.has(path.normalize(file)), ); - const config = configFilePath - ? (readYamlFileAsJson(configFilePath) as IWorkspaceConfig) - : {}; - - return config; + return configFilePath ? loadWorkspaceConfig(configFilePath, warn) : {}; } /** @@ -198,16 +170,20 @@ function extractDeviceCloudOverrides( /** * Generate execution plan for a single flow file * @param normalizedInput - Normalized path to the flow file - * @param configFile - Optional custom config file path + * @param warn - Sink for non-fatal config problems + * @param resolvedConfigFile - Optional absolute path to a custom config file * @returns Execution plan for the single file with dependencies */ async function planSingleFile( normalizedInput: string, - configFile?: string, + warn: (message: string) => void, + resolvedConfigFile?: string, ): Promise { + const inputBasename = path.basename(normalizedInput); if ( - normalizedInput.endsWith('config.yaml') || - normalizedInput.endsWith('config.yml') + inputBasename === 'config.yaml' || + inputBasename === 'config.yml' || + isWorkspaceConfigFile(normalizedInput) ) { throw new Error( 'If using config.yaml, pass the workspace folder path, not the config file or a custom path via --config', @@ -224,13 +200,22 @@ async function planSingleFile( } let workspaceConfig: IWorkspaceConfig | undefined; - if (configFile) { - const configFilePath = path.resolve(process.cwd(), configFile); - if (!fs.existsSync(configFilePath)) { - throw new Error(`Config file does not exist: ${configFilePath}`); + if (resolvedConfigFile) { + if (!fs.existsSync(resolvedConfigFile)) { + throw new Error(`Config file does not exist: ${resolvedConfigFile}`); } - workspaceConfig = readYamlFileAsJson(configFilePath) as IWorkspaceConfig; + workspaceConfig = loadWorkspaceConfig(resolvedConfigFile, warn); + + // Sequencing is resolved against a workspace's discovered flows, which a + // single-file input doesn't have — so executionOrder is ignored here. Say so + // rather than accepting a config that reads as if it applied (dcd-cli#110). + if (workspaceConfig.executionOrder?.flowsOrder.length) { + warn( + `Warning: \`executionOrder\` in ${resolvedConfigFile} is ignored when a single flow file is passed.\n` + + `Pass the workspace folder instead so the named flows can be discovered and sequenced.`, + ); + } } const checkedDependancies = await checkDependencies(normalizedInput); @@ -249,7 +234,7 @@ async function planSingleFile( * @param workspaceConfig - Workspace configuration containing flow globs * @param normalizedInput - Normalized path to the workspace directory * @param unfilteredFlowFiles - List of all discovered flow files - * @param configFile - Optional custom config file path + * @param resolvedConfigFile - Optional absolute path to a custom config file * @param excludeFlows - --exclude-flows patterns to re-apply to glob matches * @returns Filtered list of flow file paths matching the globs */ @@ -257,9 +242,24 @@ async function applyFlowGlobs( workspaceConfig: IWorkspaceConfig, normalizedInput: string, unfilteredFlowFiles: string[], - configFile?: string, + resolvedConfigFile?: string, excludeFlows?: string[], ): Promise { + // Both branches compare absolute paths, so `--config ./x.yml` and + // `--config /abs/x.yml` behave identically, and a same-named file in a + // sibling directory is never mistaken for the active config. + const activeConfig = resolvedConfigFile + ? path.normalize(resolvedConfigFile) + : undefined; + const isExcludedConfig = (absolutePath: string): boolean => { + const base = path.basename(absolutePath); + if (base === 'config.yaml' || base === 'config.yml') return true; + return ( + activeConfig !== undefined && + path.normalize(absolutePath) === activeConfig + ); + }; + if (workspaceConfig.flows) { const globs = workspaceConfig.flows.map((g) => g); // fs.globSync lands in Node 22; the CLI's `engines.node` already requires it. @@ -273,31 +273,19 @@ async function applyFlowGlobs( } }); + // Resolve before filtering: glob matches are relative to normalizedInput, + // so comparing them against the config path only ever worked when the + // config happened to sit at the workspace root. const globbedFlowFiles = matchedFiles - .filter((file: string) => { - if (file === 'config.yaml' || file === 'config.yml') return false; - if (configFile && file === path.basename(configFile)) return false; - if (!file.endsWith('.yaml') && !file.endsWith('.yml')) return false; - const pathParts = file.split(path.sep); - for (const part of pathParts) { - if (part.endsWith('.app')) return false; - } - - return true; - }) - .map((file) => path.resolve(normalizedInput, file)); + .map((file) => path.resolve(normalizedInput, file)) + .filter((file) => !isExcludedConfig(file) && isFlowFile(file)); // Re-globbing from disk bypasses the earlier --exclude-flows filter, so // re-apply it here or excluded flows sneak back in via `flows:` globs. return filterFlowFiles(globbedFlowFiles, excludeFlows); } - return unfilteredFlowFiles.filter( - (file) => - !file.endsWith('config.yaml') && - !file.endsWith('config.yml') && - (!configFile || !file.endsWith(configFile)), - ); + return unfilteredFlowFiles.filter((file) => !isExcludedConfig(file)); } /** @@ -379,9 +367,13 @@ export async function plan(options: PlanOptions): Promise { excludeFlows, configFile, debug = false, + warn = (message: string) => console.warn(message), } = options; const normalizedInput = path.normalize(input); const flowMetadata: Record> = {}; + const resolvedConfigFile = configFile + ? path.resolve(process.cwd(), configFile) + : undefined; if (!fs.existsSync(normalizedInput)) { throw new Error( @@ -390,7 +382,7 @@ export async function plan(options: PlanOptions): Promise { } if (fs.lstatSync(normalizedInput).isFile()) { - return planSingleFile(normalizedInput, configFile); + return planSingleFile(normalizedInput, warn, resolvedConfigFile); } let unfilteredFlowFiles = await readDirectory(normalizedInput, isFlowFile); @@ -405,25 +397,50 @@ export async function plan(options: PlanOptions): Promise { unfilteredFlowFiles = filterFlowFiles(unfilteredFlowFiles, excludeFlows); let workspaceConfig: IWorkspaceConfig; - if (configFile) { - const configFilePath = path.resolve(process.cwd(), configFile); - if (!fs.existsSync(configFilePath)) { - throw new Error(`Config file does not exist: ${configFilePath}`); + if (resolvedConfigFile) { + if (!fs.existsSync(resolvedConfigFile)) { + throw new Error(`Config file does not exist: ${resolvedConfigFile}`); } - workspaceConfig = readYamlFileAsJson(configFilePath) as IWorkspaceConfig; + workspaceConfig = loadWorkspaceConfig(resolvedConfigFile, warn); } else { - workspaceConfig = getWorkspaceConfig(normalizedInput, unfilteredFlowFiles); + workspaceConfig = getWorkspaceConfig( + normalizedInput, + unfilteredFlowFiles, + warn, + ); } unfilteredFlowFiles = await applyFlowGlobs( workspaceConfig, normalizedInput, unfilteredFlowFiles, - configFile, + resolvedConfigFile, excludeFlows, ); + // The exclusions above are filename-based: they only catch + // `config.yaml`/`config.yml` and the active --config target. Any other + // workspace config sharing the folder (a second CI workflow's, say) is still + // on the list and would be parsed as a flow, so drop config-shaped files by + // shape — see dcd-cli#99. + const configShapedFiles = new Set( + unfilteredFlowFiles.filter((file) => isWorkspaceConfigFile(file)), + ); + if (configShapedFiles.size > 0) { + if (debug) { + console.log( + `[DEBUG] Skipping ${configShapedFiles.size} workspace config file(s): ${[ + ...configShapedFiles, + ].join(', ')}`, + ); + } + + unfilteredFlowFiles = unfilteredFlowFiles.filter( + (file) => !configShapedFiles.has(file), + ); + } + if (unfilteredFlowFiles.length === 0) { const error = workspaceConfig.flows ? new Error( diff --git a/src/services/execution-plan.utils.ts b/src/services/execution-plan.utils.ts index b117759..dbb23ee 100644 --- a/src/services/execution-plan.utils.ts +++ b/src/services/execution-plan.utils.ts @@ -3,6 +3,12 @@ import * as yaml from 'js-yaml'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { + IWorkspaceConfig, + parseWorkspaceConfig, + WORKSPACE_CONFIG_KEYS, +} from './workspace-config.schema.js'; + const commandsThatRequireFiles = new Set(['addMedia', 'runFlow', 'runScript']); export function getFlowsToRunInSequence( @@ -60,25 +66,43 @@ export function isFlowFile(filePath: string): boolean { return filePath.endsWith('.yaml') || filePath.endsWith('.yml'); } -export const readYamlFileAsJson = (filePath: string) => { +/** + * True when a YAML file is a workspace config rather than a runnable flow. + * + * A flow is either `front matter --- steps` or a bare steps array; a + * single-document top-level map carrying workspace-config keys is neither, and + * left in the flow list it blows up processDependencies with "Expected an array + * of steps". Detection is by shape, not filename, so several named configs can + * coexist in one folder (dcd-cli#99). Requiring a recognised config key — not + * just "single document, top-level map" — keeps a flow that is merely *missing* + * its `---` separator loud rather than silently dropped. + * + * @param filePath - Path to the YAML file to classify + * @returns Whether the file is a workspace config rather than a flow + */ +export function isWorkspaceConfigFile(filePath: string): boolean { + let parsed; try { - const normalizedPath = path.normalize(filePath); - const yamlText = fs.readFileSync(normalizedPath, 'utf8'); + parsed = readTestYamlFileAsJson(filePath); + } catch { + // Unparseable — leave it in the flow list so the existing error path reports it. + return false; + } - const result = yaml.load(yamlText); + const { config, testSteps } = parsed; + if (config !== null) return false; // has `---` front matter → flow + if (Array.isArray(testSteps)) return false; // bare steps array → flow + if (!testSteps || typeof testSteps !== 'object') return false; - // Ensure includeTags and excludeTags are always arrays if present - if (result && typeof result === 'object') { - if ('includeTags' in result && !Array.isArray(result.includeTags)) { - result.includeTags = result.includeTags ? [result.includeTags] : []; - } + return Object.keys(testSteps).some((key) => WORKSPACE_CONFIG_KEYS.has(key)); +} - if ('excludeTags' in result && !Array.isArray(result.excludeTags)) { - result.excludeTags = result.excludeTags ? [result.excludeTags] : []; - } - } +export const readYamlFileAsJson = (filePath: string) => { + try { + const normalizedPath = path.normalize(filePath); + const yamlText = fs.readFileSync(normalizedPath, 'utf8'); - return result; + return yaml.load(yamlText); } catch (error) { throw new Error(`Error parsing YAML file ${filePath}: ${error}`, { cause: error, @@ -86,6 +110,26 @@ export const readYamlFileAsJson = (filePath: string) => { } }; +/** + * Load and validate a workspace config file. + * + * The single chokepoint for reading a config: every caller gets a + * runtime-validated object instead of an unchecked `as IWorkspaceConfig` cast. + * Scalar-to-array coercion for `includeTags`/`excludeTags` lives in the schema, + * so `readYamlFileAsJson` stays a plain YAML read. + * + * @param filePath - Path to the config file + * @param warn - Sink for non-fatal problems (unrecognised keys) + * @returns The validated workspace config + * @throws Error if the file is unparseable or the config is invalid + */ +export function loadWorkspaceConfig( + filePath: string, + warn: (message: string) => void, +): IWorkspaceConfig { + return parseWorkspaceConfig(readYamlFileAsJson(filePath), { filePath, warn }); +} + export const readTestYamlFileAsJson = (filePath: string) => { try { const normalizedPath = path.normalize(filePath); diff --git a/src/services/metadata-extractor.service.ts b/src/services/metadata-extractor.service.ts index 3c26478..87b068f 100644 --- a/src/services/metadata-extractor.service.ts +++ b/src/services/metadata-extractor.service.ts @@ -1,15 +1,15 @@ -import bplistParser from 'bplist-parser'; +import { parseBuffer } from 'bplist-parser'; import nodeApk from 'node-apk'; import { readFile, rm } from 'node:fs/promises'; import * as path from 'node:path'; import StreamZip from 'node-stream-zip'; import { parse } from 'plist'; -// node-apk and bplist-parser are CJS with no `exports` map; Node's named-export -// detection for CJS (cjs-module-lexer) is version-dependent, so destructure off -// the default import instead — that interop is guaranteed on every Node version. +// node-apk is CJS with no `exports` map; Node's named-export detection for CJS +// (cjs-module-lexer) is version-dependent, so destructure off the default import +// instead — that interop is guaranteed on every Node version. bplist-parser 0.5 +// is a real ESM/CJS dual package with named exports, so it imports directly. const { Apk } = nodeApk; -const { parseBuffer } = bplistParser; export interface TAppMetadata { appId: string; diff --git a/src/services/notices.service.ts b/src/services/notices.service.ts new file mode 100644 index 0000000..ff5022f --- /dev/null +++ b/src/services/notices.service.ts @@ -0,0 +1,160 @@ +import { ui } from '../utils/ui.js'; +import { colors } from '../utils/styling.js'; +import { compareSemver } from './version.service.js'; + +export type NoticeLevel = 'deprecation' | 'warn' | 'info' | 'marketing'; + +export interface NoticeMatchRule { + field: string; + op: 'present' | 'absent' | 'equals' | 'in' | 'not_in' | 'lt' | 'gt'; + value?: unknown; +} + +export interface NoticeMatch { + rules: NoticeMatchRule[]; +} + +/** + * The client-facing notice shape returned by the API (embedded in the + * compatibility response and from GET /notices). `match` is an optional display + * gate the CLI evaluates locally against its own context (e.g. the selected iOS + * version) — the API can't know those at fetch time. + */ +export interface Notice { + id: string; + slug: string | null; + level: NoticeLevel; + title: string; + body: string; + learnMoreUrl: string | null; + dismissible: boolean; + match: NoticeMatch | null; +} + +/** Flat key/value context the `match` DSL is evaluated against. */ +export type NoticeContext = Record; + +function isPresent(raw: unknown): boolean { + return raw !== null && raw !== undefined && raw !== false && raw !== ''; +} + +function ruleMatches(rule: NoticeMatchRule, ctx: NoticeContext): boolean { + const raw = ctx[rule.field]; + switch (rule.op) { + case 'present': + return isPresent(raw); + case 'absent': + return !isPresent(raw); + case 'equals': + return isPresent(raw) && String(raw) === String(rule.value); + case 'in': + return ( + isPresent(raw) && + Array.isArray(rule.value) && + rule.value.map(String).includes(String(raw)) + ); + case 'not_in': + return ( + isPresent(raw) && + Array.isArray(rule.value) && + !rule.value.map(String).includes(String(raw)) + ); + case 'lt': + return ( + isPresent(raw) && + rule.value != null && + compareSemver(String(raw), String(rule.value)) < 0 + ); + case 'gt': + return ( + isPresent(raw) && + rule.value != null && + compareSemver(String(raw), String(rule.value)) > 0 + ); + default: + return false; + } +} + +/** A null / empty match imposes no constraint; otherwise every rule must match (AND). */ +export function matchesRules( + match: NoticeMatch | null | undefined, + ctx: NoticeContext, +): boolean { + if (!match || !Array.isArray(match.rules) || match.rules.length === 0) { + return true; + } + return match.rules.every((rule) => ruleMatches(rule, ctx)); +} + +export interface RenderNoticesOptions { + /** + * Emit a line of human output. Notices route through the same gated `out` as + * the rest of the CLI (suppressed under `--json`); we don't use the `warn` + * channel because its logger prepends its own `⚠`, which would double up with + * the symbol the `ui.*` helpers already add. + */ + out: (message: string) => void; +} + +/** Render a single notice with styling appropriate to its level. */ +function renderNotice(notice: Notice, opts: RenderNoticesOptions): void { + const rows = [notice.body]; + if (notice.learnMoreUrl) { + rows.push(`${colors.dim('See:')} ${colors.url(notice.learnMoreUrl)}`); + } + + switch (notice.level) { + case 'deprecation': + opts.out(ui.deprecation(colors.bold(notice.title))); + opts.out(ui.branch(rows)); + break; + case 'warn': + opts.out(ui.warn(colors.bold(notice.title))); + opts.out(ui.branch(rows)); + break; + case 'marketing': + opts.out(ui.section(notice.title)); + opts.out(ui.branch(rows)); + break; + case 'info': + default: + opts.out(ui.info(colors.bold(notice.title))); + opts.out(ui.branch(rows)); + break; + } +} + +/** + * Filter notices by their local-context `match` gate and render those that pass. + * Returns the visible notices so a `--json` caller can include them in its + * payload instead of printing. `opts.out` is the caller's `--json`-gated + * emitter, so under `--json` nothing prints but the list is still returned. + */ +/** + * Best-effort platform from the app artifact's extension, so a notice can be + * targeted at one platform (e.g. "you rely on the default Android API level") + * without firing on the other platform's runs. + */ +export function platformFromAppFile( + appFile: string | undefined, +): 'android' | 'ios' | undefined { + if (!appFile) return undefined; + const ext = appFile.split('?')[0].toLowerCase().match(/.([a-z0-9]+)$/)?.[1]; + if (ext === 'apk' || ext === 'aab') return 'android'; + if (ext === 'zip' || ext === 'app' || ext === 'ipa') return 'ios'; + return undefined; +} + +export function renderNotices( + notices: Notice[] | undefined, + ctx: NoticeContext, + opts: RenderNoticesOptions, +): Notice[] { + if (!notices || notices.length === 0) return []; + const visible = notices.filter((n) => matchesRules(n.match, ctx)); + for (const notice of visible) { + renderNotice(notice, opts); + } + return visible; +} diff --git a/src/services/results-polling.service.ts b/src/services/results-polling.service.ts index 9146f69..774fc66 100644 --- a/src/services/results-polling.service.ts +++ b/src/services/results-polling.service.ts @@ -9,6 +9,7 @@ import { formatDurationSeconds } from '../methods.js'; import type { AuthContext } from '../types/domain/auth.types.js'; import { paths } from '../types/generated/schema.types.js'; import { checkInternetConnectivity } from '../utils/connectivity.js'; +import { isCI } from '../utils/ci.js'; import { ux } from '../utils/progress.js'; import { colors, formatTestSummary, statusPalette, table } from '../utils/styling.js'; import { type Field, ui } from '../utils/ui.js'; @@ -46,10 +47,46 @@ export interface TestMetadata { tags: string[]; } +/** + * The device a result ran on. Additive: single-device runs are unchanged, and + * a device matrix disambiguates two `tests[]` entries that share a `name` (the + * same flow on two devices) by their device. + */ +export interface TestDevice { + googlePlay?: boolean; + name?: string; + osVersion?: string; +} + +/** + * Structured device for a result row. Prefers the friendly deviceName/osVersion + * the per-flow targeting / matrix fan-out stamps onto each result's config + * (#1097), falling back to the raw simulator_name for older rows. Returns + * undefined when neither is present, so single-device runs that predate the + * field simply omit `device`. Shared by the sync polling path and the async + * (--async --json) path so both emit an identical device shape. + */ +export function deviceFromResultRow(r: { + config?: unknown; + simulator_name?: string | null; +}): TestDevice | undefined { + const config = (r.config ?? {}) as { deviceName?: string; osVersion?: string }; + const sim = r.simulator_name ?? undefined; + const name = config.deviceName ?? sim; + if (!name && !config.osVersion) return undefined; + return { + name, + osVersion: config.osVersion, + googlePlay: sim ? /(_PLAY|-play)$/.test(sim) : undefined, + }; +} + export interface PollingResult { consoleUrl: string; status: 'FAILED' | 'PASSED'; tests: Array<{ + /** Device this result ran on (present when the API reports it). */ + device?: TestDevice; durationSeconds: null | number; failReason?: string; /** File path of the test (same as name, for clarity) */ @@ -160,12 +197,21 @@ export class ResultsPollingService { let realtimeEnabled = false; let statusBody = ''; let nextPollAt: null | number = null; + // The animated footer/countdown only makes sense on a TTY. In CI/pipes it + // would flood logs (a fresh line per frame), so we drop it and let the + // progress adapter print one line per distinct status change instead. + const interactive = !json && !isCI(); const renderStatus = () => { if (json) return; + if (!interactive) { + ux.action.status = statusBody; + return; + } const footer = this.buildStatusFooter( realtimeEnabled, subscription?.isConnected() ?? false, nextPollAt, + quiet, ); ux.action.status = footer ? `${statusBody}\n${footer}` : statusBody; }; @@ -200,8 +246,11 @@ export class ResultsPollingService { // Tick the live footer once a second so the countdown actually counts down // (the spinner's own frames don't recompute our message). Unref'd so it - // never keeps the process alive on its own. - const ticker: NodeJS.Timeout | null = json ? null : setInterval(renderStatus, 1000); + // never keeps the process alive on its own. Only on an interactive TTY — + // a 1s ticker in CI would reprint the status every second. + const ticker: NodeJS.Timeout | null = interactive + ? setInterval(renderStatus, 1000) + : null; ticker?.unref?.(); try { @@ -298,6 +347,12 @@ export class ResultsPollingService { ? 'PASSED' : 'FAILED', tests: resultsWithoutEarlierTries.map((r) => ({ + // r carries config/simulator_name at runtime; the committed generated + // types lag the API (regenerated wholesale from dev's swagger), so read + // them through the helper's structural type. + device: deviceFromResultRow( + r as { config?: unknown; simulator_name?: string | null }, + ), durationSeconds: r.duration_seconds ?? null, failReason: r.status === 'FAILED' ? r.fail_reason || 'No reason provided' : undefined, @@ -644,12 +699,13 @@ export class ResultsPollingService { * Build the live footer shown under the status display: whether realtime * updates are connected (for logged-in users) and how long until the next * backstop poll. While a fetch is in flight (`nextPollAt` is null) the - * countdown reads "refreshing…". + * countdown reads "refreshing…". In quiet mode the countdown is omitted. */ private buildStatusFooter( realtimeEnabled: boolean, realtimeConnected: boolean, nextPollAt: null | number, + quiet: boolean, ): string { const parts: string[] = []; @@ -661,11 +717,15 @@ export class ResultsPollingService { ); } - if (nextPollAt === null) { - parts.push(colors.dim('refreshing…')); - } else { - const secondsLeft = Math.max(0, Math.ceil((nextPollAt - Date.now()) / 1000)); - parts.push(colors.dim(`next refresh in ${secondsLeft}s`)); + // The countdown to the next backstop poll is noise in quiet mode (geared at + // CI), so suppress it there while keeping the realtime indicator. + if (!quiet) { + if (nextPollAt === null) { + parts.push(colors.dim('refreshing…')); + } else { + const secondsLeft = Math.max(0, Math.ceil((nextPollAt - Date.now()) / 1000)); + parts.push(colors.dim(`next refresh in ${secondsLeft}s`)); + } } return parts.join(colors.dim(' · ')); diff --git a/src/services/test-submission.service.ts b/src/services/test-submission.service.ts index 5d4aea6..61cda81 100644 --- a/src/services/test-submission.service.ts +++ b/src/services/test-submission.service.ts @@ -2,6 +2,13 @@ import { createHash } from 'node:crypto'; import * as path from 'node:path'; import { compressFilesFromRelativePath } from '../methods.js'; +import { DeviceMatrixConfig } from '../types/domain/device.types.js'; +import { + type BinaryEnvelope, + encryptEnv, + encryptFlowBuffer, + resolveKekPublicKey, +} from '../utils/envelope.js'; import { toPortableRelativePath } from '../utils/paths.js'; import { IExecutionPlan } from './execution-plan.service.js'; @@ -9,13 +16,21 @@ export interface TestSubmissionConfig { androidApiLevel?: string; androidDevice?: string; androidNoSnapshot?: boolean; + apiUrl?: string; appBinaryId: string; cliVersion: string; commonRoot: string; continueOnFailure?: boolean; debug?: boolean; deviceLocale?: string; + deviceMatrix?: DeviceMatrixConfig[]; disableAnimations?: boolean; + /** + * Encrypt the flow zip and env vars before upload (#1151/#1152), each with its + * own per-upload DEK wrapped under the environment KEK. Requires `apiUrl` to + * resolve the pinned KEK public key. + */ + encrypt?: boolean; env?: string[]; executionPlan: IExecutionPlan; flowFile: string; @@ -26,11 +41,15 @@ export interface TestSubmissionConfig { maestroChromeOnboarding?: boolean; maestroVersion: string; metadata?: string[]; - mitmHost?: string; - mitmPath?: string; name?: string; orientation?: string; raw?: unknown; + /** + * Which emulator software renderer to boot with (Android only). Omitted + * means the server decides: lavapipe by default, or swiftshader when the + * binary is detected as shipping the Flutter engine. + */ + renderEngine?: 'lavapipe' | 'swiftshader'; report?: string; retry?: number; runnerType?: string; @@ -58,7 +77,9 @@ export class TestSubmissionService { config: TestSubmissionConfig, ): Promise<{ buffer: Buffer; fields: Record; sha: string }> { const { + apiUrl, appBinaryId, + encrypt = false, flowFile, executionPlan, commonRoot, @@ -76,15 +97,15 @@ export class TestSubmissionService { maestroVersion, deviceLocale, orientation, - mitmHost, - mitmPath, retry, continueOnFailure = true, + renderEngine, report, showCrosshairs, maestroChromeOnboarding, raw, disableAnimations, + deviceMatrix, debug = false, logger, } = config; @@ -141,7 +162,7 @@ export class TestSubmissionService { this.logDebug(debug, logger, `[DEBUG] Compressing files from path: ${flowFile}`); - const buffer = await compressFilesFromRelativePath( + const plaintextZip = await compressFilesFromRelativePath( flowFile?.endsWith('.yaml') || flowFile?.endsWith('.yml') ? path.dirname(flowFile) : flowFile, @@ -155,9 +176,45 @@ export class TestSubmissionService { commonRoot, ); - this.logDebug(debug, logger, `[DEBUG] Compressed file size: ${buffer.length} bytes`); + this.logDebug(debug, logger, `[DEBUG] Compressed file size: ${plaintextZip.length} bytes`); + + // Client-side envelope encryption (#1151 flow zip, #1152 env vars). Each + // gets its own per-upload DEK wrapped under the environment KEK; the flow + // zip becomes a DCDE container and `sha` is the CIPHERTEXT hash (uploads.sha + // stays a ciphertext hash, mirroring binaries.sha). `enc` rides `fields` + // as a JSON string like every other field, so both the JSON submitFlowTest + // body and the legacy multipart form carry it identically. + let buffer = plaintextZip; + let envObjectToSend: Record = envObject; + let flowEnc: BinaryEnvelope | undefined; + if (encrypt) { + if (!apiUrl) { + throw new Error('Encryption requires apiUrl to resolve the KEK public key'); + } + const kek = resolveKekPublicKey(apiUrl); + if (!kek) { + throw new Error( + 'Encryption was requested but no KEK public key is configured for this environment. ' + + 'Set DCD_BINARY_KEK_PUBLIC=: or pin one in src/config/environments.ts.', + ); + } + const flow = encryptFlowBuffer(plaintextZip, kek); + buffer = flow.ciphertext; + flowEnc = flow.enc; + this.logDebug( + debug, + logger, + `[DEBUG] Encrypting flow zip before upload (KEK v${kek.version}); ciphertext ${buffer.length} bytes`, + ); + // Only encrypt when there are env vars; an empty map has no secret to + // protect and stays a plaintext `{}` (no `enc` marker, passes through). + if (Object.keys(envObject).length > 0) { + envObjectToSend = { enc: encryptEnv(envObject, kek) }; + this.logDebug(debug, logger, `[DEBUG] Encrypting ${Object.keys(envObject).length} env var(s)`); + } + } - // Calculate SHA-256 hash of the flow ZIP + // SHA-256 of what actually gets uploaded (ciphertext when encrypted). const sha = createHash('sha256').update(buffer).digest('hex'); this.logDebug(debug, logger, `[DEBUG] Flow ZIP SHA-256: ${sha}`); @@ -179,11 +236,32 @@ export class TestSubmissionService { fields.sequentialFlows = JSON.stringify( this.normalizePaths(sequentialFlows, commonRoot), ); - fields.env = JSON.stringify(envObject); + fields.env = JSON.stringify(envObjectToSend); + // Flow-zip envelope for uploads.metadata.enc (#1151). JSON string so both + // submission paths carry it like every other field; the API parses it. + if (flowEnc) { + fields.enc = JSON.stringify(flowEnc); + } // Note: googlePlay is now included in configPayload below instead of as a separate field // to work around a FormData parsing issue in the API - const targetPlatform = iOSDevice || iOSVersion ? 'ios' : 'android'; + // Explicit device matrix (one upload, N cells). Only sent when present, so + // single-device submissions stay byte-identical. + if (deviceMatrix && deviceMatrix.length > 0) { + fields.deviceMatrix = JSON.stringify(deviceMatrix); + } + + // Platform used only to pick which workspace-config disableAnimations flag + // applies. A device matrix is single-platform; its first cell decides. Fall + // back to the scalar iOS flags for single-device submissions. + const matrixPlatform = + deviceMatrix && deviceMatrix.length > 0 + ? 'iOSDevice' in deviceMatrix[0] + ? 'ios' + : 'android' + : undefined; + const targetPlatform = + matrixPlatform ?? (iOSDevice || iOSVersion ? 'ios' : 'android'); const configYamlDisableAnimations = targetPlatform === 'ios' ? Boolean(workspaceConfig?.platform?.ios?.disableAnimations) @@ -208,10 +286,9 @@ export class TestSubmissionService { deviceLocale, googlePlay, maestroVersion, - mitmHost, - mitmPath, orientation, raw: JSON.stringify(raw), + renderEngine, report, showCrosshairs, maestroChromeOnboarding, diff --git a/src/services/version.service.ts b/src/services/version.service.ts index ceda057..2340377 100644 --- a/src/services/version.service.ts +++ b/src/services/version.service.ts @@ -3,6 +3,70 @@ import { CompatibilityData } from '../utils/compatibility.js'; const DEFAULT_MANIFEST_URL = 'https://get.devicecloud.dev/latest.json'; const MANIFEST_TIMEOUT_MS = 3000; +export type ReleaseChannel = 'beta' | 'stable'; + +/** + * Outcome of a release-manifest lookup. `ok: true` means the manifest was + * reachable — `version` is the published version on the channel, or `null` when + * nothing is published there yet. `ok: false` means the lookup itself failed + * (network/timeout/non-2xx). + */ +export type LatestVersionResult = + | { ok: true; channel: ReleaseChannel; version: null | string } + | { ok: false; error: string }; + +/** + * Compare two semantic versions per SemVer 2.0.0 precedence rules. + * Returns a negative number if `a < b`, positive if `a > b`, and 0 if equal. + * + * Implements the prerelease rules that the previous naive comparator dropped: + * - A version WITH a prerelease has lower precedence than the same version + * without one ("1.0.0-beta" < "1.0.0"). + * - Prerelease identifiers are compared dot-separated, left to right: + * numeric identifiers compare numerically, alphanumeric ones compare + * lexically (ASCII), and numeric always sorts below alphanumeric. A longer + * set of identifiers wins when all preceding ones are equal. + */ +export function compareSemver(a: string, b: string): number { + const split = (v: string): { release: number[]; pre: string[] } => { + const [core, ...preParts] = v.trim().replace(/^v/, '').split('-'); + const nums = core.split('.').map((n) => Number(n) || 0); + const pre = preParts.join('-'); + return { + release: [nums[0] || 0, nums[1] || 0, nums[2] || 0], + pre: pre ? pre.split('.') : [], + }; + }; + + const left = split(a); + const right = split(b); + + for (let i = 0; i < 3; i++) { + if (left.release[i] !== right.release[i]) { + return left.release[i] - right.release[i]; + } + } + + // Equal release: a version with no prerelease outranks one that has it. + if (left.pre.length === 0 && right.pre.length === 0) return 0; + if (left.pre.length === 0) return 1; + if (right.pre.length === 0) return -1; + + const len = Math.min(left.pre.length, right.pre.length); + for (let i = 0; i < len; i++) { + const lp = left.pre[i]; + const rp = right.pre[i]; + if (lp === rp) continue; + const ln = /^\d+$/.test(lp); + const rn = /^\d+$/.test(rp); + if (ln && rn) return Number(lp) - Number(rp); + if (ln) return -1; // numeric identifiers sort below alphanumeric + if (rn) return 1; + return lp < rp ? -1 : 1; + } + return left.pre.length - right.pre.length; +} + /** * Service for handling version validation and checking */ @@ -10,47 +74,57 @@ export class VersionService { /** * Fetch the latest published CLI version from the release manifest. * Works for both npm- and binary-installed users (no `npm` shell-out). - * Silently returns null on any failure — this check is informational only. + * + * The result is discriminated so callers can tell "reachable, but no release + * on this channel yet" (`ok: true, version: null`) apart from an actual + * network/manifest failure (`ok: false`) — the old single-`null` return + * conflated the two and produced a misleading "check your network" error + * during the beta. Prerelease installs (current version contains `-`) query + * the opt-in beta channel; everyone else gets the stable channel. */ - async checkLatestCliVersion(): Promise { - const url = process.env.DCD_MANIFEST_URL ?? DEFAULT_MANIFEST_URL; + async checkLatestCliVersion( + currentVersion?: string, + ): Promise { + const channel: ReleaseChannel = + currentVersion?.includes('-') ? 'beta' : 'stable'; + const base = process.env.DCD_MANIFEST_URL ?? DEFAULT_MANIFEST_URL; + const url = + channel === 'beta' + ? `${base}${base.includes('?') ? '&' : '?'}channel=beta` + : base; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), MANIFEST_TIMEOUT_MS); try { const res = await fetch(url, { signal: controller.signal }); - if (!res.ok) return null; + if (!res.ok) { + return { ok: false, error: `manifest responded with HTTP ${res.status}` }; + } const data = (await res.json()) as { version?: unknown }; - return typeof data.version === 'string' ? data.version : null; - } catch { - return null; + return { + ok: true, + channel, + version: typeof data.version === 'string' ? data.version : null, + }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; } finally { clearTimeout(timer); } } /** - * Compare two semantic version strings - * @param current - Current version - * @param latest - Latest version - * @returns true if current is older than latest + * Compare two semantic version strings (SemVer 2.0.0 precedence, including + * prerelease tags). Returns true if `current` is strictly older than `latest`. + * + * Prerelease handling matters here: a beta-to-beta bump such as + * "5.0.0-beta.0" -> "5.0.0-beta.1" shares the same major.minor.patch, so we + * must compare the prerelease identifiers to detect that an upgrade exists. */ isOutdated(current: string, latest: string): boolean { - // Strip any prerelease suffix ("1.2.3-beta.1" -> "1.2.3") and default - // missing segments to 0 so short/prerelease versions still compare. - const parts = (version: string): number[] => { - const nums = version.split('-')[0].split('.').map(Number); - return [nums[0] || 0, nums[1] || 0, nums[2] || 0]; - }; - - const currentParts = parts(current); - const latestParts = parts(latest); - - for (let i = 0; i < 3; i++) { - if (currentParts[i] < latestParts[i]) return true; - if (currentParts[i] > latestParts[i]) return false; - } - - return false; + return compareSemver(current, latest) < 0; } /** diff --git a/src/services/workspace-config.schema.ts b/src/services/workspace-config.schema.ts new file mode 100644 index 0000000..960cee9 --- /dev/null +++ b/src/services/workspace-config.schema.ts @@ -0,0 +1,255 @@ +import * as yaml from 'js-yaml'; +import { z } from 'zod'; + +/** + * Runtime schema for a workspace `config.yaml`. + * + * This is the single source of truth for the config's shape — the TypeScript + * type is inferred from it (`IWorkspaceConfig`) rather than declared alongside + * it, so the compile-time and runtime views cannot drift. Before this existed + * the config was `yaml.load`ed and straight-cast, which meant an + * `executionOrder` written in the wrong shape was silently ignored and every + * flow ran in parallel (dcd-cli#110). + */ + +/** + * Tags may be written as a bare scalar (`includeTags: smoke`) or a list. + * Scalars are wrapped, and primitive members are coerced to strings so a + * YAML-numeric tag (`includeTags: [2]`) still compares against flow tags + * instead of silently matching nothing. + */ +const tagList = z.preprocess((value) => { + if (value === null || value === undefined) return value; + const members = Array.isArray(value) ? value : [value]; + return members.map((member) => + typeof member === 'number' || typeof member === 'boolean' + ? String(member) + : member, + ); +}, z.array(z.string())); + +/** Sequential execution configuration */ +const ExecutionOrderSchema = z.object({ + // Defaults to true: a failing flow does not stop the rest of the sequence. + // Declared here so the default lives in one place instead of being + // re-specified at each read site. + continueOnFailure: z.boolean().default(true), + flowsOrder: z.array(z.string()), +}); + +/** + * `looseObject`, not `object`: unknown keys are reported as warnings but must + * survive parsing, because the whole config is forwarded to the API as + * `fields.workspaceConfig` and stripping keys would silently alter that + * payload. + */ +export const WorkspaceConfigSchema = z.looseObject({ + excludeTags: tagList.nullish(), + executionOrder: ExecutionOrderSchema.nullish(), + flows: z.array(z.string()).nullish(), + includeTags: tagList.nullish(), + local: z + .looseObject({ deterministicOrder: z.boolean().nullish() }) + .nullish(), + notifications: z + .looseObject({ + email: z + .looseObject({ + enabled: z.boolean().optional(), + onSuccess: z.boolean().optional(), + recipients: z.array(z.string()).optional(), + }) + .optional(), + }) + .nullish(), + platform: z + .looseObject({ + android: z + .looseObject({ disableAnimations: z.boolean().optional() }) + .optional(), + ios: z + .looseObject({ disableAnimations: z.boolean().optional() }) + .optional(), + }) + .nullish(), +}); + +/** Workspace configuration from config.yaml */ +export type IWorkspaceConfig = z.infer; + +/** + * Top-level keys that only ever appear in a workspace config, derived from the + * schema so the two can't drift. Deliberately excludes keys Maestro also allows + * in flow front matter — appId, name, tags, env, onFlowStart, onFlowComplete, + * jsEngine — because this set also drives config-vs-flow detection + * (`isWorkspaceConfigFile`). + */ +export const WORKSPACE_CONFIG_KEYS: ReadonlySet = new Set( + Object.keys(WorkspaceConfigSchema.shape), +); + +/** + * Near-misses that aren't just a casing slip on a real key. Keyed lowercase. + */ +const KEY_ALIASES: Record = { + continueonfailure: 'executionOrder.continueOnFailure', + excludetag: 'excludeTags', + floworder: 'executionOrder.flowsOrder', + flowsorder: 'executionOrder.flowsOrder', + includetag: 'includeTags', + tags: 'includeTags / excludeTags', +}; + +/** + * Suggest the key the author probably meant. + * @param unknownKey - Unrecognised top-level key from the config + * @returns The suggested key name, or undefined if there's no close match + */ +function suggestKey(unknownKey: string): string | undefined { + const lowered = unknownKey.toLowerCase(); + const casingSlip = [...WORKSPACE_CONFIG_KEYS].find( + (key) => key.toLowerCase() === lowered, + ); + + return casingSlip ?? KEY_ALIASES[lowered]; +} + +/** + * Describe how an `executionOrder` value is malformed, in prose. + * + * Split out so the most likely mistake — a bare list of flow names — gets a + * message showing the expected shape rather than a generic schema dump. + * + * @param value - The raw `executionOrder` value from the config + * @returns A description of the problem, or undefined if the shape is valid + */ +function describeExecutionOrderShape(value: unknown): string | undefined { + if (value === null || value === undefined) return undefined; + if (Array.isArray(value)) return 'a bare list of flow names'; + if (typeof value !== 'object') return `a ${typeof value} value`; + + const { flowsOrder } = value as Record; + if (flowsOrder === undefined) return 'a map with no `flowsOrder` key'; + if (!Array.isArray(flowsOrder)) { + return 'a map whose `flowsOrder` is not a list'; + } + + return undefined; +} + +/** Indent a YAML fragment so it reads as a block inside an error message. */ +function indentYaml(value: unknown): string { + return yaml + .dump(value, { lineWidth: -1 }) + .trimEnd() + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); +} + +/** + * Build the error for a malformed `executionOrder`, showing what was found + * next to what was expected. + * @param filePath - Path to the config file, for the message + * @param problem - Prose description from describeExecutionOrderShape + * @param found - The raw `executionOrder` value that failed + * @returns The error to throw + */ +function executionOrderError( + filePath: string, + problem: string, + found: unknown, +): Error { + return new Error( + `Invalid \`executionOrder\` in ${filePath}\n\n` + + `\`executionOrder\` must be a map containing a \`flowsOrder\` list, but it is ${problem}.\n\n` + + `Found:\n${indentYaml({ executionOrder: found })}\n\n` + + `Expected:\n${indentYaml({ + executionOrder: { + continueOnFailure: true, + flowsOrder: ['first-flow', 'second-flow'], + }, + })}\n\n` + + `Without \`flowsOrder\` the flows are not sequenced — they all run in parallel.`, + ); +} + +/** + * Validate a parsed workspace config. + * + * Hard errors (thrown): the config isn't a map, or `executionOrder` is present + * but malformed. Both are unambiguous mistakes with no valid interpretation, so + * failing fast beats a warning that scrolls past in CI. + * + * Soft problems (warned): unknown top-level keys. These are preserved, not + * stripped, and warned about once. + * + * @param raw - The result of loading the YAML file + * @param options - Config file path (for messages) and a warning sink + * @returns The validated config, with defaults applied + * @throws Error if the config is not a map or `executionOrder` is malformed + */ +export function parseWorkspaceConfig( + raw: unknown, + options: { filePath: string; warn: (message: string) => void }, +): IWorkspaceConfig { + const { filePath, warn } = options; + + if (raw === null || raw === undefined) return {}; + + if (typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error( + `Invalid workspace config in ${filePath}\n\n` + + `Expected a map of configuration keys (${[...WORKSPACE_CONFIG_KEYS].join( + ', ', + )}), but found ${Array.isArray(raw) ? 'a list' : `a ${typeof raw} value`}.`, + ); + } + + const rawConfig = raw as Record; + + // Checked ahead of the schema so this specific mistake gets a targeted + // message; the schema would otherwise report "expected object, received array". + const executionOrderProblem = describeExecutionOrderShape( + rawConfig.executionOrder, + ); + if (executionOrderProblem) { + throw executionOrderError( + filePath, + executionOrderProblem, + rawConfig.executionOrder, + ); + } + + const unknownKeys = Object.keys(rawConfig).filter( + (key) => !WORKSPACE_CONFIG_KEYS.has(key), + ); + if (unknownKeys.length > 0) { + const lines = unknownKeys.map((key) => { + const suggestion = suggestKey(key); + return suggestion + ? ` ${key} — did you mean ${suggestion}?` + : ` ${key}`; + }); + + warn( + `Warning: unrecognised key(s) in ${filePath} — these are ignored:\n` + + `${lines.join('\n')}\n\n` + + `Supported keys: ${[...WORKSPACE_CONFIG_KEYS].join(', ')}`, + ); + } + + const result = WorkspaceConfigSchema.safeParse(rawConfig); + if (!result.success) { + const issues = result.error.issues + .map((issue) => { + const location = issue.path.length > 0 ? issue.path.join('.') : '(root)'; + return ` ${location}: ${issue.message}`; + }) + .join('\n'); + + throw new Error(`Invalid workspace config in ${filePath}\n\n${issues}`); + } + + return result.data; +} diff --git a/src/types.ts b/src/types.ts index ff898d3..69f4a35 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,12 @@ +import type { BinaryEnvelope } from './utils/envelope.js'; + export type TAppMetadata = { appId: string; platform: 'android' | 'ios'; + /** + * Present when the binary was client-side envelope-encrypted before upload + * (dcd#1138). Stored on `binaries.metadata.enc`; the platform reads it to + * release the DEK and decrypt. Absent = legacy plaintext upload. + */ + enc?: BinaryEnvelope; }; diff --git a/src/types/domain/device.types.ts b/src/types/domain/device.types.ts index 064c23a..ba2f0fa 100644 --- a/src/types/domain/device.types.ts +++ b/src/types/domain/device.types.ts @@ -21,12 +21,18 @@ export enum EAndroidDevices { 'pixel-6-pro' = 'pixel-6-pro', 'pixel-7' = 'pixel-7', 'pixel-7-pro' = 'pixel-7-pro', + 'pixel-8' = 'pixel-8', + 'pixel-10' = 'pixel-10', + 'pixel-10-pro' = 'pixel-10-pro', + 'pixel-10-pro-xl' = 'pixel-10-pro-xl', + 'pixel-10-pro-fold' = 'pixel-10-pro-fold', + 'pixel-11' = 'pixel-11', } export enum EiOSVersions { 'eighteen' = '18', 'seventeen' = '17', - 'sixteen' = '16', + 'twentySeven' = '27', 'twentySix' = '26', } @@ -35,8 +41,23 @@ export enum EAndroidApiLevels { 'thirtyFive' = '35', 'thirtyFour' = '34', 'thirtyOne' = '31', + 'thirtySeven' = '37', 'thirtySix' = '36', 'thirtyThree' = '33', 'thirtyTwo' = '32', 'twentyNine' = '29', } + +/** + * One explicit device-matrix cell. iOS entries carry {iOSDevice, iOSVersion}; + * Android entries carry {androidDevice, androidApiLevel} plus an optional Play + * channel. Sent to the API as the `deviceMatrix` array; every non-targeted flow + * runs once per cell. There is no cross-product — each entry is one cell. + */ +export type DeviceMatrixConfig = + | { iOSDevice: string; iOSVersion: string } + | { androidApiLevel: string; androidDevice: string; googlePlay?: boolean }; + +export const isIosMatrixConfig = ( + c: DeviceMatrixConfig, +): c is { iOSDevice: string; iOSVersion: string } => 'iOSDevice' in c; diff --git a/src/types/generated/schema.types.ts b/src/types/generated/schema.types.ts index f1bcbc0..29897ea 100644 --- a/src/types/generated/schema.types.ts +++ b/src/types/generated/schema.types.ts @@ -70,6 +70,22 @@ export interface paths { patch?: never; trace?: never; }; + "/uploads/getFlowUploadUrl": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["UploadsController_getFlowUploadUrl"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/uploads/flow": { parameters: { query?: never; @@ -86,6 +102,56 @@ export interface paths { patch?: never; trace?: never; }; + "/uploads/submitFlowTest": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Client-direct flow submission (JSON). The flow zip has already been + * uploaded straight to storage via getFlowUploadUrl, so nothing is buffered + * in API memory here — we just create the uploads row from the stored + * reference and run the shared submission flow. + */ + post: operations["UploadsController_submitFlowTest"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/uploads/estimateMatrix": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Dry-run cost + cell-count estimate for a (possibly device-matrix) + * submission. Runs the exact same resolve → validate → fan-out → price core as + * the submit path (so the quote equals the charge) but never persists and + * never touches credits. Lets the CLI print the cell count and estimated cost, + * and surface validation errors, before uploading the flow ZIP. + * + * The dollar estimate is exact for every cell, Google Play included: quote + * and charge both run buildBaseCostByTuple -> calculateCost -> the shared + * resolvePricingTier over the same typed device, and so do the parallel and + * sequential submit paths (#1100). + */ + post: operations["UploadsController_estimateMatrix"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/uploads/retryTest": { parameters: { query?: never; @@ -166,23 +232,23 @@ export interface paths { patch?: never; trace?: never; }; - "/results/{uploadId}": { + "/org/paddle-webhook": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["ResultsController_getResults"]; + get?: never; put?: never; - post?: never; + post: operations["OrgController_handlePaddleWebhook"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/results/{uploadId}/download": { + "/org/update-name": { parameters: { query?: never; header?: never; @@ -191,14 +257,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["ResultsController_getTestRunArtifacts"]; + post: operations["OrgController_updateOrgName"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/results/notify/{uploadId}": { + "/org/invite-team-member": { parameters: { query?: never; header?: never; @@ -207,114 +273,110 @@ export interface paths { }; get?: never; put?: never; - post: operations["ResultsController_notifyTestRunComplete"]; + post: operations["OrgController_inviteTeamMember"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/results/{uploadId}/report": { + "/org/accept-invite": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["ResultsController_downloadReport"]; + get?: never; put?: never; - post?: never; + post: operations["OrgController_acceptInvite"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/results/{uploadId}/html-report": { + "/org/revoke-invite": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["ResultsController_downloadHtmlReport"]; + get?: never; put?: never; - post?: never; + post: operations["OrgController_revokeInvite"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/results/{resultId}/html-report-single": { + "/org/change-team-member-role": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["ResultsController_downloadSingleHtmlReport"]; + get?: never; put?: never; - post?: never; + post: operations["OrgController_changeTeamMemberRole"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/results/compatibility/data": { + "/org/remove-team-member": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["ResultsController_getCompatibilityData"]; + get?: never; put?: never; - post?: never; + post: operations["OrgController_removeTeamMember"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/allure/{uploadId}/download": { + "/org/leave-team": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** - * Download Allure report as HTML - * @description Downloads a single-file Allure report as HTML containing all test results. Report is generated once and stored in Supabase Storage for subsequent downloads. - */ - get: operations["AllureController_downloadAllureReport"]; + get?: never; put?: never; - post?: never; + post: operations["OrgController_leaveTeam"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/webhooks": { + "/org/delete-team": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["WebhooksController_getWebhook"]; + get?: never; put?: never; - post: operations["WebhooksController_setWebhook"]; - delete: operations["WebhooksController_deleteWebhook"]; + post: operations["OrgController_deleteTeam"]; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/webhooks/regenerate-secret": { + "/org/subscriptions": { parameters: { query?: never; header?: never; @@ -323,14 +385,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["WebhooksController_regenerateWebhookSecret"]; + post: operations["OrgController_getAllSubscriptions"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/webhooks/test": { + "/org/update-overage-limit": { parameters: { query?: never; header?: never; @@ -339,14 +401,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["WebhooksController_testWebhook"]; + post: operations["OrgController_updateOverageLimit"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/org/paddle-webhook": { + "/org/usage-history": { parameters: { query?: never; header?: never; @@ -355,14 +417,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["OrgController_handlePaddleWebhook"]; + post: operations["OrgController_getUsageHistory"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/org/update-name": { + "/org/update-retry-escalation": { parameters: { query?: never; header?: never; @@ -371,14 +433,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["OrgController_updateOrgName"]; + post: operations["OrgController_updateRetryEscalation"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/org/invite-team-member": { + "/org/update-billing-email": { parameters: { query?: never; header?: never; @@ -387,14 +449,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["OrgController_inviteTeamMember"]; + post: operations["OrgController_updateBillingEmail"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/org/accept-invite": { + "/org/invoices": { parameters: { query?: never; header?: never; @@ -403,14 +465,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["OrgController_acceptInvite"]; + post: operations["OrgController_getInvoices"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/org/subscriptions": { + "/org/invoice-url": { parameters: { query?: never; header?: never; @@ -419,14 +481,14 @@ export interface paths { }; get?: never; put?: never; - post: operations["OrgController_getAllSubscriptions"]; + post: operations["OrgController_getInvoiceUrl"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/org/update-overage-limit": { + "/org/bill-daily-overages": { parameters: { query?: never; header?: never; @@ -435,30 +497,30 @@ export interface paths { }; get?: never; put?: never; - post: operations["OrgController_updateOverageLimit"]; + post: operations["OrgController_billDailyOverages"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/org/usage-history": { + "/results/{uploadId}": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + get: operations["ResultsController_getResults"]; put?: never; - post: operations["OrgController_getUsageHistory"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/frontend/check-domain-saml": { + "/results/{uploadId}/download": { parameters: { query?: never; header?: never; @@ -467,37 +529,37 @@ export interface paths { }; get?: never; put?: never; - post: operations["FrontendController_checkDomainSaml"]; + post: operations["ResultsController_getTestRunArtifacts"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/frontend/validate-email": { + "/results/{uploadId}/report": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + get: operations["ResultsController_downloadReport"]; put?: never; - post: operations["FrontendController_validateEmail"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/health": { + "/results/{uploadId}/html-report": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["HealthController_health"]; + get: operations["ResultsController_downloadHtmlReport"]; put?: never; post?: never; delete?: never; @@ -506,30 +568,36 @@ export interface paths { patch?: never; trace?: never; }; - "/billing/create-subscription": { + "/results/{resultId}/html-report-single": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + get: operations["ResultsController_downloadSingleHtmlReport"]; put?: never; - post: operations["BillingController_createSubscription"]; + post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/stats/marketing": { + "/results/{uploadId}/artifacts-bundle": { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations["StatsController_getMarketingStats"]; + /** + * CDN-Worker bundle manifests (dcd#1137). These return a signed manifest the + * client POSTs to the Worker, which streams the ZIP from B2 — the artifact + * bytes bypass the API. A `501` means the CDN Worker isn't configured on this + * deployment; the client falls back to the inline download endpoints. + */ + get: operations["ResultsController_getArtifactsBundleManifest"]; put?: never; post?: never; delete?: never; @@ -538,458 +606,3449 @@ export interface paths { patch?: never; trace?: never; }; -} -export type webhooks = Record; -export interface components { - schemas: { - IDBResult: { - binary_upload_id: string; - cost: number | null; - created_at: string; - env: Record; - id: number; - org_id: number; - platform: string; - simulator_name: string; - status: string; - test_file_name: string; - test_upload_id: string; - }; - IGetBinaryUploadUrlArgs: { - /** - * @description Platform for the binary upload (ios or android) - * @enum {string} - */ - platform: "ios" | "android"; - /** @description File size in bytes (optional, for Backblaze upload strategy) */ - fileSize?: number; - /** @description Whether client uses TUS resumable uploads (true for new clients, undefined/false for legacy) */ - useTus?: boolean; + "/results/{uploadId}/report-bundle": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - B2SimpleUpload: { - uploadUrl: string; - authorizationToken: string; + get: operations["ResultsController_getReportBundleManifest"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/results/{resultId}/report-bundle-single": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - B2UploadPartUrl: { - uploadUrl: string; - authorizationToken: string; + get: operations["ResultsController_getSingleReportBundleManifest"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/results/compatibility/data": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - B2LargeUpload: { - fileId: string; - fileName: string; - uploadPartUrls: components["schemas"]["B2UploadPartUrl"][]; + get: operations["ResultsController_getCompatibilityData"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/allure/{uploadId}/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - B2UploadStrategy: { - /** @enum {string} */ - strategy: "simple" | "large"; - simple?: components["schemas"]["B2SimpleUpload"]; - large?: components["schemas"]["B2LargeUpload"]; + /** + * Download Allure report as HTML + * @description Downloads a single-file Allure report as HTML containing all test results. Report is generated once and stored in Supabase Storage for subsequent downloads. + */ + get: operations["AllureController_downloadAllureReport"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/webhooks": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - IGetBinaryUploadUrlResponse: { - /** @description Temporary upload path in uploads/ folder for TUS upload */ - path: string; - /** @description Temporary upload path (same as path) */ - tempPath: string; - /** @description Final path where file will be moved after upload completes */ - finalPath: string; - /** @description Upload ID */ - id: string; - /** @description Backblaze upload strategy if configured */ + get: operations["WebhooksController_getWebhook"]; + put?: never; + post: operations["WebhooksController_setWebhook"]; + delete: operations["WebhooksController_deleteWebhook"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/webhooks/notify-on-retry": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Toggle re-delivery when a retry finishes on an already-reported run (#1277). + * + * Defaults to on, unlike the Slack and email equivalents: this endpoint exists + * for the consumer that would rather NOT be called twice, while automations + * that want the current state keep working untouched. + */ + post: operations["WebhooksController_setNotifyOnRetry"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/webhooks/regenerate-secret": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["WebhooksController_regenerateWebhookSecret"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/webhooks/test": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["WebhooksController_testWebhook"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/slack/oauth/start": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SlackController_oauthStart"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/slack": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SlackController_getConnection"]; + put?: never; + post?: never; + delete: operations["SlackController_disconnect"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/slack/channels": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["SlackController_getChannels"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/slack/config": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["SlackController_setConfig"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/slack/test": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["SlackController_test"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/github/oauth/start": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["GithubController_oauthStart"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/github": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["GithubController_getConnection"]; + put?: never; + post?: never; + delete: operations["GithubController_disconnect"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/notices": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Active notify notices (deprecation/warn/info/marketing) for the calling + * client. Block notices are never returned here — they are enforced + * server-side at submit time. The consumer applies any `match` gating it alone + * can evaluate (e.g. the selected device version). + */ + get: operations["NoticesController_getNotices"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api-keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["ApiKeysController_list"]; + put?: never; + post: operations["ApiKeysController_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api-keys/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["ApiKeysController_revoke"]; + options?: never; + head?: never; + patch: operations["ApiKeysController_update"]; + trace?: never; + }; + "/api-keys/{id}/rotate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["ApiKeysController_rotate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/ip-addresses": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["NetworkController_getIpAddresses"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/frontend/check-domain-saml": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["FrontendController_checkDomainSaml"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/frontend/validate-email": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["FrontendController_validateEmail"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/frontend/binary-download-url": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["FrontendController_getBinaryDownloadUrl"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/frontend/binaries/{binaryId}/download": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["FrontendController_downloadBinary"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/frontend/artifact-download-url": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["FrontendController_getArtifactDownloadUrl"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/frontend/result-detail/{resultId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["FrontendController_getResultDetail"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/frontend/logs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["FrontendController_ingestLogs"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/frontend/logs/anon": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["FrontendController_ingestLogsAnonymous"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["HealthController_health"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/billing/create-subscription": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["BillingController_createSubscription"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/billing/update-subscription": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["BillingController_updateSubscription"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/stats/marketing": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["StatsController_getMarketingStats"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/flows": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["FlowsController_getFlows"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/flows/runs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["FlowsController_getFlowRuns"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/live": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["LiveController_createSession"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/live/{identifier}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["LiveController_getSession"]; + put?: never; + post?: never; + delete: operations["LiveController_stopSession"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/live/{identifier}/exec": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["LiveController_execTest"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/live/{identifier}/commands/{commandId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["LiveController_getCommandStatus"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/live/{identifier}/keepalive": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["LiveController_keepalive"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/live/{identifier}/install": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["LiveController_installBinary"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/me/orgs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["MeController_listOrgs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/me/sessions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** The caller's active auth sessions (for the Settings "Active sessions" panel). */ + get: operations["MeController_listSessions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/me/sessions/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Revoke one of the caller's active auth sessions (per-row "Sign out" in the panel). JWT-authed; + * the service scopes the delete to the caller's own id, so you can only kill your own sessions. + * Throttled since it mutates auth state. + */ + delete: operations["MeController_revokeSession"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/me/personal-team": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Provision a personal team for the caller — backs the "Create a personal team" button on the + * no-teams empty state (a user who left/was removed from their last org). Throttled. + */ + post: operations["MeController_createPersonalTeam"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/me/team": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create an additional, named team for the caller — backs the "+ New team" org-switcher action. + * Always creates (vs /personal-team which is idempotent). Throttled. + */ + post: operations["MeController_createTeam"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/me/delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Irreversibly delete the caller's own account. JWT-authed; the body must echo the + * account email as a defence-in-depth confirmation (the UI also type-confirms). Throttled + * hard since it's destructive. + */ + post: operations["MeController_deleteAccount"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/cli-login/handoff": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["CliLoginController_handoff"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/cli-login/claim": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["CliLoginController_claim"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/cli/logs": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["CliLogsController_ingestLogs"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/email-change/start": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["EmailChangeController_start"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/email-change/verify-current": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["EmailChangeController_verifyCurrent"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/email-change/verify-new": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["EmailChangeController_verifyNew"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/email-change/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["EmailChangeController_cancel"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + IDBResult: { + binary_upload_id: string; + cost: number | null; + created_at: string; + env: Record; + id: number; + org_id: number; + platform: string; + simulator_name: string; + status: string; + test_file_name: string; + test_upload_id: string; + }; + IGetBinaryUploadUrlArgs: { + /** + * @description Platform for the binary upload (ios or android) + * @enum {string} + */ + platform: "ios" | "android"; + /** @description File size in bytes (optional, for Backblaze upload strategy) */ + fileSize?: number; + /** @description Whether client uses TUS resumable uploads (true for new clients, undefined/false for legacy) */ + useTus?: boolean; + }; + B2SimpleUpload: { + uploadUrl: string; + authorizationToken: string; + }; + B2UploadPartUrl: { + uploadUrl: string; + authorizationToken: string; + }; + B2LargeUpload: { + fileId: string; + fileName: string; + uploadPartUrls: components["schemas"]["B2UploadPartUrl"][]; + }; + B2UploadStrategy: { + /** @enum {string} */ + strategy: "simple" | "large"; + simple?: components["schemas"]["B2SimpleUpload"]; + large?: components["schemas"]["B2LargeUpload"]; + }; + IGetBinaryUploadUrlResponse: { + /** + * @description Temporary upload path in uploads/ folder for TUS upload + * @example uploads/123e4567-e89b-12d3-a456-426614174000/123e4567-e89b-12d3-a456-426614174000.apk + */ + path: string; + /** + * @description Temporary upload path (same as path) + * @example uploads/123e4567-e89b-12d3-a456-426614174000/123e4567-e89b-12d3-a456-426614174000.apk + */ + tempPath: string; + /** + * @description Final path where file will be moved after upload completes + * @example 1/binaries/android/123e4567-e89b-12d3-a456-426614174000.apk + */ + finalPath: string; + /** + * @description Upload ID + * @example 123e4567-e89b-12d3-a456-426614174000 + */ + id: string; + /** @description Backblaze upload strategy if configured */ b2?: components["schemas"]["B2UploadStrategy"]; /** @description Signed upload URL token for legacy clients (deprecated) */ token?: string; }; - ICheckForExistingUploadArgs: { - /** @description SHA-256 hash of the binary file */ - sha: string; + ICheckForExistingUploadArgs: { + /** @description SHA-256 hash of the binary file as uploaded. Required unless `encrypted` is true, in which case `shaPlain` is the lookup key (the ciphertext hash is not yet known at dedup time). */ + sha?: string; + /** @description SHA-256 hash of the PLAINTEXT binary. The lookup key when `encrypted` is true (#1168): encrypted uploads wrap under a fresh random DEK, so the ciphertext hash differs on every upload of identical input and cannot dedup. */ + shaPlain?: string; + /** + * @description Whether the caller intends to upload an encrypted binary. When true the lookup uses `shaPlain` and only ever matches rows that carry an encryption envelope, so an encrypting client can never be handed back a plaintext binary. + * @default false + */ + encrypted: boolean; + }; + ICheckForExistingUploadResponse: { + appBinaryId: string; + exists: boolean; + /** + * @description Whether the matched binary is stored encrypted (#1168). Lets an encrypting + * client assert the invariant it cares about client-side instead of trusting + * the server to have applied the right predicate — so an older or misbehaving + * deployment cannot quietly hand it a plaintext binary. + */ + encrypted?: boolean; + }; + IFinaliseUploadArgs: { + /** @description Unique upload identifier */ + id: string; + /** @description Storage path for the uploaded file */ + path: string; + /** @description File metadata (bundle ID, package name, platform) - required for new clients */ + metadata?: Record; + /** @description SHA-256 hash of the file - required for new clients */ + sha?: string; + /** @description SHA-256 hash of the PLAINTEXT file. Sent only by clients uploading an encrypted binary (#1168), where `sha` is the ciphertext hash; persisted as binaries.sha_plain so later encrypted uploads of the same input can dedup. */ + shaPlain?: string; + /** + * @description Whether the Supabase upload was successful + * @default true + */ + supabaseSuccess: boolean; + /** + * @description Whether the Backblaze upload was successful + * @default false + */ + backblazeSuccess: boolean; + /** @description Whether client uses TUS resumable uploads (true for new clients, undefined/false for legacy) */ + useTus?: boolean; + /** @description File size in bytes */ + bytes?: number; + }; + IFinaliseUploadResponse: Record; + IFinishLargeFileArgs: { + /** + * @description The Backblaze file ID from the large file upload + * @example abc123xyz + */ + fileId: string; + /** + * @description Array of SHA1 hashes for each uploaded part + * @example [ + * "sha1hash1", + * "sha1hash2" + * ] + */ + partSha1Array: string[]; + }; + IFinishLargeFileResponse: { + success: boolean; + result: Record; + }; + IGetFlowUploadUrlArgs: { + /** @description File size in bytes (optional, for Backblaze upload strategy) */ + fileSize?: number; + /** @description Whether client uses TUS resumable uploads (true for new clients, undefined/false for legacy) */ + useTus?: boolean; + }; + ICreateTestUploadArgs: { + testFileNames?: string; + sequentialFlows?: string; + /** @enum {string} */ + androidApiLevel?: "29" | "30" | "31" | "32" | "33" | "34" | "35" | "36" | "37"; + /** @enum {string} */ + androidDevice?: "pixel-6" | "pixel-6-pro" | "pixel-7" | "pixel-7-pro" | "pixel-8" | "pixel-10" | "pixel-10-pro" | "pixel-10-pro-xl" | "pixel-10-pro-fold" | "pixel-11" | "generic-tablet"; + apiKey?: string; + apiUrl?: string; + appBinaryId: string; + appFile?: string; + env: string; + /** @enum {string} */ + iOSVersion?: "17" | "18" | "26" | "27"; + /** @enum {string} */ + iOSDevice?: "iphone-14" | "iphone-15" | "iphone-16" | "iphone-16-plus" | "iphone-16-pro" | "iphone-16-pro-max" | "ipad-pro-6th-gen"; + platform?: string; + googlePlay?: boolean; + config: string; + name?: string; + /** @enum {string} */ + runnerType?: "m4" | "m1" | "default" | "gpu1" | "cpu1"; + metadata?: string; + workspaceConfig?: string; + flowMetadata?: string; + testFileOverrides?: string; + /** @description JSON array of explicit device configs forming the upload device matrix. Every flow that does not name its own device runs once per entry. Each entry names exactly one validated cell and must match the binary platform — there is no cross-product expansion. iOS: {"iOSDevice":"iphone-16","iOSVersion":"18"}. Android: {"androidDevice":"pixel-7","androidApiLevel":"34","googlePlay":true}. Omit for single-device (legacy) behaviour. */ + deviceMatrix?: string; + /** @description SHA-256 hash of the flow ZIP file */ + sha?: string; + /** @description JSON-encoded envelope { v, kek, wrapped_key } when the flow ZIP was client-side encrypted (#1151). Stored as uploads.metadata.enc; the flow zip carries its own per-upload DEK, distinct from the binary. */ + enc?: string; + /** @description Size of the flow ZIP file in bytes */ + bytes?: number; + /** + * Format: binary + * @description This file must be a zip file + */ + file: string; + }; + ISubmitFlowTestArgs: { + testFileNames?: string; + sequentialFlows?: string; + /** @enum {string} */ + androidApiLevel?: "29" | "30" | "31" | "32" | "33" | "34" | "35" | "36" | "37"; + /** @enum {string} */ + androidDevice?: "pixel-6" | "pixel-6-pro" | "pixel-7" | "pixel-7-pro" | "pixel-8" | "pixel-10" | "pixel-10-pro" | "pixel-10-pro-xl" | "pixel-10-pro-fold" | "pixel-11" | "generic-tablet"; + apiKey?: string; + apiUrl?: string; + appBinaryId: string; + appFile?: string; + env: string; + /** @enum {string} */ + iOSVersion?: "17" | "18" | "26" | "27"; + /** @enum {string} */ + iOSDevice?: "iphone-14" | "iphone-15" | "iphone-16" | "iphone-16-plus" | "iphone-16-pro" | "iphone-16-pro-max" | "ipad-pro-6th-gen"; + platform?: string; + googlePlay?: boolean; + config: string; + name?: string; + /** @enum {string} */ + runnerType?: "m4" | "m1" | "default" | "gpu1" | "cpu1"; + metadata?: string; + workspaceConfig?: string; + flowMetadata?: string; + testFileOverrides?: string; + /** @description JSON array of explicit device configs forming the upload device matrix. Every flow that does not name its own device runs once per entry. Each entry names exactly one validated cell and must match the binary platform — there is no cross-product expansion. iOS: {"iOSDevice":"iphone-16","iOSVersion":"18"}. Android: {"androidDevice":"pixel-7","androidApiLevel":"34","googlePlay":true}. Omit for single-device (legacy) behaviour. */ + deviceMatrix?: string; + /** @description SHA-256 hash of the flow ZIP file */ + sha?: string; + /** @description JSON-encoded envelope { v, kek, wrapped_key } when the flow ZIP was client-side encrypted (#1151). Stored as uploads.metadata.enc; the flow zip carries its own per-upload DEK, distinct from the binary. */ + enc?: string; + /** @description Size of the flow ZIP file in bytes */ + bytes?: number; + /** @description Flow upload identifier returned by getFlowUploadUrl */ + id: string; + /** @description Storage path where the flow zip was uploaded (tempPath for TUS, finalPath for legacy signed URL) */ + path: string; + /** + * @description Whether the Supabase upload was successful + * @default true + */ + supabaseSuccess: boolean; + /** + * @description Whether the Backblaze upload was successful + * @default false + */ + backblazeSuccess: boolean; + /** @description Whether client uses TUS resumable uploads (true for new clients, undefined/false for legacy) */ + useTus?: boolean; + }; + IFlowTestParams: { + testFileNames?: string; + sequentialFlows?: string; + /** @enum {string} */ + androidApiLevel?: "29" | "30" | "31" | "32" | "33" | "34" | "35" | "36" | "37"; + /** @enum {string} */ + androidDevice?: "pixel-6" | "pixel-6-pro" | "pixel-7" | "pixel-7-pro" | "pixel-8" | "pixel-10" | "pixel-10-pro" | "pixel-10-pro-xl" | "pixel-10-pro-fold" | "pixel-11" | "generic-tablet"; + apiKey?: string; + apiUrl?: string; + appBinaryId: string; + appFile?: string; + env: string; + /** @enum {string} */ + iOSVersion?: "17" | "18" | "26" | "27"; + /** @enum {string} */ + iOSDevice?: "iphone-14" | "iphone-15" | "iphone-16" | "iphone-16-plus" | "iphone-16-pro" | "iphone-16-pro-max" | "ipad-pro-6th-gen"; + platform?: string; + googlePlay?: boolean; + config: string; + name?: string; + /** @enum {string} */ + runnerType?: "m4" | "m1" | "default" | "gpu1" | "cpu1"; + metadata?: string; + workspaceConfig?: string; + flowMetadata?: string; + testFileOverrides?: string; + /** @description JSON array of explicit device configs forming the upload device matrix. Every flow that does not name its own device runs once per entry. Each entry names exactly one validated cell and must match the binary platform — there is no cross-product expansion. iOS: {"iOSDevice":"iphone-16","iOSVersion":"18"}. Android: {"androidDevice":"pixel-7","androidApiLevel":"34","googlePlay":true}. Omit for single-device (legacy) behaviour. */ + deviceMatrix?: string; + /** @description SHA-256 hash of the flow ZIP file */ + sha?: string; + /** @description JSON-encoded envelope { v, kek, wrapped_key } when the flow ZIP was client-side encrypted (#1151). Stored as uploads.metadata.enc; the flow zip carries its own per-upload DEK, distinct from the binary. */ + enc?: string; + /** @description Size of the flow ZIP file in bytes */ + bytes?: number; + }; + IRetryTestArgs: { + /** @description ID of a specific result to retry. Either resultId or uploadId must be provided, but not both. */ + resultId?: number; + /** @description ID of an upload to retry all failed tests for. Either resultId or uploadId must be provided, but not both. */ + uploadId?: string; + }; + ICancelTestArgs: { + /** @description ID of a specific result to cancel. Either resultId or uploadId must be provided, but not both. */ + resultId?: number; + /** @description ID of an upload to cancel all pending results for. Either resultId or uploadId must be provided, but not both. */ + uploadId?: string; + }; + UpdateOrgNameDto: { + /** + * @description Organization ID + * @example 123 + */ + orgId: number; + /** + * @description Organization name + * @example Acme Corporation + */ + name: string; + }; + InviteTeamMemberDto: { + /** + * @description Email address to invite + * @example teammate@example.com + */ + inviteEmail: string; + /** + * @description Invite acceptance link shown in the email + * @example https://app.devicecloud.dev/login?invite_email=... + */ + link: string; + /** + * @description Organization ID + * @example 1 + */ + orgId: string; + /** @description Organization name shown in the email */ + orgName: string; + }; + AcceptInviteDto: { + /** + * @description Organization ID + * @example 1 + */ + orgId: string; + /** + * @description User email address + * @example user@example.com + */ + email: string; + }; + RevokeInviteDto: { + /** + * @description Organization ID + * @example 1 + */ + orgId: string; + /** + * @description Email of the pending invite to revoke + * @example teammate@example.com + */ + email: string; + }; + ChangeRoleDto: { + /** + * @description Organization ID + * @example 1 + */ + orgId: string; + /** + * @description Email of the team member whose role is being changed + * @example teammate@example.com + */ + email: string; + /** + * @description New role to assign + * @example admin + * @enum {string} + */ + newRole: "admin" | "standard" | "owner"; + }; + RemoveMemberDto: { + /** + * @description Organization ID + * @example 1 + */ + orgId: string; + /** + * @description Email of the team member to remove + * @example teammate@example.com + */ + email: string; + }; + LeaveTeamDto: { + /** + * @description Organization ID to leave + * @example 1 + */ + orgId: string; + }; + DeleteTeamDto: { + /** + * @description Organization ID to delete + * @example 1 + */ + orgId: string; + }; + TResultResponse: { + id: number; + test_file_name: string; + status: string; + retry_of?: number; + fail_reason?: string; + duration_seconds?: number; + simulator_name?: string; + config?: Record; + }; + TFlowSummaryResponse: { + flow_name: string; + file_name: string; + last_run_at: string; + total_runs: number; + passed_runs: number; + failed_runs: number; + pass_rate: number; + avg_duration: number; + daily_data: Record; + tags: string[]; + }; + TFlowRunItem: { + id: number; + status: string; + createdAt: string; + durationSeconds: number | null; + failReason: string | null; + testUploadId: string; + uploadName: string; + }; + HandoffDto: { + /** @description Opaque state token minted by the CLI. */ + state: string; + /** @description base64url(sha256(code_verifier)) — the PKCE S256 challenge. */ + code_challenge: string; + /** @description The browser session's Supabase access token (JWT), used only to verify the user's identity. */ + access_token: string; + /** @description The browser session's refresh token. Sent only for SAML SSO sessions, which cannot be minted a dedicated CLI session server-side. */ + refresh_token?: string; + }; + ClaimDto: { + /** @description Opaque state token minted by the CLI. */ + state: string; + /** @description base64url(random bytes) — the PKCE verifier. */ + code_verifier: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + UploadsController_getBinaryUploadUrl: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["IGetBinaryUploadUrlArgs"]; + }; + }; + responses: { + /** @description The url has been successfully created. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IGetBinaryUploadUrlResponse"]; + }; + }; + }; + }; + UploadsController_checkForExistingUpload: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ICheckForExistingUploadArgs"]; + }; + }; + responses: { + /** @description The url has been successfully created. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ICheckForExistingUploadResponse"]; + }; + }; + }; + }; + UploadsController_finaliseUpload: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["IFinaliseUploadArgs"]; + }; + }; + responses: { + /** @description The upload has been completed. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IFinaliseUploadResponse"]; + }; + }; + }; + }; + UploadsController_finishLargeFile: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["IFinishLargeFileArgs"]; + }; + }; + responses: { + /** @description The large file upload has been completed. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IFinishLargeFileResponse"]; + }; + }; + }; + }; + UploadsController_getFlowUploadUrl: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["IGetFlowUploadUrlArgs"]; + }; + }; + responses: { + /** @description The url has been successfully created. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IGetBinaryUploadUrlResponse"]; + }; + }; + }; + }; + UploadsController_createTest: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["ICreateTestUploadArgs"]; + }; + }; + responses: { + /** @description The record has been successfully created. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message?: string; + results?: components["schemas"]["IDBResult"][]; + }; + }; + }; + }; + }; + UploadsController_submitFlowTest: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ISubmitFlowTestArgs"]; + }; + }; + responses: { + /** @description The record has been successfully created. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message?: string; + results?: components["schemas"]["IDBResult"][]; + }; + }; + }; + }; + }; + UploadsController_estimateMatrix: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["IFlowTestParams"]; + }; + }; + responses: { + /** @description Estimated cell count and cost for a device-matrix submission. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + cellCount?: number; + totalCost?: number; + excludedFlows?: string[]; + columns?: { + deviceName?: string; + osVersion?: string; + googlePlay?: boolean; + flowCount?: number; + cost?: number; + }[]; + }; + }; + }; + }; + }; + UploadsController_retryTest: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["IRetryTestArgs"]; + }; + }; + responses: { + /** @description Retry started. Provide resultId to retry a single test (returns the new result id), or uploadId to retry all failed tests in an upload (returns retriedCount). */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message?: string; + id?: number; + success?: boolean; + retriedCount?: number; + }; + }; + }; + }; + }; + UploadsController_cancelTest: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ICancelTestArgs"]; + }; + }; + responses: { + /** @description The record has been successfully cancelled. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message?: string; + success?: boolean; + cancelledCount?: number; + }; + }; + }; + }; + }; + UploadsController_getUploadStatus: { + parameters: { + query?: { + /** @description Upload ID to get status for */ + uploadId?: string; + /** @description Upload name to get status for */ + name?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Upload status */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "uploadId": "upload-123", + * "status": "PENDING", + * "tests": [ + * { + * "id": 1, + * "test_file_name": "test-flow.yaml", + * "status": "PENDING" + * } + * ] + * } + */ + "application/json": Record; + }; + }; + }; + }; + UploadsController_listUploads: { + parameters: { + query?: { + /** @description Filter by upload name (supports * wildcard) */ + name?: string; + /** @description Filter uploads created on or after this date (ISO 8601) */ + from?: string; + /** @description Filter uploads created on or before this date (ISO 8601) */ + to?: string; + /** @description Maximum number of uploads to return (default: 20) */ + limit?: number; + /** @description Number of uploads to skip (default: 0) */ + offset?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of flow uploads. Use GET /uploads/status for detailed test results. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "uploads": [ + * { + * "id": "upload-123", + * "name": "Test Upload", + * "created_at": "2024-01-01T00:00:00Z", + * "consoleUrl": "https://console.devicecloud.dev/results/upload-123" + * } + * ], + * "total": 1, + * "limit": 20, + * "offset": 0 + * } + */ + "application/json": { + uploads?: { + id?: string; + name?: string | null; + created_at?: string; + consoleUrl?: string; + }[]; + total?: number; + limit?: number; + offset?: number; + }; + }; + }; + }; + }; + UploadsController_deleteUpload: { + parameters: { + query?: never; + header?: never; + path: { + uploadId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The upload has been successfully deleted. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + success?: boolean; + message?: string; + }; + }; + }; + }; + }; + OrgController_handlePaddleWebhook: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Paddle webhook handler. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; + }; + }; + OrgController_updateOrgName: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateOrgNameDto"]; + }; + }; + responses: { + /** @description Organization name updated successfully. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": boolean; + }; + }; + }; + }; + OrgController_inviteTeamMember: { + parameters: { + query?: never; + header: { + authorization: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["InviteTeamMemberDto"]; + }; + }; + responses: { + /** @description Team member invited successfully. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": boolean; + }; + }; + }; + }; + OrgController_acceptInvite: { + parameters: { + query?: never; + header: { + authorization: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AcceptInviteDto"]; + }; + }; + responses: { + /** @description Team invite accepted successfully. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": boolean; + }; + }; + }; + }; + OrgController_revokeInvite: { + parameters: { + query?: never; + header: { + authorization: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RevokeInviteDto"]; + }; + }; + responses: { + /** @description Team invite revoked successfully. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": boolean; + }; + }; + }; + }; + OrgController_changeTeamMemberRole: { + parameters: { + query?: never; + header: { + authorization: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChangeRoleDto"]; + }; + }; + responses: { + /** @description Team member role updated successfully. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": boolean; + }; + }; + }; + }; + OrgController_removeTeamMember: { + parameters: { + query?: never; + header: { + authorization: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RemoveMemberDto"]; + }; + }; + responses: { + /** @description Team member removed successfully. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": boolean; + }; + }; + }; + }; + OrgController_leaveTeam: { + parameters: { + query?: never; + header: { + authorization: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LeaveTeamDto"]; + }; + }; + responses: { + /** @description Left the team successfully. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": boolean; + }; + }; + }; + }; + OrgController_deleteTeam: { + parameters: { + query?: never; + header: { + authorization: string; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DeleteTeamDto"]; + }; + }; + responses: { + /** @description Team deleted successfully. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": boolean; + }; + }; + }; + }; + OrgController_getAllSubscriptions: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + orgId: string; + }; + }; + }; + responses: { + /** @description All subscription data fetched successfully. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + }; + }; + OrgController_updateOverageLimit: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + orgId: string; + overageLimit: number; + }; + }; + }; + responses: { + /** @description Overage limit updated successfully. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + }; + }; + OrgController_getUsageHistory: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + orgId: string; + /** @enum {string} */ + format?: "json" | "csv"; + /** Format: date-time */ + startDate?: string; + /** Format: date-time */ + endDate?: string; + }; + }; + }; + responses: { + /** @description Usage history fetched successfully. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown[]; + }; + }; + }; + }; + OrgController_updateRetryEscalation: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + orgId: number; + enabled: boolean; + }; + }; + }; + responses: { + /** @description Retry escalation setting updated successfully. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + }; + }; + OrgController_updateBillingEmail: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + orgId: number; + billingEmail: string; + }; + }; + }; + responses: { + /** @description Billing email updated successfully. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + }; + }; + OrgController_getInvoices: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + orgId: number; + }; + }; + }; + responses: { + /** @description List of Paddle invoices for the organization. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + }; + }; + OrgController_getInvoiceUrl: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + orgId: number; + transactionId: string; + }; + }; + }; + responses: { + /** @description Hosted PDF URL for a single invoice. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + }; + }; + OrgController_billDailyOverages: { + parameters: { + query?: never; + header: { + "x-cron-secret": string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Daily overage billing processed. */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + }; + }; + ResultsController_getResults: { + parameters: { + query?: never; + header?: never; + path: { + uploadId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The record has been successfully created. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + statusCode?: number; + results?: components["schemas"]["TResultResponse"][]; + }; + }; + }; + }; + }; + ResultsController_getTestRunArtifacts: { + parameters: { + query?: never; + header?: never; + path: { + uploadId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + ResultsController_downloadReport: { + parameters: { + query?: never; + header?: never; + path: { + uploadId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Download combined JUNIT test report (report.xml) for the upload */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; + }; + }; + ResultsController_downloadHtmlReport: { + parameters: { + query?: never; + header?: never; + path: { + uploadId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Download combined HTML test report with assets (report.zip) for the upload */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; + }; + }; + ResultsController_downloadSingleHtmlReport: { + parameters: { + query?: never; + header?: never; + path: { + resultId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Download HTML test report with assets (report.zip) for a single result */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; + }; + }; + ResultsController_getArtifactsBundleManifest: { + parameters: { + query: { + results: string; + }; + header?: never; + path: { + uploadId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + ResultsController_getReportBundleManifest: { + parameters: { + query?: never; + header?: never; + path: { + uploadId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + ResultsController_getSingleReportBundleManifest: { + parameters: { + query?: never; + header?: never; + path: { + resultId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + ResultsController_getCompatibilityData: { + parameters: { + query?: never; + header: { + "x-dcd-cli-version": string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Device compatibility lookup data including Maestro versions */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "statusCode": 200, + * "data": { + * "ios": { + * "iphone-14": { + * "name": "iPhone 14", + * "versions": [ + * "17", + * "18" + * ], + * "deprecated": false + * }, + * "iphone-15": { + * "name": "iPhone 15", + * "versions": [ + * "17" + * ], + * "deprecated": false + * }, + * "iphone-16": { + * "name": "iPhone 16", + * "versions": [ + * "18", + * "26" + * ], + * "deprecated": false + * }, + * "iphone-16-plus": { + * "name": "iPhone 16 Plus", + * "versions": [ + * "26" + * ], + * "deprecated": false + * }, + * "iphone-16-pro": { + * "name": "iPhone 16 Pro", + * "versions": [ + * "18", + * "26" + * ], + * "deprecated": false + * }, + * "iphone-16-pro-max": { + * "name": "iPhone 16 Pro Max", + * "versions": [ + * "18", + * "26" + * ], + * "deprecated": false + * }, + * "ipad-pro-6th-gen": { + * "name": "iPad Pro (6th gen)", + * "versions": [ + * "18", + * "26" + * ], + * "deprecated": false + * } + * }, + * "android": { + * "pixel-6": { + * "name": "Pixel 6", + * "apiLevels": [ + * "29", + * "30", + * "31", + * "32", + * "33", + * "34", + * "35", + * "36", + * "37" + * ], + * "deprecated": false + * }, + * "pixel-6-pro": { + * "name": "Pixel 6 Pro", + * "apiLevels": [ + * "33", + * "35" + * ], + * "deprecated": false + * }, + * "pixel-7": { + * "name": "Pixel 7", + * "apiLevels": [ + * "33", + * "34", + * "35", + * "36", + * "37" + * ], + * "deprecated": false + * }, + * "pixel-7-pro": { + * "name": "Pixel 7 Pro", + * "apiLevels": [ + * "33", + * "34", + * "35", + * "36", + * "37" + * ], + * "deprecated": false + * }, + * "pixel-8": { + * "name": "Pixel 8", + * "apiLevels": [ + * "34", + * "35", + * "36", + * "37" + * ], + * "deprecated": false + * }, + * "pixel-10": { + * "name": "Pixel 10", + * "apiLevels": [ + * "36", + * "37" + * ], + * "deprecated": false + * }, + * "pixel-10-pro": { + * "name": "Pixel 10 Pro", + * "apiLevels": [ + * "36", + * "37" + * ], + * "deprecated": false + * }, + * "pixel-10-pro-xl": { + * "name": "Pixel 10 Pro XL", + * "apiLevels": [ + * "36", + * "37" + * ], + * "deprecated": false + * }, + * "pixel-10-pro-fold": { + * "name": "Pixel 10 Pro Fold", + * "apiLevels": [ + * "36", + * "37" + * ], + * "deprecated": false + * }, + * "pixel-11": { + * "name": "Pixel 11", + * "apiLevels": [ + * "37" + * ], + * "deprecated": false + * }, + * "generic-tablet": { + * "name": "Generic Tablet", + * "apiLevels": [ + * "33", + * "36", + * "37" + * ], + * "deprecated": false + * } + * }, + * "androidPlay": { + * "pixel-7": { + * "name": "Pixel 7 (Google Play)", + * "apiLevels": [ + * "34" + * ], + * "deprecated": false + * } + * }, + * "maestro": { + * "supportedVersions": [ + * "2.0.4", + * "2.0.9", + * "2.1.0", + * "2.2.0", + * "2.5.0", + * "2.5.1", + * "2.6.0", + * "2.6.1", + * "2.7.0", + * "2.8.0", + * "2.9.0", + * "2.10.0" + * ], + * "defaultVersion": "2.2.0", + * "latestVersion": "2.10.0" + * } + * } + * } + */ + "application/json": { + statusCode?: number; + data?: { + ios?: Record; + android?: Record; + androidPlay?: Record; + maestro?: { + supportedVersions?: string[]; + defaultVersion?: string; + latestVersion?: string; + }; + }; + }; + }; + }; + }; + }; + AllureController_downloadAllureReport: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The upload ID to generate Allure report for */ + uploadId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Allure report HTML file download */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "text/html": string; + }; + }; + /** @description Upload not found or no results available */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + WebhooksController_getWebhook: { + parameters: { + query?: { + /** @description Set to true to return full secret instead of masked version */ + show_secret?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Current webhook configuration */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + webhook_url?: string; + /** @description Full secret (only when show_secret=true) */ + secret_key?: string; + /** @description Masked secret (default) */ + secret_key_masked?: string; + /** @description Re-deliver when retried tests finish (default true) */ + notify_on_retry?: boolean; + /** Format: date-time */ + created_at?: string; + /** Format: date-time */ + updated_at?: string; + }; + }; + }; + }; + }; + WebhooksController_setWebhook: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** + * Format: uri + * @example https://api.example.com/webhook + */ + url: string; + }; + }; + }; + responses: { + /** @description Webhook URL set successfully */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + }; + }; + WebhooksController_deleteWebhook: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Webhook configuration deleted successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + }; + }; + WebhooksController_setNotifyOnRetry: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + notifyOnRetry: boolean; + }; + }; + }; + responses: { + /** @description Retry re-delivery setting updated */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + }; + }; + WebhooksController_regenerateWebhookSecret: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Webhook secret regenerated successfully */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + }; + }; + WebhooksController_testWebhook: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** Format: uri */ + url?: string; + }; + }; + }; + responses: { + /** @description Test webhook sent successfully */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; + }; + }; + SlackController_oauthStart: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - ICheckForExistingUploadResponse: { - appBinaryId: string; - exists: boolean; + requestBody?: never; + responses: { + /** @description Slack authorize URL to redirect the user to */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; - IFinaliseUploadArgs: { - /** @description Unique upload identifier */ - id: string; - /** @description Storage path for the uploaded file */ - path: string; - /** @description File metadata (bundle ID, package name, platform) - required for new clients */ - metadata?: Record; - /** @description SHA-256 hash of the file - required for new clients */ - sha?: string; - /** - * @description Whether the Supabase upload was successful - * @default true - */ - supabaseSuccess: boolean; - /** - * @description Whether the Backblaze upload was successful - * @default false - */ - backblazeSuccess: boolean; - /** @description Whether client uses TUS resumable uploads (true for new clients, undefined/false for legacy) */ - useTus?: boolean; - /** @description File size in bytes */ - bytes?: number; + }; + SlackController_getConnection: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - IFinaliseUploadResponse: Record; - IFinishLargeFileArgs: { - /** - * @description The Backblaze file ID from the large file upload - * @example abc123xyz - */ - fileId: string; - /** - * @description Array of SHA1 hashes for each uploaded part - * @example [ - * "sha1hash1", - * "sha1hash2" - * ] - */ - partSha1Array: string[]; + requestBody?: never; + responses: { + /** @description Current Slack connection for the org (or null) */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; - IFinishLargeFileResponse: { - success: boolean; - result: Record; + }; + SlackController_disconnect: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - ICreateTestUploadArgs: { - /** - * Format: binary - * @description This file must be a zip file - */ - file: string; - testFileNames?: string; - sequentialFlows?: string; - /** @enum {string} */ - androidApiLevel?: "29" | "30" | "31" | "32" | "33" | "34" | "35" | "36"; - /** @enum {string} */ - androidDevice?: "pixel-6" | "pixel-6-pro" | "pixel-7" | "pixel-7-pro" | "generic-tablet"; - apiKey?: string; - apiUrl?: string; - appBinaryId: string; - appFile?: string; - env: string; - /** @enum {string} */ - iOSVersion?: "16" | "17" | "18" | "26"; - /** @enum {string} */ - iOSDevice?: "iphone-14" | "iphone-15" | "iphone-16" | "iphone-16-plus" | "iphone-16-pro" | "iphone-16-pro-max" | "ipad-pro-6th-gen"; - platform?: string; - googlePlay: boolean; - config: string; - name?: string; - /** @enum {string} */ - runnerType?: "m4" | "m1" | "default" | "gpu1" | "cpu1"; - metadata?: string; - workspaceConfig?: string; - flowMetadata?: string; - testFileOverrides?: string; - /** @description SHA-256 hash of the flow ZIP file */ - sha?: string; + requestBody?: never; + responses: { + /** @description Slack disconnected and connection removed */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; - IRetryTestArgs: { - resultId: number; + }; + SlackController_getChannels: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Channels the bot can see, for the picker */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SlackController_setConfig: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Slack channel / preferences updated */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + SlackController_test: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Test message sent to the configured channel */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + GithubController_oauthStart: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description GitHub App install URL to redirect the user to */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + GithubController_getConnection: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Current GitHub App connection for the org (or null) */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + GithubController_disconnect: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description GitHub connection removed for the org */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + NoticesController_getNotices: { + parameters: { + query: { + surface: string; + platform: string; + }; + header: { + "x-dcd-cli-version": string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Active notices for the calling client. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + statusCode?: number; + data?: { + id?: string; + slug?: string | null; + /** @enum {string} */ + level?: "deprecation" | "warn" | "info" | "marketing"; + title?: string; + body?: string; + learnMoreUrl?: string | null; + dismissible?: boolean; + match?: Record | null; + }[]; + }; + }; + }; + }; + }; + ApiKeysController_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List the org API keys (no secrets) */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; - ICancelTestArgs: { - /** @description ID of a specific result to cancel. Either resultId or uploadId must be provided, but not both. */ - resultId?: number; - /** @description ID of an upload to cancel all pending results for. Either resultId or uploadId must be provided, but not both. */ - uploadId?: string; + }; + ApiKeysController_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - TResultResponse: { - id: number; - test_file_name: string; - status: string; - retry_of?: number; - fail_reason?: string; - duration_seconds?: number; + requestBody?: never; + responses: { + /** @description Issue a new key; returns the raw key once */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record; + }; + }; }; - UpdateOrgNameDto: { - /** - * @description Organization ID - * @example 123 - */ - orgId: number; - /** - * @description Organization name - * @example Acme Corporation - */ - name: string; + }; + ApiKeysController_revoke: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; }; - AcceptInviteDto: { - /** - * @description Organization ID - * @example 1 - */ - orgId: string; - /** - * @description User email address - * @example user@example.com - */ - email: string; + requestBody?: never; + responses: { + /** @description Revoke (soft-delete) a key */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; -} -export type $defs = Record; -export interface operations { - UploadsController_getBinaryUploadUrl: { + ApiKeysController_update: { parameters: { query?: never; - header: { - "x-app-api-key": string; + header?: never; + path: { + id: number; }; - path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["IGetBinaryUploadUrlArgs"]; + requestBody?: never; + responses: { + /** @description Update a key name/description/expiry */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + ApiKeysController_rotate: { + parameters: { + query?: never; + header?: never; + path: { + id: number; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description The url has been successfully created. */ + /** @description Rotate a key; returns the new raw key once */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; 201: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["IGetBinaryUploadUrlResponse"]; + "application/json": Record; }; }; }; }; - UploadsController_checkForExistingUpload: { + NetworkController_getIpAddresses: { parameters: { query?: never; - header: { - "x-app-api-key": string; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Current DeviceCloud test-runner egress IP addresses. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "updatedAt": "2026-07-06", + * "ipAddresses": [ + * "46.17.215.144", + * "46.17.215.145", + * "83.217.174.249" + * ], + * "ranges": [ + * { + * "cidr": "46.17.215.144/32", + * "platforms": [ + * "android", + * "ios" + * ], + * "type": "egress", + * "description": "Test runner egress" + * } + * ] + * } + */ + "application/json": { + /** @example 2026-07-06 */ + updatedAt?: string; + /** + * @example [ + * "46.17.215.144", + * "46.17.215.145", + * "83.217.174.249" + * ] + */ + ipAddresses?: string[]; + ranges?: { + /** @example 46.17.215.144/32 */ + cidr?: string; + platforms?: ("android" | "ios")[]; + /** @enum {string} */ + type?: "egress"; + description?: string; + }[]; + }; + }; }; + }; + }; + FrontendController_checkDomainSaml: { + parameters: { + query?: never; + header?: never; path?: never; cookie?: never; }; + /** @description Domain to check for SAML configuration */ requestBody: { content: { - "application/json": components["schemas"]["ICheckForExistingUploadArgs"]; + "application/json": { + /** @example example.com */ + domain: string; + }; }; }; responses: { - /** @description The url has been successfully created. */ + /** @description SAML status for the domain */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + forceSaml?: boolean; + }; + }; + }; 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad request - invalid domain or API error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ICheckForExistingUploadResponse"]; + "application/json": { + error?: string; + }; }; }; }; }; - UploadsController_finaliseUpload: { + FrontendController_validateEmail: { parameters: { query?: never; - header: { - "x-app-api-key": string; - }; + header?: never; path?: never; cookie?: never; }; + /** @description Email address to validate */ requestBody: { content: { - "application/json": components["schemas"]["IFinaliseUploadArgs"]; + "application/json": { + /** @example user@example.com */ + email: string; + }; }; }; responses: { - /** @description The upload has been completed. */ + /** @description Email validation result */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + valid?: boolean; + reason?: string; + }; + }; + }; 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad request - invalid email or API error */ + 400: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["IFinaliseUploadResponse"]; + "application/json": { + error?: string; + }; }; }; }; }; - UploadsController_finishLargeFile: { + FrontendController_getBinaryDownloadUrl: { parameters: { query?: never; header: { - "x-app-api-key": string; + authorization: string; }; path?: never; cookie?: never; }; + /** @description Get a signed download URL for a binary */ requestBody: { content: { - "application/json": components["schemas"]["IFinishLargeFileArgs"]; + "application/json": { + binaryId: string; + orgId: number; + }; }; }; responses: { - /** @description The large file upload has been completed. */ + /** @description Signed download URL for the binary; `dek` (base64) is present when the binary is encrypted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + url?: string; + encrypted?: boolean; + dek?: string; + }; + }; + }; 201: { headers: { [name: string]: unknown; }; - content: { - "application/json": components["schemas"]["IFinishLargeFileResponse"]; + content: { + "application/json": Record; + }; + }; + /** @description Binary not found or not accessible */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + error?: string; + }; + }; + }; + }; + }; + FrontendController_downloadBinary: { + parameters: { + query: { + orgId: number; + }; + header: { + authorization: string; + }; + path: { + binaryId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Binary bytes (decrypted if encrypted) */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Binary not found or not accessible */ + 400: { + headers: { + [name: string]: unknown; }; + content?: never; }; }; }; - UploadsController_createTest: { + FrontendController_getArtifactDownloadUrl: { parameters: { query?: never; header: { - "x-app-api-key": string; + authorization: string; }; path?: never; cookie?: never; }; + /** @description Get a signed download URL for a single result artifact */ requestBody: { content: { - "multipart/form-data": components["schemas"]["ICreateTestUploadArgs"]; + "application/json": { + resultId: number; + /** @description Supabase storage path; must be one of the result's recorded files */ + path: string; + /** @description Optional filename override for the saved file */ + download?: string; + }; }; }; responses: { - /** @description The record has been successfully created. */ + /** @description Signed download URL for the artifact (CDN/B2 or Supabase) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + url?: string; + }; + }; + }; 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Artifact not found or not accessible */ + 400: { headers: { [name: string]: unknown; }; content: { "application/json": { - message?: string; - results?: components["schemas"]["IDBResult"][]; + error?: string; }; }; }; }; }; - UploadsController_retryTest: { + FrontendController_getResultDetail: { parameters: { query?: never; header: { - "x-app-api-key": string; + authorization: string; }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["IRetryTestArgs"]; + path: { + resultId: number; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description The record has been successfully created. */ - 201: { + /** @description Bundled result detail: row + result_files + binary + signed media URLs + parsed log in a single round-trip */ + 200: { headers: { [name: string]: unknown; }; - content: { - "application/json": { - message?: string; - results?: components["schemas"]["IDBResult"][]; - }; + content?: never; + }; + /** @description Result not found or caller not authorized */ + 400: { + headers: { + [name: string]: unknown; }; + content?: never; }; }; }; - UploadsController_cancelTest: { + FrontendController_ingestLogs: { parameters: { query?: never; header: { - "x-app-api-key": string; + authorization: string; }; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["ICancelTestArgs"]; - }; - }; + requestBody?: never; responses: { - /** @description The record has been successfully cancelled. */ - 201: { + /** @description Batch accepted for forwarding to Axiom */ + 202: { headers: { [name: string]: unknown; }; content: { "application/json": { - message?: string; - success?: boolean; - cancelledCount?: number; + accepted?: number; }; }; }; + /** @description Malformed batch payload */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; }; - UploadsController_getUploadStatus: { + FrontendController_ingestLogsAnonymous: { parameters: { - query?: { - /** @description Upload ID to get status for */ - uploadId?: string; - /** @description Upload name to get status for */ - name?: string; - }; - header: { - "x-app-api-key": string; - }; + query?: never; + header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Upload status */ - 200: { + /** @description Batch accepted for forwarding to Axiom */ + 202: { headers: { [name: string]: unknown; }; content: { - /** - * @example { - * "uploadId": "upload-123", - * "status": "PENDING", - * "tests": [ - * { - * "id": 1, - * "test_file_name": "test-flow.yaml", - * "status": "PENDING" - * } - * ] - * } - */ - "application/json": Record; + "application/json": { + accepted?: number; + }; + }; + }; + /** @description Malformed batch payload */ + 400: { + headers: { + [name: string]: unknown; }; + content?: never; }; }; }; - UploadsController_listUploads: { + HealthController_health: { parameters: { - query?: { - /** @description Filter by upload name (supports * wildcard) */ - name?: string; - /** @description Filter uploads created on or after this date (ISO 8601) */ - from?: string; - /** @description Filter uploads created on or before this date (ISO 8601) */ - to?: string; - /** @description Maximum number of uploads to return (default: 20) */ - limit?: number; - /** @description Number of uploads to skip (default: 0) */ - offset?: number; - }; - header: { - "x-app-api-key": string; - }; + query?: never; + header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description List of flow uploads. Use GET /uploads/status for detailed test results. */ + /** @description Health check endpoint */ 200: { headers: { [name: string]: unknown; @@ -997,428 +4056,219 @@ export interface operations { content: { /** * @example { - * "uploads": [ - * { - * "id": "upload-123", - * "name": "Test Upload", - * "created_at": "2024-01-01T00:00:00Z", - * "consoleUrl": "https://console.devicecloud.dev/results/upload-123" - * } - * ], - * "total": 1, - * "limit": 20, - * "offset": 0 + * "status": "ok" * } */ "application/json": { - uploads?: { - id?: string; - name?: string | null; - created_at?: string; - consoleUrl?: string; - }[]; - total?: number; - limit?: number; - offset?: number; + /** @example ok */ + status?: string; }; }; }; }; }; - UploadsController_deleteUpload: { + BillingController_createSubscription: { parameters: { query?: never; - header: { - "x-app-api-key": string; - }; - path: { - uploadId: string; - }; + header?: never; + path?: never; cookie?: never; }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; + requestBody: { + content: { + "application/json": { + items: { + price_id?: string; + quantity?: number; + }[]; + customer_email?: string; + custom_data?: { + userId?: string; + }; + billing_details?: { + enable_checkout?: boolean; + }; }; - content?: never; }; - /** @description The upload has been successfully deleted. */ + }; + responses: { + /** @description Subscription created successfully. */ 201: { headers: { [name: string]: unknown; }; content: { - "application/json": { - success?: boolean; - message?: string; - }; + "application/json": Record; }; }; }; }; - ResultsController_getResults: { + BillingController_updateSubscription: { parameters: { query?: never; - header: { - "x-app-api-key": string; - }; - path: { - uploadId: string; - }; + header?: never; + path?: never; cookie?: never; }; - requestBody?: never; - responses: { - /** @description The record has been successfully created. */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - statusCode?: number; - results?: components["schemas"]["TResultResponse"][]; - }; + requestBody: { + content: { + "application/json": { + price_id: string; }; }; }; - }; - ResultsController_getTestRunArtifacts: { - parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path: { - uploadId: string; - }; - cookie?: never; - }; - requestBody?: never; responses: { + /** @description Subscription updated successfully. */ 201: { headers: { [name: string]: unknown; }; - content?: never; + content: { + "application/json": Record; + }; }; }; }; - ResultsController_notifyTestRunComplete: { + StatsController_getMarketingStats: { parameters: { query?: never; - header: { - "x-app-api-key": string; - }; - path: { - uploadId: string; - }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Send results summary email. */ - 201: { + /** @description Public marketing statistics */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": string; + "application/json": { + /** @example 150000 */ + total_count?: number; + }; }; }; }; }; - ResultsController_downloadReport: { + FlowsController_getFlows: { parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path: { - uploadId: string; + query?: { + platform?: "android" | "ios"; + appId?: string; + days?: number; + /** @description ISO 8601 date string (e.g. 2026-01-01). Overrides days when provided. */ + startDate?: string; + /** @description ISO 8601 date string (e.g. 2026-01-31). Defaults to now when startDate is set. */ + endDate?: string; + /** @description Comma-separated tag filter. Returns flows that have any of the given tags (e.g. smoke,critical). */ + tags?: string; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Download combined JUNIT test report (report.xml) for the upload */ + /** @description Aggregated flow statistics for the last N days. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": string; + "application/json": { + statusCode?: number; + flows?: components["schemas"]["TFlowSummaryResponse"][]; + }; }; }; }; }; - ResultsController_downloadHtmlReport: { + FlowsController_getFlowRuns: { parameters: { - query?: never; - header: { - "x-app-api-key": string; - }; - path: { - uploadId: string; + query: { + fileName: string; + platform?: "android" | "ios"; + appId?: string; + limit?: number; + /** @description ISO 8601 date string (e.g. 2026-01-01). */ + startDate?: string; + /** @description ISO 8601 date string (e.g. 2026-01-31). */ + endDate?: string; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Download combined HTML test report with assets (report.zip) for the upload */ + /** @description Individual run history for a specific flow file. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": string; + "application/json": { + statusCode?: number; + runs?: components["schemas"]["TFlowRunItem"][]; + }; }; }; }; }; - ResultsController_downloadSingleHtmlReport: { + LiveController_createSession: { parameters: { query?: never; - header: { - "x-app-api-key": string; - }; - path: { - resultId: string; - }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Download HTML test report with assets (report.zip) for a single result */ - 200: { + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": string; + "application/json": Record; }; }; }; }; - ResultsController_getCompatibilityData: { + LiveController_getSession: { parameters: { query?: never; - header: { - "x-app-api-key": string; + header?: never; + path: { + identifier: string; }; - path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Device compatibility lookup data including Maestro versions */ 200: { headers: { [name: string]: unknown; }; content: { - /** - * @example { - * "statusCode": 200, - * "data": { - * "ios": { - * "iphone-14": { - * "name": "iPhone 14", - * "versions": [ - * "16", - * "17", - * "18" - * ], - * "deprecated": false - * }, - * "iphone-15": { - * "name": "iPhone 15", - * "versions": [ - * "17" - * ], - * "deprecated": false - * }, - * "iphone-16": { - * "name": "iPhone 16", - * "versions": [ - * "18", - * "26" - * ], - * "deprecated": false - * }, - * "iphone-16-plus": { - * "name": "iPhone 16 Plus", - * "versions": [ - * "18", - * "26" - * ], - * "deprecated": false - * }, - * "iphone-16-pro": { - * "name": "iPhone 16 Pro", - * "versions": [ - * "18", - * "26" - * ], - * "deprecated": false - * }, - * "iphone-16-pro-max": { - * "name": "iPhone 16 Pro Max", - * "versions": [ - * "18", - * "26" - * ], - * "deprecated": false - * }, - * "ipad-pro-6th-gen": { - * "name": "iPad Pro (6th gen)", - * "versions": [ - * "18", - * "26" - * ], - * "deprecated": false - * } - * }, - * "android": { - * "pixel-6": { - * "name": "Pixel 6", - * "apiLevels": [ - * "29", - * "30", - * "31", - * "32", - * "33", - * "34", - * "35", - * "36" - * ], - * "deprecated": false - * }, - * "pixel-6-pro": { - * "name": "Pixel 6 Pro", - * "apiLevels": [ - * "33", - * "34", - * "35", - * "36" - * ], - * "deprecated": false - * }, - * "pixel-7": { - * "name": "Pixel 7", - * "apiLevels": [ - * "33", - * "34", - * "35", - * "36" - * ], - * "deprecated": false - * }, - * "pixel-7-pro": { - * "name": "Pixel 7 Pro", - * "apiLevels": [ - * "33", - * "34", - * "35", - * "36" - * ], - * "deprecated": false - * }, - * "generic-tablet": { - * "name": "Generic Tablet", - * "apiLevels": [ - * "33", - * "34", - * "35", - * "36" - * ], - * "deprecated": false - * } - * }, - * "androidPlay": { - * "pixel-6": { - * "name": "Pixel 6 (Google Play)", - * "apiLevels": [ - * "34", - * "35", - * "36" - * ], - * "deprecated": false - * }, - * "pixel-7": { - * "name": "Pixel 7 (Google Play)", - * "apiLevels": [ - * "34", - * "35", - * "36" - * ], - * "deprecated": false - * } - * }, - * "maestro": { - * "supportedVersions": [ - * "1.39.0", - * "1.39.2", - * "1.39.5", - * "1.39.7", - * "1.40.3", - * "1.41.0", - * "2.0.2", - * "2.0.3", - * "2.0.4", - * "2.0.9", - * "2.1.0" - * ], - * "defaultVersion": "1.41.0", - * "latestVersion": "2.1.0" - * } - * } - * } - */ - "application/json": { - statusCode?: number; - data?: { - ios?: Record; - android?: Record; - androidPlay?: Record; - maestro?: { - supportedVersions?: string[]; - defaultVersion?: string; - latestVersion?: string; - }; - }; - }; + "application/json": Record; }; }; }; }; - AllureController_downloadAllureReport: { + LiveController_stopSession: { parameters: { query?: never; - header: { - "x-app-api-key": string; - }; + header?: never; path: { - /** @description The upload ID to generate Allure report for */ - uploadId: string; + identifier: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Allure report HTML file download */ 200: { - headers: { - [name: string]: unknown; - }; - content: { - "text/html": string; - }; - }; - /** @description Upload not found or no results available */ - 404: { headers: { [name: string]: unknown; }; @@ -1426,64 +4276,40 @@ export interface operations { }; }; }; - WebhooksController_getWebhook: { + LiveController_execTest: { parameters: { - query?: { - /** @description Set to true to return full secret instead of masked version */ - show_secret?: boolean; - }; - header: { - "x-app-api-key": string; + query?: never; + header?: never; + path: { + identifier: string; }; - path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Current webhook configuration */ - 200: { + 201: { headers: { [name: string]: unknown; }; content: { - "application/json": { - webhook_url?: string; - /** @description Full secret (only when show_secret=true) */ - secret_key?: string; - /** @description Masked secret (default) */ - secret_key_masked?: string; - /** Format: date-time */ - created_at?: string; - /** Format: date-time */ - updated_at?: string; - }; + "application/json": Record; }; }; }; }; - WebhooksController_setWebhook: { + LiveController_getCommandStatus: { parameters: { query?: never; - header: { - "x-app-api-key": string; + header?: never; + path: { + identifier: string; + commandId: string; }; - path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": { - /** - * Format: uri - * @example https://api.example.com/webhook - */ - url: string; - }; - }; - }; + requestBody?: never; responses: { - /** @description Webhook URL set successfully */ - 201: { + 200: { headers: { [name: string]: unknown; }; @@ -1493,160 +4319,123 @@ export interface operations { }; }; }; - WebhooksController_deleteWebhook: { + LiveController_keepalive: { parameters: { query?: never; - header: { - "x-app-api-key": string; + header?: never; + path: { + identifier: string; }; - path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Webhook configuration deleted successfully */ - 200: { + 201: { headers: { [name: string]: unknown; }; - content: { - "application/json": Record; - }; + content?: never; }; }; }; - WebhooksController_regenerateWebhookSecret: { + LiveController_installBinary: { parameters: { query?: never; - header: { - "x-app-api-key": string; + header?: never; + path: { + identifier: string; }; - path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Webhook secret regenerated successfully */ 201: { headers: { [name: string]: unknown; }; - content: { - "application/json": Record; - }; + content?: never; }; }; }; - WebhooksController_testWebhook: { + MeController_listOrgs: { parameters: { query?: never; header: { - "x-app-api-key": string; + authorization: string; }; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": { - /** Format: uri */ - url?: string; - }; - }; - }; + requestBody?: never; responses: { - /** @description Test webhook sent successfully */ - 201: { + 200: { headers: { [name: string]: unknown; }; - content: { - "application/json": Record; - }; + content?: never; }; }; }; - OrgController_handlePaddleWebhook: { + MeController_listSessions: { parameters: { query?: never; header: { - "paddle-signature": string; + authorization: string; }; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Paddle webhook handler. */ - 201: { + 200: { headers: { [name: string]: unknown; }; - content: { - "application/json": string; - }; + content?: never; }; }; }; - OrgController_updateOrgName: { + MeController_revokeSession: { parameters: { query?: never; header: { - "x-app-api-key": string; + authorization: string; }; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateOrgNameDto"]; + path: { + id: string; }; + cookie?: never; }; + requestBody?: never; responses: { - /** @description Organization name updated successfully. */ - 201: { + 200: { headers: { [name: string]: unknown; }; - content: { - "application/json": boolean; - }; + content?: never; }; }; }; - OrgController_inviteTeamMember: { + MeController_createPersonalTeam: { parameters: { query?: never; header: { - "x-app-api-key": string; + authorization: string; }; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": { - inviteEmail: string; - requesterEmail: string; - link: string; - orgId: string; - orgName: string; - }; - }; - }; + requestBody?: never; responses: { - /** @description Team member invited successfully. */ - 201: { + 200: { headers: { [name: string]: unknown; }; - content: { - "application/json": boolean; - }; + content?: never; }; }; }; - OrgController_acceptInvite: { + MeController_createTeam: { parameters: { query?: never; header: { @@ -1655,292 +4444,262 @@ export interface operations { path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["AcceptInviteDto"]; - }; - }; + requestBody?: never; responses: { - /** @description Team invite accepted successfully. */ - 201: { + 200: { headers: { [name: string]: unknown; }; - content: { - "application/json": boolean; - }; + content?: never; }; }; }; - OrgController_getAllSubscriptions: { + MeController_deleteAccount: { parameters: { query?: never; header: { - "x-app-api-key": string; + authorization: string; }; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": { - orgId: string; - }; - }; - }; + requestBody?: never; responses: { - /** @description All subscription data fetched successfully. */ - 201: { + 200: { headers: { [name: string]: unknown; }; - content: { - "application/json": Record; - }; + content?: never; }; }; }; - OrgController_updateOverageLimit: { + CliLoginController_handoff: { parameters: { query?: never; - header: { - "x-app-api-key": string; - }; + header?: never; path?: never; cookie?: never; }; requestBody: { content: { - "application/json": { - orgId: string; - overageLimit: number; - }; + "application/json": components["schemas"]["HandoffDto"]; }; }; responses: { - /** @description Overage limit updated successfully. */ - 201: { + 204: { headers: { [name: string]: unknown; }; - content: { - "application/json": Record; - }; + content?: never; }; }; }; - OrgController_getUsageHistory: { + CliLoginController_claim: { parameters: { query?: never; - header: { - "x-app-api-key": string; - }; + header?: never; path?: never; cookie?: never; }; requestBody: { content: { - "application/json": { - orgId: string; - /** @enum {string} */ - format?: "json" | "csv"; - /** Format: date-time */ - startDate?: string; - /** Format: date-time */ - endDate?: string; - }; + "application/json": components["schemas"]["ClaimDto"]; }; }; responses: { - /** @description Usage history fetched successfully. */ - 201: { + /** @description Returns the Supabase session on successful claim. */ + 200: { headers: { [name: string]: unknown; }; - content: { - "application/json": unknown[]; - }; + content?: never; }; }; }; - FrontendController_checkDomainSaml: { + CliLogsController_ingestLogs: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** @description Domain to check for SAML configuration */ - requestBody: { - content: { - "application/json": { - /** @example example.com */ - domain: string; - }; - }; - }; + requestBody?: never; responses: { - /** @description SAML status for the domain */ + /** @description Batch accepted for forwarding to Axiom */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": { - forceSaml?: boolean; + accepted?: number; }; }; }; - 201: { + 202: { headers: { [name: string]: unknown; }; content?: never; }; - /** @description Bad request - invalid domain or API error */ + /** @description Malformed batch payload */ 400: { headers: { [name: string]: unknown; }; - content: { - "application/json": { - error?: string; - }; - }; + content?: never; }; }; }; - FrontendController_validateEmail: { + EmailChangeController_start: { parameters: { query?: never; - header?: never; + header: { + authorization: string; + }; path?: never; cookie?: never; }; - /** @description Email address to validate */ + /** @description New email address to change to */ requestBody: { content: { "application/json": { /** @example user@example.com */ - email: string; + newEmail: string; }; }; }; responses: { - /** @description Email validation result */ + /** @description Verification code sent to the current address */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": { - valid?: boolean; - reason?: string; + ok?: boolean; }; }; }; - 201: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Bad request - invalid email or API error */ + /** @description Invalid or missing newEmail */ 400: { headers: { [name: string]: unknown; }; content: { "application/json": { - error?: string; + message?: string; }; }; }; }; }; - HealthController_health: { + EmailChangeController_verifyCurrent: { parameters: { query?: never; - header?: never; + header: { + authorization: string; + }; path?: never; cookie?: never; }; - requestBody?: never; + /** @description Code emailed to the current address */ + requestBody: { + content: { + "application/json": { + /** @example 123456 */ + code: string; + }; + }; + }; responses: { - /** @description Health check endpoint */ + /** @description Current address verified; code sent to the new address */ 200: { headers: { [name: string]: unknown; }; content: { - /** - * @example { - * "status": "ok" - * } - */ "application/json": { - /** @example ok */ - status?: string; + ok?: boolean; + }; + }; + }; + /** @description Invalid or expired code */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message?: string; }; }; }; }; }; - BillingController_createSubscription: { + EmailChangeController_verifyNew: { parameters: { query?: never; - header?: never; + header: { + authorization: string; + }; path?: never; cookie?: never; }; + /** @description Code emailed to the new address */ requestBody: { content: { "application/json": { - items: { - price_id?: string; - quantity?: number; - }[]; - customer_email?: string; - custom_data?: { - orgId?: string; - userId?: string; - }; - billing_details?: { - enable_checkout?: boolean; - }; + /** @example 123456 */ + code: string; }; }; }; responses: { - /** @description Subscription created successfully. */ - 201: { + /** @description Email change applied */ + 200: { headers: { [name: string]: unknown; }; content: { - "application/json": Record; + "application/json": { + ok?: boolean; + email?: string; + }; + }; + }; + /** @description Invalid or expired code, or current email not verified */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message?: string; + }; }; }; }; }; - StatsController_getMarketingStats: { + EmailChangeController_cancel: { parameters: { query?: never; - header?: never; + header: { + authorization: string; + }; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description Public marketing statistics */ + /** @description In-progress email change cancelled (idempotent) */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": { - /** @example 150000 */ - total_count?: number; + ok?: boolean; }; }; }; diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 62a376f..21aecac 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -10,7 +10,10 @@ import { closeSync, openSync, rmSync, statSync } from 'node:fs'; import { ENVIRONMENTS } from '../config/environments.js'; -import { CliAuthGateway } from '../gateways/cli-auth-gateway.js'; +import { + CliAuthGateway, + SessionRefreshError, +} from '../gateways/cli-auth-gateway.js'; import { telemetry } from '../services/telemetry.service.js'; import type { AuthContext } from '../types/domain/auth.types.js'; @@ -124,11 +127,19 @@ async function refreshSessionWithLock( } const { anonKey } = ENVIRONMENTS[current.env].supabase; - const refreshed = await CliAuthGateway.refresh( - current.supabase_url, - anonKey, - session, - ); + let refreshed; + try { + refreshed = await CliAuthGateway.refresh( + current.supabase_url, + anonKey, + session, + ); + } catch (error) { + if (error instanceof SessionRefreshError && error.definitive) { + dropStoredSession(); + } + throw error; + } // Re-read again and merge only `session` so a concurrent `switch-org` // write (org fields) isn't reverted by our pre-refresh snapshot. const merged: StoredConfig = { ...(readConfig() ?? current), session: refreshed }; @@ -139,6 +150,20 @@ async function refreshSessionWithLock( } } +/** + * Remove only the (definitively dead) session from the stored config, keeping + * env/api_url/org so a re-login lands back in the same environment. With the + * dead session gone, subsequent commands report "Not authenticated" instead + * of failing the same refresh, and `dcd login` skips its "already logged in" + * confirm. Exported for tests. + */ +export function dropStoredSession(): void { + const config = readConfig(); + if (!config?.session) return; + delete config.session; + writeConfig(config); +} + async function acquireRefreshLock(lockPath: string): Promise { const deadline = Date.now() + LOCK_WAIT_MS; for (;;) { diff --git a/src/utils/ci.ts b/src/utils/ci.ts index 196b2dc..c5a8153 100644 --- a/src/utils/ci.ts +++ b/src/utils/ci.ts @@ -40,3 +40,39 @@ export function isCI(): boolean { return !process.stdout.isTTY; } + +/** Which DCD CI integration (if any) is wrapping this CLI invocation, and its version. */ +export interface CiContext { + /** e.g. 'github' | 'bitrise' | 'bitbucket' | 'eas' | 'gitlab' | 'circleci'. */ + provider: string | null; + /** The wrapper's own version, if it forwarded one (DCD_CI_WRAPPER_VERSION). */ + wrapperVersion: string | null; +} + +/** Infer the CI provider from the env vars each platform sets natively. */ +function inferProvider(): string | null { + const env = process.env; + if (env.GITHUB_ACTIONS) return 'github'; + if (env.BITRISE_IO || env.BITRISE_BUILD_NUMBER) return 'bitrise'; + if (env.BITBUCKET_BUILD_NUMBER) return 'bitbucket'; + if (env.EAS_BUILD || env.EAS_BUILD_RUNNER || env.EAS_BUILD_ID) return 'eas'; + if (env.GITLAB_CI) return 'gitlab'; + if (env.CIRCLECI) return 'circleci'; + return null; +} + +/** + * Resolve the CI integration context the CLI forwards to the notices API so + * notices can target a specific integration/version (e.g. "Bitbucket Pipe < + * 1.1.0"). The DCD CI wrappers set `DCD_CI_PROVIDER` / `DCD_CI_WRAPPER_VERSION` + * explicitly (preferred — carries the wrapper version); otherwise the provider + * is inferred from native env vars and the version is unknown. + */ +export function detectCiContext(): CiContext { + const forwardedProvider = process.env.DCD_CI_PROVIDER?.trim(); + const forwardedVersion = process.env.DCD_CI_WRAPPER_VERSION?.trim(); + return { + provider: forwardedProvider || inferProvider(), + wrapperVersion: forwardedVersion || null, + }; +} diff --git a/src/utils/cli.ts b/src/utils/cli.ts index 1a320f8..6b152cd 100644 --- a/src/utils/cli.ts +++ b/src/utils/cli.ts @@ -12,9 +12,19 @@ import { telemetry } from '../services/telemetry.service.js'; import { symbols } from './styling.js'; -// Resolve version at runtime — read the file rather than importing it, so -// package.json never gets pulled into the tsc program / dist rootDir. +// Resolve version at runtime. The bun-compiled binary can't read package.json +// off disk (it isn't bundled next to the embedded module), so the build stamps +// the version in via `bun --define __DCD_CLI_VERSION__` (see +// scripts/build-binaries.mjs). Prefer that constant; on the npm/tsx path the +// identifier was never defined, so `typeof` is 'undefined' (no ReferenceError) +// and we fall back to reading package.json. export function getCliVersion(): string { + if ( + typeof __DCD_CLI_VERSION__ === 'string' && + __DCD_CLI_VERSION__.length > 0 + ) { + return __DCD_CLI_VERSION__; + } try { const pkg = JSON.parse( readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), @@ -114,8 +124,7 @@ export function validateEnum( /** * Coerce a flag value (possibly a single string, array, or undefined) into a * flat string array. Comma-separated values inside each entry are split out. - * Used for repeatable flags like --include-tags, --env, --metadata where citty - * surfaces a string (single use) or string[] (repeated). + * Pair with {@link collectRepeatedFlag} for repeatable flags. */ export function coerceArray( value: string | string[] | undefined, @@ -127,6 +136,40 @@ export function coerceArray( return arr.flatMap((v) => v.split(',')); } +/** + * Collect every occurrence of a repeatable flag from raw argv, in order. + * + * citty 0.2.2 delegates to Node's `parseArgs`, which — without `multiple: true` + * (unsupported by citty's ArgsDef) — keeps only the LAST value of a repeated + * `type: 'string'` flag. So `-e A=1 -e B=2` collapses to just `B=2`. We recover + * all occurrences by scanning rawArgs ourselves (same approach as + * `recoverFlagValue` in commands/live.ts). + * + * `names` lists every spelling of one logical flag, e.g. ['--env', '-e']. + * Handles both `--flag value` (consuming the next token, so values starting + * with `-` survive) and `--flag=value`. Feed the result through + * {@link coerceArray} for comma-splitting where appropriate. + */ +export function collectRepeatedFlag( + rawArgs: string[], + names: string[], +): string[] { + const out: string[] = []; + for (let i = 0; i < rawArgs.length; i++) { + const arg = rawArgs[i]; + const eqName = names.find((n) => arg.startsWith(`${n}=`)); + if (eqName) { + out.push(arg.slice(eqName.length + 1)); + continue; + } + if (names.includes(arg) && i + 1 < rawArgs.length) { + out.push(rawArgs[i + 1]); + i++; // consume the value so a leading-dash value isn't re-read as a flag + } + } + return out; +} + /** * Parse an integer flag. Returns undefined if the value is undefined/empty. * Throws CliError if the value is not a valid integer. diff --git a/src/utils/compatibility.ts b/src/utils/compatibility.ts index a48324a..af1934d 100644 --- a/src/utils/compatibility.ts +++ b/src/utils/compatibility.ts @@ -1,4 +1,5 @@ import type { AuthContext } from '../types/domain/auth.types.js'; +import type { Notice } from '../services/notices.service.js'; export interface CompatibilityData { android: Record; @@ -9,20 +10,46 @@ export interface CompatibilityData { latestVersion: string; supportedVersions: string[]; }; + /** Active CLI notices, piggybacked onto the compatibility response by the API. */ + notices?: Notice[]; +} + +/** Identity the CLI forwards so the API can target notices by version / CI. */ +export interface ClientContext { + cliVersion?: string; + ciProvider?: string | null; + ciWrapperVersion?: string | null; } let cachedCompatibilityData: CompatibilityData | null = null; -export async function fetchCompatibilityData(apiUrl: string, auth: AuthContext): Promise { +export async function fetchCompatibilityData( + apiUrl: string, + auth: AuthContext, + clientContext?: ClientContext, +): Promise { if (cachedCompatibilityData) { return cachedCompatibilityData; } + // Forward CLI / CI identity so the API can version-filter and target notices. + const noticeHeaders: Record = {}; + if (clientContext?.cliVersion) { + noticeHeaders['x-dcd-cli-version'] = clientContext.cliVersion; + } + if (clientContext?.ciProvider) { + noticeHeaders['x-dcd-ci-provider'] = clientContext.ciProvider; + } + if (clientContext?.ciWrapperVersion) { + noticeHeaders['x-dcd-ci-wrapper-version'] = clientContext.ciWrapperVersion; + } + try { const response = await fetch(`${apiUrl}/results/compatibility/data`, { headers: { 'Content-Type': 'application/json', ...auth.headers, + ...noticeHeaders, }, method: 'GET', }); diff --git a/src/utils/device-matrix.ts b/src/utils/device-matrix.ts new file mode 100644 index 0000000..7f171ae --- /dev/null +++ b/src/utils/device-matrix.ts @@ -0,0 +1,96 @@ +import { + DeviceMatrixConfig, + isIosMatrixConfig, +} from '../types/domain/device.types.js'; +import { CliError } from './cli.js'; + +/** + * Refuse to submit a device matrix to an API that cannot honour it. + * + * The estimate endpoint is only called when a matrix was actually requested, so + * a null estimate (the gateway maps 404/405 to null) means the API predates the + * feature. That API would **silently strip** the unknown `deviceMatrix` field — + * its ValidationPipe runs `whitelist: true, forbidNonWhitelisted: false` — and + * run every flow on a single default device, exiting 0. The user would believe + * they had tested N devices when they tested one: the exact silent + * under-testing the device matrix exists to prevent. Fail loudly instead. + * + * @throws CliError when a matrix was requested but the API does not support it. + */ +export function assertMatrixSupported( + deviceMatrix: DeviceMatrixConfig[], + estimate: unknown | null, +): void { + if (deviceMatrix.length === 0 || estimate) return; + + throw new CliError( + 'This DeviceCloud API does not support device matrices, so ' + + '--ios-device-matrix / --android-device-matrix cannot be honoured. ' + + 'Submitting anyway would silently run every flow on a single default ' + + 'device and report success. Upgrade the API, or drop the matrix flags ' + + 'and use --ios-device / --android-device for a single-device run.', + ); +} + +/** + * Parse repeated `--ios-device-matrix :` and + * `--android-device-matrix :[:play]` flags into an explicit device + * matrix. Each entry is one validated cell — there is NO cross-product, because + * the compatibility matrix is ragged and a cross-product would invent cells the + * user never asked for. + * + * A device matrix is single-platform (one upload, one binary), so mixing iOS + * and Android configs is rejected here before anything is uploaded. + * + * @throws CliError on malformed syntax or a mixed-platform matrix. + */ +export function parseDeviceMatrix( + iosConfigs: string[], + androidConfigs: string[], +): DeviceMatrixConfig[] { + if (iosConfigs.length > 0 && androidConfigs.length > 0) { + throw new CliError( + 'A device matrix cannot mix platforms: use either --ios-device-matrix or --android-device-matrix, not both. One upload runs one binary.', + ); + } + + const configs: DeviceMatrixConfig[] = []; + + for (const raw of iosConfigs) { + const parts = raw.split(':'); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new CliError( + `Invalid --ios-device-matrix "${raw}". Expected :, e.g. iphone-16:18.`, + ); + } + configs.push({ iOSDevice: parts[0], iOSVersion: parts[1] }); + } + + for (const raw of androidConfigs) { + const parts = raw.split(':'); + // : with an optional trailing :play for a Play cell. + if ( + parts.length < 2 || + parts.length > 3 || + !parts[0] || + !parts[1] || + (parts.length === 3 && parts[2] !== 'play') + ) { + throw new CliError( + `Invalid --android-device-matrix "${raw}". Expected : or ::play, e.g. pixel-7:34 or pixel-7:34:play.`, + ); + } + configs.push({ + androidDevice: parts[0], + androidApiLevel: parts[1], + ...(parts.length === 3 ? { googlePlay: true } : {}), + }); + } + + return configs; +} + +/** True when the matrix targets iOS (used to pick the validation lookup). */ +export function matrixIsIos(configs: DeviceMatrixConfig[]): boolean { + return configs.length > 0 && isIosMatrixConfig(configs[0]); +} diff --git a/src/utils/envelope.ts b/src/utils/envelope.ts new file mode 100644 index 0000000..747705b --- /dev/null +++ b/src/utils/envelope.ts @@ -0,0 +1,301 @@ +import { + createCipheriv, + createPublicKey, + diffieHellman, + generateKeyPairSync, + hkdfSync, + randomBytes, +} from 'node:crypto'; +import { open, stat } from 'node:fs/promises'; +import { inferEnvFromApiUrl } from '../config/environments.js'; +import { ENVIRONMENTS } from '../config/environments.js'; + +/** + * Client-side envelope encryption of app binaries before upload (dcd#1138). + * + * The CLI is the *encrypt* half of the contract; the platform (api + + * simulators, in the `dcd` repo) is the *decrypt* half. This module MUST stay + * byte-compatible with `api/src/common/crypto/envelope.ts` — the wire format is + * specified in `dcd/docs/binary-envelope-encryption.md`. + * + * Scheme: a per-upload random 256-bit DEK encrypts the binary (chunked + * AES-256-GCM); the DEK is wrapped with the environment's pinned X25519 KEK + * public key (sealed box). Only the platform API holds the KEK private half, so + * every storage/transport tier sees ciphertext only. + */ + +const MAGIC = Buffer.from('DCDE', 'ascii'); +const CONTAINER_VERSION = 1; +const NONCE_LEN = 12; +const NONCE_PREFIX_LEN = 7; +const DEK_LEN = 32; +export const CHUNK_SIZE = 1024 * 1024; // 1 MiB plaintext segments + +const HKDF_INFO = Buffer.from('dcd-binary-dek-wrap-v1', 'ascii'); +// DER prefix that turns a raw 32-byte X25519 public key into an importable SPKI. +const SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex'); + +/** `binaries.metadata.enc` / `uploads.metadata.enc` shape (binary + flow zip). */ +export interface BinaryEnvelope { + v: number; + kek: number; + wrapped_key: string; +} + +/** + * `results.env.enc` shape (#1152). Same wrapped-DEK fields as a binary, plus the + * env ciphertext carried **inline** (the env map is tiny, so a single DCDE + * segment rides in-column rather than as a separate uploaded blob). + */ +export interface EnvEnvelope extends BinaryEnvelope { + /** base64 of a single-segment DCDE container of `JSON.stringify(env)`. */ + ciphertext: string; +} + +/** + * Whether client-side envelope encryption is on. Explicit `flag` (the + * `--encrypt` CLI flag) wins; otherwise `DCD_ENCRYPT=1` enables it for binary, + * flow, and env, and the legacy `DCD_ENCRYPT_BINARIES=1` is kept as an alias. + * When on, the binary, the flow zip, and the env map are each encrypted with + * their **own** per-upload DEK (all wrapped under the same per-env KEK). + */ +export function isEncryptionEnabled(flag?: boolean): boolean { + if (flag !== undefined) return flag; + return ( + process.env.DCD_ENCRYPT === '1' || process.env.DCD_ENCRYPT_BINARIES === '1' + ); +} + +/** Pinned KEK public key (base64 raw 32-byte X25519) + version, per env. */ +interface KekPublicKey { + version: number; + keyRaw: Buffer; +} + +function x25519PublicFromRaw(raw: Buffer) { + return createPublicKey({ + key: Buffer.concat([SPKI_PREFIX, raw]), + format: 'der', + type: 'spki', + }); +} + +/** + * Resolve the KEK public key for the environment behind `apiUrl`. Order: + * 1. `DCD_BINARY_KEK_PUBLIC` env override (`:`, e.g. `1:AAAA…`) + * — lets the feature be exercised before keys are pinned in the release. + * 2. the pinned `ENVIRONMENTS[env].kekPublicKey`. + * Returns null when no key is available (encryption cannot proceed). + */ +export function resolveKekPublicKey(apiUrl: string): KekPublicKey | null { + const override = process.env.DCD_BINARY_KEK_PUBLIC; + if (override) { + const [versionPart, b64] = override.split(':'); + const version = Number(versionPart); + if (b64 && Number.isInteger(version)) { + const keyRaw = Buffer.from(b64, 'base64'); + if (keyRaw.length === 32) return { version, keyRaw }; + } + throw new Error( + 'DCD_BINARY_KEK_PUBLIC must be ":"', + ); + } + + const env = inferEnvFromApiUrl(apiUrl); + const pinned = ENVIRONMENTS[env].kekPublicKey; + if (!pinned) return null; + const keyRaw = Buffer.from(pinned.key, 'base64'); + if (keyRaw.length !== 32) { + throw new Error(`Pinned KEK public key for ${env} is not a 32-byte key`); + } + return { version: pinned.version, keyRaw }; +} + +/** + * Wrap a DEK for the given KEK public key (X25519 sealed box). Produces base64 of + * `ephPub(32) || iv(12) || ciphertext(32) || tag(16)`. + */ +export function wrapDek(dek: Buffer, kek: KekPublicKey): BinaryEnvelope { + const eph = generateKeyPairSync('x25519'); + const ephPubRaw = eph.publicKey + .export({ type: 'spki', format: 'der' }) + .subarray(SPKI_PREFIX.length); + const shared = diffieHellman({ + privateKey: eph.privateKey, + publicKey: x25519PublicFromRaw(kek.keyRaw), + }); + const key = Buffer.from( + hkdfSync('sha256', shared, Buffer.alloc(0), HKDF_INFO, 32), + ); + const iv = randomBytes(NONCE_LEN); + const cipher = createCipheriv('aes-256-gcm', key, iv); + const ct = Buffer.concat([cipher.update(dek), cipher.final()]); + const wrapped = Buffer.concat([ + ephPubRaw, + iv, + ct, + cipher.getAuthTag(), + ]).toString('base64'); + return { v: CONTAINER_VERSION, kek: kek.version, wrapped_key: wrapped }; +} + +function segmentNonce( + prefix: Buffer, + index: number, + isLast: boolean, +): Buffer { + const nonce = Buffer.alloc(NONCE_LEN); + prefix.copy(nonce, 0, 0, NONCE_PREFIX_LEN); + nonce.writeUInt32BE(index, NONCE_PREFIX_LEN); + nonce.writeUInt8(isLast ? 1 : 0, NONCE_PREFIX_LEN + 4); + return nonce; +} + +/** + * Stream-encrypt `srcPath` into a DCDE container at `destPath` using `dek`, in + * constant memory (one chunk buffered at a time). `kekVersion` is written into + * the header (mirrors the wrapped-key's KEK version). + */ +export async function encryptFileToPath( + srcPath: string, + destPath: string, + dek: Buffer, + kekVersion: number, + chunkSize: number = CHUNK_SIZE, +): Promise { + if (dek.length !== DEK_LEN) { + throw new Error(`DEK must be ${DEK_LEN} bytes`); + } + const { size } = await stat(srcPath); + const noncePrefix = randomBytes(NONCE_PREFIX_LEN); + + const header = Buffer.alloc(17); + MAGIC.copy(header, 0); + header.writeUInt8(CONTAINER_VERSION, 4); + header.writeUInt8(kekVersion, 5); + header.writeUInt32BE(chunkSize, 6); + noncePrefix.copy(header, 10); + + const input = await open(srcPath, 'r'); + const output = await open(destPath, 'w'); + try { + await output.write(header); + const buf = Buffer.alloc(chunkSize); + let index = 0; + let readTotal = 0; + // size 0 → no segments (matches the API's empty-input handling). + while (readTotal < size) { + const { bytesRead } = await input.read(buf, 0, chunkSize, null); + if (bytesRead === 0) break; + readTotal += bytesRead; + const isLast = readTotal >= size; + const cipher = createCipheriv( + 'aes-256-gcm', + dek, + segmentNonce(noncePrefix, index, isLast), + ); + const ct = Buffer.concat([ + cipher.update(buf.subarray(0, bytesRead)), + cipher.final(), + ]); + await output.write(ct); + await output.write(cipher.getAuthTag()); + index += 1; + if (isLast) break; + } + } finally { + await input.close(); + await output.close(); + } +} + +/** + * In-memory twin of {@link encryptFileToPath}: encrypt `plaintext` into a DCDE + * container Buffer using `dek`. Byte-identical wire format (same 17-byte header, + * same per-segment nonce/tag scheme), for payloads already held in memory (flow + * zips, the env map). A payload no larger than `chunkSize` is a single segment. + */ +export function encryptToContainer( + plaintext: Buffer, + dek: Buffer, + kekVersion: number, + chunkSize: number = CHUNK_SIZE, +): Buffer { + if (dek.length !== DEK_LEN) { + throw new Error(`DEK must be ${DEK_LEN} bytes`); + } + const noncePrefix = randomBytes(NONCE_PREFIX_LEN); + + const header = Buffer.alloc(17); + MAGIC.copy(header, 0); + header.writeUInt8(CONTAINER_VERSION, 4); + header.writeUInt8(kekVersion, 5); + header.writeUInt32BE(chunkSize, 6); + noncePrefix.copy(header, 10); + + const parts: Buffer[] = [header]; + // size 0 → no segments (matches encryptFileToPath / the API's empty input). + let index = 0; + let offset = 0; + while (offset < plaintext.length) { + const end = Math.min(offset + chunkSize, plaintext.length); + const isLast = end >= plaintext.length; + const cipher = createCipheriv( + 'aes-256-gcm', + dek, + segmentNonce(noncePrefix, index, isLast), + ); + const ct = Buffer.concat([ + cipher.update(plaintext.subarray(offset, end)), + cipher.final(), + ]); + parts.push(ct, cipher.getAuthTag()); + offset = end; + index += 1; + } + return Buffer.concat(parts); +} + +/** + * Encrypt a flow zip buffer with its own per-upload DEK. Returns the ciphertext + * (a DCDE container) plus the envelope for `uploads.metadata.enc`. `uploads.sha` + * must be recomputed from the returned ciphertext by the caller. + */ +export function encryptFlowBuffer( + buffer: Buffer, + kek: KekPublicKey, +): { ciphertext: Buffer; enc: BinaryEnvelope } { + const dek = generateDek(); + const enc = wrapDek(dek, kek); + const ciphertext = encryptToContainer(buffer, dek, kek.version); + return { ciphertext, enc }; +} + +/** + * Encrypt the `--env KEY=VALUE` map with its own per-submission DEK into the + * `results.env.enc` envelope (#1152). The full map is serialized, encrypted as a + * single-segment container, and carried inline as base64 `ciphertext`. + */ +export function encryptEnv( + env: Record, + kek: KekPublicKey, +): EnvEnvelope { + const dek = generateDek(); + const { wrapped_key } = wrapDek(dek, kek); + const container = encryptToContainer( + Buffer.from(JSON.stringify(env), 'utf8'), + dek, + kek.version, + ); + return { + v: CONTAINER_VERSION, + kek: kek.version, + wrapped_key, + ciphertext: container.toString('base64'), + }; +} + +/** Generate a fresh per-upload DEK. */ +export function generateDek(): Buffer { + return randomBytes(DEK_LEN); +} diff --git a/src/utils/progress.ts b/src/utils/progress.ts index c4d33ed..fcf8d45 100644 --- a/src/utils/progress.ts +++ b/src/utils/progress.ts @@ -3,25 +3,53 @@ * drop-in API for existing services that used oclif's `ux.action` / `ux.info`. * * Keeps call sites unchanged while migrating away from @oclif/core. + * + * TTY-awareness: @clack/prompts' spinner animates on a timer and, when stdout + * isn't a TTY (CI, pipes, redirects), it can't rewrite a line in place — every + * frame becomes a fresh line, flooding logs with hundreds of duplicates. In + * non-interactive environments we skip the spinner entirely and instead print a + * plain line once per *distinct* status, so CI logs show real progress without + * the flood. */ import * as p from '@clack/prompts'; +import { isCI } from './ci.js'; + type ClackSpinner = ReturnType; class Action { private current: ClackSpinner | null = null; private _status = ''; + // Last line emitted in non-interactive mode, for de-duplication. + private _lastPrinted = ''; + + private interactive(): boolean { + return process.stdout.isTTY === true && !isCI(); + } start(title: string, initialStatus?: string, _opts?: unknown): void { + this._status = initialStatus ?? ''; + const line = initialStatus ? `${title} — ${initialStatus}` : title; + if (!this.interactive()) { + this.current = null; + this.print(line); + return; + } if (this.current) { this.current.stop(); } this.current = p.spinner(); - this._status = initialStatus ?? ''; - this.current.start(initialStatus ? `${title} — ${initialStatus}` : title); + this.current.start(line); } stop(message?: string): void { + if (!this.interactive()) { + if (message) this.print(message); + this.current = null; + this._status = ''; + this._lastPrinted = ''; + return; + } if (!this.current) { if (message) { // eslint-disable-next-line no-console @@ -36,7 +64,12 @@ class Action { set status(value: string) { this._status = value; - if (this.current && value) { + if (!value) return; + if (!this.interactive()) { + this.print(value); + return; + } + if (this.current) { this.current.message(value); } } @@ -44,6 +77,15 @@ class Action { get status(): string { return this._status; } + + // Emit a line only when it differs from the previous one, so repeated polls + // with no state change stay quiet. + private print(line: string): void { + if (line === this._lastPrinted) return; + this._lastPrinted = line; + // eslint-disable-next-line no-console + console.log(line); + } } export const ux = { diff --git a/src/utils/styling.ts b/src/utils/styling.ts index 78a3625..53d1e7b 100644 --- a/src/utils/styling.ts +++ b/src/utils/styling.ts @@ -16,6 +16,7 @@ export const stripAnsi = (s: string): string => s.replace(/\u001B\[[0-9;]*m/g, ' */ export const symbols = { cancelled: chalk.gray('⊘'), + deprecation: chalk.red('⚠'), error: chalk.red('✗'), info: chalk.blue('ℹ'), pending: chalk.yellow('⏸'), diff --git a/src/utils/ui.ts b/src/utils/ui.ts index 3977616..7b2b141 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -70,6 +70,11 @@ export const ui = { }); }, + /** `⚠ message` in red — a deprecation; reads as more serious than a (yellow) {@link warn}. */ + deprecation(message: string): string { + return `${symbols.deprecation} ${message}`; + }, + /** `ℹ message` — neutral, standalone information. */ info(message: string): string { return `${symbols.info} ${message}`; diff --git a/test/fixtures/basic-config.yaml b/test/fixtures/basic-config.yaml index 0671d08..3c49ac4 100644 --- a/test/fixtures/basic-config.yaml +++ b/test/fixtures/basic-config.yaml @@ -1,8 +1,7 @@ flows: - ./**/*.yaml - ./*.yaml -tags: - include: - - smoke - exclude: - - slow \ No newline at end of file +includeTags: + - smoke +excludeTags: + - slow diff --git a/test/fixtures/tag-filtering-config.yaml b/test/fixtures/tag-filtering-config.yaml index c08d93c..bb28996 100644 --- a/test/fixtures/tag-filtering-config.yaml +++ b/test/fixtures/tag-filtering-config.yaml @@ -1,8 +1,7 @@ flows: - ./**/*.yaml -tags: - include: - - smoke - exclude: - - slow - - integration \ No newline at end of file +includeTags: + - smoke +excludeTags: + - slow + - integration diff --git a/test/integration/cloud.integration.test.ts b/test/integration/cloud.integration.test.ts index e87f06f..39d77d5 100644 --- a/test/integration/cloud.integration.test.ts +++ b/test/integration/cloud.integration.test.ts @@ -163,6 +163,32 @@ appId: com.example.app }); }); + // #1105 device matrix: repeated --ios-device-matrix / --android-device-matrix cells. + describe('device matrix', () => { + it('accepts a repeated --ios-device-matrix matrix and still yields one upload', async () => { + const command = `${CLI} cloud ${iosAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --ios-device-matrix iphone-16:18 --ios-device-matrix iphone-16-pro:26 --async --json`; + + const { stdout } = await exec(command, { timeout: 30_000 }); + // Still one upload with one uploadId — the matrix is a property of the + // upload, not N uploads. + expectAsyncRunJson(stdout); + }); + + it('rejects mixing --ios-device-matrix and --android-device-matrix before any upload', async () => { + const command = `${CLI} cloud ${iosAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --ios-device-matrix iphone-16:18 --android-device-matrix pixel-7:34`; + + const { output } = await runExpectingFailure(command); + expect(output.toLowerCase()).to.include('cannot mix platforms'); + }); + + it('rejects a malformed --ios-device-matrix, naming the value', async () => { + const command = `${CLI} cloud ${iosAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --ios-device-matrix iphone-16`; + + const { output } = await runExpectingFailure(command); + expect(output).to.include('iphone-16'); + }); + }); + describe('device configuration options', () => { // Async non-JSON runs always reach submission against the mock API. // `.include` keeps these robust to incidental extra lines (e.g. the @@ -208,7 +234,7 @@ appId: com.example.app }; it('should support custom Maestro versions', async () => { - const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --maestro-version 1.39.5 --name test-maestro-version --async`; + const command = `${CLI} cloud ${androidAppFile} ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --maestro-version 2.2.0 --name test-maestro-version --async`; const { stdout } = await exec(command, { timeout: 15_000 }); expectAsyncSubmission(stdout); @@ -261,6 +287,211 @@ appId: com.example.app }); }); + // Regression cover for dcd-cli#99: config files were excluded from flow + // discovery by *filename* (literal config.yaml/config.yml plus the exact + // --config target), so a second workspace config sharing the folder was + // parsed as a flow and blew up with "Expected an array of steps". + // These must pass the flow *directory* — the --config tests below pass a + // single file, which short-circuits into planSingleFile and never reaches + // flow discovery. + describe('workspace config discovery', () => { + let workspaceDir: string; + let buildConfig: string; + let updateConfig: string; + let globConfig: string; + + before(() => { + workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-test-ws-')); + + fs.writeFileSync( + path.join(workspaceDir, 'flow.yaml'), + `appId: com.example.app +--- +- launchApp +- tapOn: "Login" +`, + ); + + // A genuine flow whose name merely ends in "config.yaml". The old + // unanchored endsWith() dropped it; it must run. + fs.writeFileSync( + path.join(workspaceDir, 'smoke-config.yaml'), + `appId: com.example.app +name: smoke-config +--- +- launchApp +`, + ); + + // Two sibling workspace configs with custom names, as a folder serving + // several CI workflows would have. Neither is a flow. + buildConfig = path.join(workspaceDir, 'config_build.yml'); + fs.writeFileSync( + buildConfig, + `flows: + - ./**/*.yaml +includeTags: + - build +`, + ); + + // Deliberately carries no tag or flow filtering, so when it is the active + // --config it can't mask the bug by filtering its sibling away first. + updateConfig = path.join(workspaceDir, 'config_update.yml'); + fs.writeFileSync( + updateConfig, + `platform: + android: + disableAnimations: true +`, + ); + + globConfig = path.join(workspaceDir, 'config_glob.yml'); + fs.writeFileSync( + globConfig, + `flows: + - ./**/*.yaml + - ./**/*.yml +`, + ); + }); + + after(() => { + if (fs.existsSync(workspaceDir)) { + fs.rmSync(workspaceDir, { force: true, recursive: true }); + } + }); + + it('should ignore sibling config files not named in --config', async () => { + const command = `${CLI} cloud ${androidAppFile} "${workspaceDir}" --api-key ${mockApiKey} --api-url ${mockApiUrl} --config "${updateConfig}" --dry-run`; + + const { stdout } = await exec(command, { timeout: 15_000 }); + expect(stdout).to.include('The following tests would have been run'); + expect(stdout).to.include('flow.yaml'); + expect(stdout).to.not.include('config_build.yml'); + expect(stdout).to.not.include('config_glob.yml'); + expect(stdout).to.not.include('Expected an array of steps'); + }); + + it('should ignore config-shaped files when no --config is passed', async () => { + const command = `${CLI} cloud ${androidAppFile} "${workspaceDir}" --api-key ${mockApiKey} --api-url ${mockApiUrl} --debug --dry-run`; + + const { stdout } = await exec(command, { timeout: 15_000 }); + expect(stdout).to.include('flow.yaml'); + expect(stdout).to.include('[DEBUG] Skipping 3 workspace config file(s)'); + expect(stdout).to.not.include('Expected an array of steps'); + }); + + it('should ignore config-shaped files matched by a flows glob', async () => { + const command = `${CLI} cloud ${androidAppFile} "${workspaceDir}" --api-key ${mockApiKey} --api-url ${mockApiUrl} --config "${globConfig}" --dry-run`; + + const { stdout } = await exec(command, { timeout: 15_000 }); + expect(stdout).to.include('flow.yaml'); + expect(stdout).to.not.include('config_build.yml'); + expect(stdout).to.not.include('config_update.yml'); + expect(stdout).to.not.include('Expected an array of steps'); + }); + + it('should run a flow whose filename merely ends in config.yaml', async () => { + const command = `${CLI} cloud ${androidAppFile} "${workspaceDir}" --api-key ${mockApiKey} --api-url ${mockApiUrl} --config "${updateConfig}" --dry-run`; + + const { stdout } = await exec(command, { timeout: 15_000 }); + expect(stdout).to.include('smoke-config.yaml'); + }); + + it('should reject a custom-named config passed as the flow input', async () => { + const command = `${CLI} cloud ${androidAppFile} "${buildConfig}" --api-key ${mockApiKey} --api-url ${mockApiUrl} --dry-run`; + + const { output } = await runExpectingFailure(command); + expect(output).to.include('pass the workspace folder path'); + expect(output).to.not.include('Expected an array of steps'); + }); + }); + + // dcd-cli#110: a `config.yaml` whose executionOrder was the wrong *shape* was + // silently ignored and every flow ran in parallel — same cost, wrong + // semantics, green run. These assert the shape is now validated. + describe('executionOrder validation', () => { + let workspaceDir: string; + + /** Point the run at a workspace whose config.yaml holds `configBody`. */ + const commandWithConfig = (configBody: string): string => { + fs.writeFileSync(path.join(workspaceDir, 'config.yaml'), configBody); + return `${CLI} cloud ${androidAppFile} "${workspaceDir}" --api-key ${mockApiKey} --api-url ${mockApiUrl} --dry-run`; + }; + + before(() => { + workspaceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dcd-test-order-')); + + for (const name of ['a', 'b', 'c']) { + fs.writeFileSync( + path.join(workspaceDir, `${name}.yaml`), + `appId: com.example.app +--- +- launchApp +`, + ); + } + }); + + after(() => { + if (fs.existsSync(workspaceDir)) { + fs.rmSync(workspaceDir, { force: true, recursive: true }); + } + }); + + it('should reject a bare-list executionOrder instead of running in parallel', async () => { + const command = commandWithConfig(`executionOrder: + - a.yaml + - b.yaml + - c.yaml +`); + + const { output } = await runExpectingFailure(command); + expect(output).to.include('Invalid `executionOrder`'); + expect(output).to.include('flowsOrder'); + // The whole point: it must not quietly proceed to a parallel run. + expect(output).to.not.include('The following tests would have been run'); + }); + + it('should reject an executionOrder map with no flowsOrder', async () => { + const command = commandWithConfig(`executionOrder: + continueOnFailure: true +`); + + const { output } = await runExpectingFailure(command); + expect(output).to.include('Invalid `executionOrder`'); + expect(output).to.include('no `flowsOrder` key'); + }); + + it('should sequence flows for a well-formed executionOrder', async () => { + const command = commandWithConfig(`executionOrder: + continueOnFailure: true + flowsOrder: + - a.yaml + - b.yaml +`); + + const { stdout } = await exec(command, { timeout: 15_000 }); + expect(stdout).to.include('Sequential flows'); + expect(stdout).to.include('a.yaml'); + expect(stdout).to.include('b.yaml'); + }); + + it('should warn about unrecognised config keys without failing the run', async () => { + const command = commandWithConfig(`flowOrder: + - a.yaml +flowTimeout: 120000 +`); + + const { stdout, stderr } = await exec(command, { timeout: 15_000 }); + expect(stdout).to.include('The following tests would have been run'); + expect(stderr).to.include('flowOrder'); + expect(stderr).to.include('executionOrder.flowsOrder'); + expect(stderr).to.include('flowTimeout'); + }); + }); + describe('file and binary management', () => { it('should support app binary ID instead of file', async () => { const command = `${CLI} cloud --app-binary-id test-binary-123 ${testFlowFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --dry-run`; diff --git a/test/integration/helpers.ts b/test/integration/helpers.ts index d60eeb1..0c998f9 100644 --- a/test/integration/helpers.ts +++ b/test/integration/helpers.ts @@ -7,15 +7,54 @@ * assert the success path unconditionally: a dead or missing mock API must * fail the suite, never soften it. */ -import { exec as execCallback } from 'node:child_process'; +import { execFile as execFileCallback } from 'node:child_process'; import * as path from 'node:path'; import { promisify } from 'node:util'; -export const exec = promisify(execCallback); +const execFileAsync = promisify(execFileCallback); /** Absolute path to the built CLI so tests can run with any cwd. */ export const CLI = path.resolve('dist/index.js'); +export interface ExecResult { + stdout: string; + stderr: string; +} + +/** + * Split a `${CLI} …` command line into argv, honouring single/double quotes so + * a quoted multi-word value stays one token. These test commands contain no + * other shell metacharacters, so this is exact for our use. + */ +function tokenize(command: string): string[] { + const tokens: string[] = []; + const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(command)) !== null) { + tokens.push(match[1] ?? match[2] ?? match[3]); + } + return tokens; +} + +/** + * Run a test command **without a shell**. Every integration command is + * `${CLI} ` — an absolute script path plus arguments — so we tokenise it + * and invoke the current Node binary directly with the script and args as argv + * (`execFile`, never `exec`). Passing an argument list instead of a shell string + * is CodeQL's recommended fix for `js/shell-command-injection-from-environment`: + * with no shell there is nothing for the (uncontrolled, but trusted) absolute + * paths to inject into. Signature-compatible with the previous + * `promisify(child_process.exec)` so no call site changes. + */ +export async function exec( + command: string, + opts: { cwd?: string; env?: NodeJS.ProcessEnv; timeout?: number } = {}, +): Promise { + const argv = tokenize(command); + const { stdout, stderr } = await execFileAsync(process.execPath, argv, opts); + return { stdout: String(stdout), stderr: String(stderr) }; +} + export const MOCK_API_URL = process.env.MOCK_API_URL ?? 'http://localhost:3001'; /** One of the keys accepted by the mock API's auth shim. */ diff --git a/test/integration/upload.integration.test.ts b/test/integration/upload.integration.test.ts index ceadc8a..b41dc8e 100644 --- a/test/integration/upload.integration.test.ts +++ b/test/integration/upload.integration.test.ts @@ -188,20 +188,14 @@ describe('Upload Command Integration Tests', () => { expect(stdout).to.include('skipping upload'); }); - it('should attempt a real upload when the SHA check is bypassed', async () => { + it('should perform a real upload when the SHA check is bypassed', async () => { const command = `${CLI} upload ${androidAppFile} --api-key ${mockApiKey} --api-url ${mockApiUrl} --ignore-sha-check --json`; - // Bypassing dedup makes the CLI upload to the storage URLs from the - // mock's example response, which point at real (unwritable) hosts — - // so this deterministically fails after attempting every upload path. - // It still verifies --ignore-sha-check skips the dedup short-circuit. - const { code, stdout } = await runExpectingFailure(command, { - timeout: 60_000, - }); - expect(code).to.equal(1); - const result = JSON.parse(stdout); - expect(result).to.have.property('status', 'FAILED'); - expect(result.error).to.include('All uploads failed'); + // --ignore-sha-check skips the dedup short-circuit and performs a real + // upload. The mock returns a valid `uploads/` staging path, so the TUS + // fallback upload succeeds and the command returns the new binary id. + const { stdout } = await exec(command, { timeout: 60_000 }); + expectUploadJson(stdout); }); }); }); diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts index 6140715..89ed4dd 100644 --- a/test/unit/auth.test.ts +++ b/test/unit/auth.test.ts @@ -3,7 +3,8 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { resolveAuth } from '../../src/utils/auth.js'; +import { SessionRefreshError } from '../../src/gateways/cli-auth-gateway.js'; +import { dropStoredSession, resolveAuth } from '../../src/utils/auth.js'; import { clearConfig, configFileMode, @@ -181,6 +182,48 @@ describe('resolveAuth precedence', () => { }); }); + it('surfaces definitive vs transient refresh failures via SessionRefreshError', () => { + const dead = new SessionRefreshError('Invalid Refresh Token: Already Used', true); + const blip = new SessionRefreshError('fetch failed', false); + expect(dead.definitive).to.equal(true); + expect(blip.definitive).to.equal(false); + expect(dead).to.be.instanceOf(Error); + }); + + it('dropStoredSession removes only the session, keeping env/org fields', async () => { + await withTempConfigDir(() => { + writeConfig({ + version: 1, + env: 'dev', + api_url: 'https://api.dev.devicecloud.dev', + supabase_url: 'https://lbmsowehtjwnqlurpemb.supabase.co', + session: { + access_token: 'a', + refresh_token: 'consumed-by-another-client', + expires_at: Math.floor(Date.now() / 1000) - 60, + user_email: 'u@example.com', + user_id: 'u1', + }, + current_org_id: '42', + current_org_name: 'Acme', + }); + + dropStoredSession(); + + const after = readConfig(); + expect(after).to.not.equal(null); + expect(after!.session).to.equal(undefined); + expect(after!.env).to.equal('dev'); + expect(after!.api_url).to.equal('https://api.dev.devicecloud.dev'); + expect(after!.current_org_id).to.equal('42'); + expect(after!.current_org_name).to.equal('Acme'); + + // Idempotent when there's no session to drop. + dropStoredSession(); + expect(readConfig()!.session).to.equal(undefined); + }); + }); + it('uses a non-expired stored session as a last resort', async () => { await withTempConfigDir(async () => { writeConfig({ diff --git a/test/unit/device-matrix.test.ts b/test/unit/device-matrix.test.ts new file mode 100644 index 0000000..20476a1 --- /dev/null +++ b/test/unit/device-matrix.test.ts @@ -0,0 +1,95 @@ +import { expect } from 'chai'; + +import { CliError } from '../../src/utils/cli.js'; +import { + assertMatrixSupported, + matrixIsIos, + parseDeviceMatrix, +} from '../../src/utils/device-matrix.js'; + +/** + * The device matrix is the load-bearing part of #1105: each --ios-device-matrix / + * --android-device-matrix names exactly one cell, there is no cross-product, and a + * matrix is single-platform. These are pure and worth pinning precisely. + */ +describe('parseDeviceMatrix', () => { + it('returns an empty matrix when no config flags are passed', () => { + expect(parseDeviceMatrix([], [])).to.deep.equal([]); + expect(matrixIsIos([])).to.equal(false); + }); + + it('parses each --ios-device-matrix as exactly one cell (no cross-product)', () => { + const matrix = parseDeviceMatrix( + ['iphone-16:18', 'iphone-16-pro:26'], + [], + ); + expect(matrix).to.deep.equal([ + { iOSDevice: 'iphone-16', iOSVersion: '18' }, + { iOSDevice: 'iphone-16-pro', iOSVersion: '26' }, + ]); + expect(matrixIsIos(matrix)).to.equal(true); + }); + + it('parses --android-device-matrix, with :play marking a Google Play cell', () => { + expect(parseDeviceMatrix([], ['pixel-7:34', 'pixel-7:34:play'])).to.deep.equal([ + { androidDevice: 'pixel-7', androidApiLevel: '34' }, + { androidDevice: 'pixel-7', androidApiLevel: '34', googlePlay: true }, + ]); + }); + + it('rejects a mixed-platform matrix', () => { + expect(() => + parseDeviceMatrix(['iphone-16:18'], ['pixel-7:34']), + ).to.throw(CliError, /cannot mix platforms/i); + }); + + it('rejects malformed iOS syntax, naming the offending value', () => { + expect(() => parseDeviceMatrix(['iphone-16'], [])).to.throw( + CliError, + /iphone-16/, + ); + expect(() => parseDeviceMatrix(['iphone-16:'], [])).to.throw(CliError); + expect(() => parseDeviceMatrix([':18'], [])).to.throw(CliError); + }); + + it('rejects malformed Android syntax and a bad third segment', () => { + expect(() => parseDeviceMatrix([], ['pixel-7'])).to.throw(CliError); + expect(() => parseDeviceMatrix([], ['pixel-7:34:store'])).to.throw( + CliError, + /pixel-7:34:store/, + ); + }); +}); + +/** + * An API that predates the matrix silently STRIPS the unknown deviceMatrix + * field (its ValidationPipe is whitelist:true / forbidNonWhitelisted:false) and + * runs every flow on one default device, exiting 0. Submitting into that is the + * worst outcome the feature can produce, so it must be refused, not tolerated. + */ +describe('assertMatrixSupported', () => { + const matrix = [{ iOSDevice: 'iphone-16', iOSVersion: '18' }]; + + it('throws when a matrix was requested but the API has no estimate endpoint', () => { + expect(() => assertMatrixSupported(matrix, null)).to.throw( + CliError, + /does not support device matrices/i, + ); + }); + + it('explains that submitting anyway would silently run a single device', () => { + expect(() => assertMatrixSupported(matrix, null)).to.throw( + /silently run every flow on a single default device/i, + ); + }); + + it('passes when the API returned an estimate', () => { + expect(() => + assertMatrixSupported(matrix, { cellCount: 2, totalCost: 0.16 }), + ).to.not.throw(); + }); + + it('is a no-op when no matrix was requested (legacy single-device runs)', () => { + expect(() => assertMatrixSupported([], null)).to.not.throw(); + }); +}); diff --git a/test/unit/encrypted-dedup.test.ts b/test/unit/encrypted-dedup.test.ts new file mode 100644 index 0000000..47e4c5c --- /dev/null +++ b/test/unit/encrypted-dedup.test.ts @@ -0,0 +1,375 @@ +import { expect } from 'chai'; +import { generateKeyPairSync } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { ApiGateway } from '../../src/gateways/api-gateway.js'; +import { uploadBinary } from '../../src/methods.js'; +import type { AuthContext } from '../../src/types/domain/auth.types.js'; + +/** + * Dedup for client-side encrypted binaries (dcd#1168). + * + * Encryption uses a fresh random DEK per upload, so the ciphertext hash differs + * every time and cannot serve as a dedup key. The CLI therefore hashes the + * PLAINTEXT first, deduplicates on that, and only encrypts on a miss. + * + * The security-critical half is the invariant check: a plaintext row has the same + * plaintext hash as its encrypted twin, so a dedup hit must be rejected unless + * the server confirms the matched binary is itself encrypted. + */ + +const TEST_AUTH: AuthContext = { + mode: 'apiKey', + headers: { 'x-app-api-key': 'test-key' }, +}; + +const API = 'http://localhost:9999'; +const APK = path.join(process.cwd(), 'test/fixtures/wikipedia.apk'); + +const originalFetch = (global as any).fetch; + +type Call = { body: unknown; url: string }; + +/** + * Mock global.fetch with a per-endpoint handler map, recording every call. + * Any endpoint without a handler resolves to a 500 carrying a marker string, so + * a test can assert control reached it. + * @param handlers Map of URL substring to a response factory + * @returns The recorded call list + */ +function mockFetch( + handlers: Record { body: unknown; status: number }>, +): Call[] { + const calls: Call[] = []; + (global as any).fetch = async ( + input: URL | string, + init?: RequestInit, + ): Promise => { + const url = input.toString(); + calls.push({ + body: init?.body ? JSON.parse(String(init.body)) : null, + url, + }); + + const key = Object.keys(handlers).find((k) => url.includes(k)); + const { body, status } = key + ? handlers[key]() + : { body: { message: 'REACHED_UPLOAD_PATH' }, status: 500 }; + + return new Response(JSON.stringify(body), { + headers: { 'content-type': 'application/json' }, + status, + }); + }; + return calls; +} + +/** A throwaway X25519 public key so encryption can run without a pinned KEK. */ +function setTestKek() { + const { publicKey } = generateKeyPairSync('x25519'); + const raw = publicKey.export({ format: 'der', type: 'spki' }).subarray(12); + process.env.DCD_BINARY_KEK_PUBLIC = `1:${raw.toString('base64')}`; +} + +describe('encrypted binary dedup (#1168)', () => { + afterEach(() => { + (global as any).fetch = originalFetch; + delete process.env.DCD_BINARY_KEK_PUBLIC; + }); + + describe('ApiGateway.checkForExistingUpload wire format', () => { + it('sends shaPlain + encrypted (and NOT sha) for an encrypted lookup', async () => { + const calls = mockFetch({ + checkForExistingUpload: () => ({ + body: { appBinaryId: 'enc-binary', encrypted: true, exists: true }, + status: 200, + }), + }); + + const res = await ApiGateway.checkForExistingUpload(API, TEST_AUTH, { + encrypted: true, + shaPlain: 'plain-hash', + }); + + expect(calls[0].body).to.deep.equal({ + encrypted: true, + shaPlain: 'plain-hash', + }); + // The ciphertext hash is not known at dedup time and must not be implied. + expect(calls[0].body).to.not.have.property('sha'); + expect(res.encrypted).to.equal(true); + expect(res.appBinaryId).to.equal('enc-binary'); + }); + + it('sends a bare sha for an unencrypted lookup', async () => { + const calls = mockFetch({ + checkForExistingUpload: () => ({ + body: { appBinaryId: 'plain-binary', encrypted: false, exists: true }, + status: 200, + }), + }); + + await ApiGateway.checkForExistingUpload(API, TEST_AUTH, { + sha: 'cipher-or-plain-hash', + }); + + expect(calls[0].body).to.deep.equal({ sha: 'cipher-or-plain-hash' }); + }); + + it('still accepts a bare string sha (back-compat with the old signature)', async () => { + const calls = mockFetch({ + checkForExistingUpload: () => ({ + body: { appBinaryId: 'b', exists: true }, + status: 200, + }), + }); + + await ApiGateway.checkForExistingUpload(API, TEST_AUTH, 'legacy-sha'); + + expect(calls[0].body).to.deep.equal({ sha: 'legacy-sha' }); + }); + }); + + describe('uploadBinary dedup behaviour', () => { + it('reuses an encrypted match without encrypting or uploading', async () => { + setTestKek(); + const calls = mockFetch({ + checkForExistingUpload: () => ({ + body: { appBinaryId: 'enc-binary', encrypted: true, exists: true }, + status: 200, + }), + }); + + const id = await uploadBinary({ + apiUrl: API, + auth: TEST_AUTH, + encrypt: true, + filePath: APK, + log: false, + }); + + expect(id).to.equal('enc-binary'); + // Nothing beyond the dedup check should have been attempted. + expect(calls).to.have.lengthOf(1); + expect(calls[0].url).to.contain('checkForExistingUpload'); + }); + + it('REJECTS a plaintext match when encryption was requested', async () => { + // The trap: the plaintext row has the same plaintext hash, so the server + // could answer with it. Honouring that hit would return an unencrypted + // binary to a caller who asked for encryption. + setTestKek(); + const calls = mockFetch({ + checkForExistingUpload: () => ({ + body: { appBinaryId: 'plain-binary', encrypted: false, exists: true }, + status: 200, + }), + }); + + let returned: string | undefined; + try { + returned = await uploadBinary({ + apiUrl: API, + auth: TEST_AUTH, + encrypt: true, + filePath: APK, + log: false, + }); + } catch { + // The mocked upload path fails; reaching it at all is the assertion. + } + + // Proceeded past dedup into the upload path rather than silently handing + // back the unencrypted binary. + expect(returned).to.equal(undefined); + expect(calls.some((c) => c.url.includes('getBinaryUploadUrl'))).to.equal( + true, + ); + }); + + it('also rejects a hit when the server omits the encrypted field entirely', async () => { + // An older deployment predating #1168 has no encryption predicate on the + // lookup, so the absence of confirmation must be treated as a miss. + setTestKek(); + const calls = mockFetch({ + checkForExistingUpload: () => ({ + body: { appBinaryId: 'unknown-binary', exists: true }, + status: 200, + }), + }); + + let returned: string | undefined; + try { + returned = await uploadBinary({ + apiUrl: API, + auth: TEST_AUTH, + encrypt: true, + filePath: APK, + log: false, + }); + } catch { + // As above — the mocked upload path fails by design. + } + + expect(returned).to.equal(undefined); + expect(calls.some((c) => c.url.includes('getBinaryUploadUrl'))).to.equal( + true, + ); + }); + + it('dedups on the plaintext hash, so the key is stable across encrypted runs', async () => { + setTestKek(); + const seen: unknown[] = []; + mockFetch({ + checkForExistingUpload: () => ({ + body: { appBinaryId: 'enc-binary', encrypted: true, exists: true }, + status: 200, + }), + }); + + for (let i = 0; i < 2; i++) { + + await uploadBinary({ + apiUrl: API, + auth: TEST_AUTH, + encrypt: true, + filePath: APK, + log: false, + }); + } + + // Reset and capture the lookup keys from two independent invocations. + const calls = mockFetch({ + checkForExistingUpload: () => ({ + body: { appBinaryId: 'enc-binary', encrypted: true, exists: true }, + status: 200, + }), + }); + await uploadBinary({ + apiUrl: API, + auth: TEST_AUTH, + encrypt: true, + filePath: APK, + log: false, + }); + await uploadBinary({ + apiUrl: API, + auth: TEST_AUTH, + encrypt: true, + filePath: APK, + log: false, + }); + for (const c of calls) { + seen.push((c.body as { shaPlain?: string }).shaPlain); + } + + expect(seen).to.have.lengthOf(2); + expect(seen[0]).to.be.a('string'); + // Identical input ⇒ identical lookup key, which is the whole point: the + // ciphertext hash would have differed on every run. + expect(seen[0]).to.equal(seen[1]); + }); + + it('leaves the unencrypted path deduping on the sha exactly as before', async () => { + const calls = mockFetch({ + checkForExistingUpload: () => ({ + body: { appBinaryId: 'plain-binary', encrypted: false, exists: true }, + status: 200, + }), + }); + + const id = await uploadBinary({ + apiUrl: API, + auth: TEST_AUTH, + encrypt: false, + filePath: APK, + log: false, + }); + + expect(id).to.equal('plain-binary'); + expect(calls[0].body).to.have.property('sha'); + expect(calls[0].body).to.not.have.property('encrypted'); + expect(calls[0].body).to.not.have.property('shaPlain'); + }); + + it('honours --ignore-sha-check by skipping the lookup altogether', async () => { + setTestKek(); + const calls = mockFetch({}); + + try { + await uploadBinary({ + apiUrl: API, + auth: TEST_AUTH, + encrypt: true, + filePath: APK, + ignoreShaCheck: true, + log: false, + }); + } catch { + // Upload path is mocked to fail; only the absence of a lookup matters. + } + + expect(calls.some((c) => c.url.includes('checkForExistingUpload'))).to.equal( + false, + ); + }); + }); + + describe('finalise payload', () => { + it('sends sha (ciphertext) alongside shaPlain for an encrypted upload', async () => { + const calls = mockFetch({ + finaliseUpload: () => ({ body: {}, status: 200 }), + }); + + await ApiGateway.finaliseUpload({ + auth: TEST_AUTH, + backblazeSuccess: true, + baseUrl: API, + bytes: 10, + id: 'upload-id', + + metadata: {} as any, + path: 'p', + sha: 'ciphertext-hash', + shaPlain: 'plaintext-hash', + supabaseSuccess: true, + }); + + expect(calls[0].body).to.include({ + sha: 'ciphertext-hash', + shaPlain: 'plaintext-hash', + }); + }); + + it('omits shaPlain for an unencrypted upload', async () => { + const calls = mockFetch({ + finaliseUpload: () => ({ body: {}, status: 200 }), + }); + + await ApiGateway.finaliseUpload({ + auth: TEST_AUTH, + backblazeSuccess: true, + baseUrl: API, + bytes: 10, + id: 'upload-id', + + metadata: {} as any, + path: 'p', + sha: 'plain-hash', + supabaseSuccess: true, + }); + + expect(calls[0].body).to.not.have.property('shaPlain'); + }); + }); +}); + +// Keep the fixture path assumption honest — every uploadBinary case depends on it. +describe('encrypted dedup test fixture', () => { + it('has the wikipedia.apk fixture available', () => { + expect(fs.existsSync(APK), `missing fixture: ${APK}`).to.equal(true); + expect(os.tmpdir()).to.be.a('string'); + }); +}); diff --git a/test/unit/envelope.test.ts b/test/unit/envelope.test.ts new file mode 100644 index 0000000..2c8f1a0 --- /dev/null +++ b/test/unit/envelope.test.ts @@ -0,0 +1,241 @@ +import { expect } from 'chai'; +import { + createDecipheriv, + createPublicKey, + createPrivateKey, + diffieHellman, + generateKeyPairSync, + hkdfSync, + randomBytes, +} from 'node:crypto'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + encryptEnv, + encryptFileToPath, + encryptFlowBuffer, + generateDek, + resolveKekPublicKey, + wrapDek, +} from '../../src/utils/envelope.js'; + +/** + * Reference implementation of the platform *decrypt* half (as implemented in + * dcd `api/src/common/crypto/envelope.ts` and `simulators/gateways/ + * EnvelopeGateway.ts`). If the CLI's ciphertext round-trips through this, it is + * byte-compatible with the platform. + */ +const SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex'); +const PKCS8_PREFIX = Buffer.from('302e020100300506032b656e04220420', 'hex'); +const HKDF_INFO = Buffer.from('dcd-binary-dek-wrap-v1', 'ascii'); + +function refDecryptContainer(ciphertext: Buffer, dek: Buffer): Buffer { + expect(ciphertext.subarray(0, 4).toString('ascii')).to.equal('DCDE'); + expect(ciphertext.readUInt8(4)).to.equal(1); // container version + const chunkSize = ciphertext.readUInt32BE(6); + const noncePrefix = ciphertext.subarray(10, 17); + const body = ciphertext.subarray(17); + const segBytes = chunkSize + 16; + + const out: Buffer[] = []; + let off = 0; + let index = 0; + while (off < body.length) { + const end = Math.min(off + segBytes, body.length); + const seg = body.subarray(off, end); + const isLast = end >= body.length; + const ct = seg.subarray(0, seg.length - 16); + const tag = seg.subarray(seg.length - 16); + const nonce = Buffer.alloc(12); + noncePrefix.copy(nonce, 0, 0, 7); + nonce.writeUInt32BE(index, 7); + nonce.writeUInt8(isLast ? 1 : 0, 11); + const d = createDecipheriv('aes-256-gcm', dek, nonce); + d.setAuthTag(tag); + out.push(Buffer.concat([d.update(ct), d.final()])); + off = end; + index += 1; + } + return Buffer.concat(out); +} + +function refUnwrapDek(wrappedB64: string, kekPrivRaw: Buffer): Buffer { + const kekPriv = createPrivateKey({ + key: Buffer.concat([PKCS8_PREFIX, kekPrivRaw]), + format: 'der', + type: 'pkcs8', + }); + const blob = Buffer.from(wrappedB64, 'base64'); + const ephPubRaw = blob.subarray(0, 32); + const iv = blob.subarray(32, 44); + const ct = blob.subarray(44, 76); + const tag = blob.subarray(76, 92); + const ephPub = createPublicKey({ + key: Buffer.concat([SPKI_PREFIX, ephPubRaw]), + format: 'der', + type: 'spki', + }); + const shared = diffieHellman({ privateKey: kekPriv, publicKey: ephPub }); + const key = Buffer.from( + hkdfSync('sha256', shared, Buffer.alloc(0), HKDF_INFO, 32), + ); + const d = createDecipheriv('aes-256-gcm', key, iv); + d.setAuthTag(tag); + return Buffer.concat([d.update(ct), d.final()]); +} + +function rawX25519(): { privRaw: Buffer; pubRaw: Buffer } { + const { publicKey, privateKey } = generateKeyPairSync('x25519'); + return { + pubRaw: publicKey + .export({ type: 'spki', format: 'der' }) + .subarray(SPKI_PREFIX.length), + privRaw: privateKey + .export({ type: 'pkcs8', format: 'der' }) + .subarray(PKCS8_PREFIX.length), + }; +} + +describe('binary envelope encryption (#1138)', () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(path.join(os.tmpdir(), 'dcd-enc-test-')); + }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + delete process.env.DCD_BINARY_KEK_PUBLIC; + }); + + const sizes: Array<[string, number]> = [ + ['sub-chunk', 500], + ['exactly one chunk', 1024], + ['one chunk + 1 byte', 1025], + ['several chunks + remainder', 4096 + 321], + ]; + + for (const [label, size] of sizes) { + it(`encrypts ${label} payloads into a DCDE container the platform decrypt recovers`, async () => { + const plaintext = randomBytes(size); + const src = path.join(dir, 'plain.bin'); + const dest = path.join(dir, 'cipher.enc'); + await writeFile(src, plaintext); + + const dek = generateDek(); + await encryptFileToPath(src, dest, dek, 1, 1024); // small chunk → multi-segment + + const ciphertext = await readFile(dest); + expect(refDecryptContainer(ciphertext, dek).equals(plaintext)).to.equal( + true, + ); + }); + } + + it('round-trips the wikipedia.apk fixture at the 1 MiB default chunk size', async () => { + const apk = await readFile( + new URL('../fixtures/wikipedia.apk', import.meta.url), + ); + const src = path.join(dir, 'wikipedia.apk'); + const dest = path.join(dir, 'wikipedia.apk.enc'); + await writeFile(src, apk); + + const dek = generateDek(); + await encryptFileToPath(src, dest, dek, 1); + + const ciphertext = await readFile(dest); + // Ciphertext must differ from plaintext and be recoverable byte-for-byte. + expect(ciphertext.subarray(0, 4).toString('ascii')).to.equal('DCDE'); + expect(refDecryptContainer(ciphertext, dek).equals(apk)).to.equal(true); + }); + + it('detects tampering (GCM tag mismatch)', async () => { + const src = path.join(dir, 'p.bin'); + const dest = path.join(dir, 'c.enc'); + await writeFile(src, randomBytes(3000)); + const dek = generateDek(); + await encryptFileToPath(src, dest, dek, 1, 1024); + const ciphertext = await readFile(dest); + ciphertext[25] ^= 0xff; // flip a ciphertext byte + expect(() => refDecryptContainer(ciphertext, dek)).to.throw(); + }); + + it('wraps a DEK that the matching KEK private key unwraps', () => { + const { privRaw, pubRaw } = rawX25519(); + process.env.DCD_BINARY_KEK_PUBLIC = `3:${pubRaw.toString('base64')}`; + const kek = resolveKekPublicKey('https://api.devicecloud.dev'); + expect(kek).to.not.equal(null); + + const dek = generateDek(); + const envelope = wrapDek(dek, kek!); + expect(envelope.v).to.equal(1); + expect(envelope.kek).to.equal(3); + expect(refUnwrapDek(envelope.wrapped_key, privRaw).equals(dek)).to.equal( + true, + ); + }); + + it('resolves the pinned KEK public key for each environment', () => { + const prod = resolveKekPublicKey('https://api.devicecloud.dev'); + expect(prod).to.not.equal(null); + expect(prod!.version).to.equal(1); + expect(prod!.keyRaw.toString('base64')).to.equal( + 'wtfyWEwK7nJzwI4PD+9RAW8jxIR1u8kMQq2IhsrVnH4=', + ); + + const dev = resolveKekPublicKey('https://api.dev.devicecloud.dev'); + expect(dev).to.not.equal(null); + expect(dev!.version).to.equal(1); + expect(dev!.keyRaw.toString('base64')).to.equal( + 'RgcToF/OJpcQI9koYvSvtj/WLaebfcN4v5GJoqtr/00=', + ); + }); +}); + +describe('flow + env envelope encryption (#1151, #1152)', () => { + afterEach(() => { + delete process.env.DCD_BINARY_KEK_PUBLIC; + }); + + it('encryptFlowBuffer produces a container the platform decrypts to the original zip', () => { + const { privRaw, pubRaw } = rawX25519(); + process.env.DCD_BINARY_KEK_PUBLIC = `2:${pubRaw.toString('base64')}`; + const kek = resolveKekPublicKey('https://api.dev.devicecloud.dev')!; + + const zip = randomBytes(5000); + const { ciphertext, enc } = encryptFlowBuffer(zip, kek); + + expect(enc.v).to.equal(1); + expect(enc.kek).to.equal(2); + expect(ciphertext.subarray(0, 4).toString('ascii')).to.equal('DCDE'); + const dek = refUnwrapDek(enc.wrapped_key, privRaw); + expect(refDecryptContainer(ciphertext, dek).equals(zip)).to.equal(true); + }); + + it('encryptEnv produces an inline envelope the platform decrypts back to the map', () => { + const { privRaw, pubRaw } = rawX25519(); + process.env.DCD_BINARY_KEK_PUBLIC = `1:${pubRaw.toString('base64')}`; + const kek = resolveKekPublicKey('https://api.dev.devicecloud.dev')!; + + const env = { API_TOKEN: 'secret', PASSWORD: 'p@ss word=1', EMPTY: '' }; + const enc = encryptEnv(env, kek); + + expect(enc.v).to.equal(1); + expect(enc.kek).to.equal(1); + // API unwraps the DEK from wrapped_key; runner decrypts the inline blob. + const dek = refUnwrapDek(enc.wrapped_key, privRaw); + const plain = refDecryptContainer(Buffer.from(enc.ciphertext, 'base64'), dek); + expect(JSON.parse(plain.toString('utf8'))).to.deep.equal(env); + }); + + it('gives the flow zip and env their own distinct DEKs', () => { + const { pubRaw } = rawX25519(); + process.env.DCD_BINARY_KEK_PUBLIC = `1:${pubRaw.toString('base64')}`; + const kek = resolveKekPublicKey('https://api.dev.devicecloud.dev')!; + + const flow = encryptFlowBuffer(randomBytes(100), kek); + const env = encryptEnv({ A: 'b' }, kek); + // Independent sealed boxes → the wrapped keys must differ. + expect(flow.enc.wrapped_key).to.not.equal(env.wrapped_key); + }); +}); diff --git a/test/unit/report-download.service.test.ts b/test/unit/report-download.service.test.ts index 2ed8566..8c75f13 100644 --- a/test/unit/report-download.service.test.ts +++ b/test/unit/report-download.service.test.ts @@ -303,4 +303,157 @@ describe('ReportDownloadService', () => { expect(warnings.join(' ')).to.match(/failed to download allure/i); }); }); + + // ------------------------------------------------------------------------- + // bundle delivery + // ------------------------------------------------------------------------- + + describe('bundle delivery', () => { + const BASE = { + auth: TEST_AUTH, + apiUrl: 'https://api.example.com', + uploadId: 'run-42', + }; + + let calls: Array<{ + headers: Record; + method: string; + url: string; + }>; + + /** + * Route fetch by URL: a `*-bundle` endpoint returns a signed manifest, and + * the manifest's `bundleUrl` streams the ZIP. Records every call so the + * flow (manifest GET, then bundle POST, no inline call) can be asserted. + */ + function mockBundleFetch( + opts: { manifestStatus?: number; zipBody?: string } = {}, + ) { + const { manifestStatus = 200, zipBody = 'bundle-zip' } = opts; + const encoder = new TextEncoder(); + const stream = (s: string) => + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(s)); + controller.close(); + }, + }); + + const impl = async ( + input: URL | string, + init?: RequestInit, + ): Promise => { + const url = input.toString(); + calls.push({ + headers: Object.fromEntries( + Object.entries((init?.headers as Record) ?? {}), + ), + method: (init?.method ?? 'GET').toUpperCase(), + url, + }); + + if (url.includes('artifacts-bundle') || url.includes('report-bundle')) { + return new Response( + stream( + JSON.stringify({ + bundleUrl: 'https://cdn.example.com/bundle', + entryCount: 3, + filename: 'artifacts-all.zip', + manifest: '{"version":1}', + sig: 'SIG', + }), + ), + { + headers: { 'content-type': 'application/json' }, + status: manifestStatus, + }, + ); + } + if (url === 'https://cdn.example.com/bundle') { + return new Response(stream(zipBody), { status: 200 }); + } + return new Response(stream('inline-zip'), { status: 200 }); + }; + globalThis.fetch = impl as typeof fetch; + } + + beforeEach(() => { + calls = []; + }); + + it('streams from the bundle URL and skips the inline endpoint', async () => { + mockBundleFetch(); + const outPath = path.join(tempDir, 'bundle.zip'); + + await service.downloadArtifacts({ + ...BASE, + artifactsPath: outPath, + downloadType: 'ALL', + }); + + expect(calls[0]).to.include({ + method: 'GET', + url: 'https://api.example.com/results/run-42/artifacts-bundle?results=ALL', + }); + expect(calls[1]).to.include({ + method: 'POST', + url: 'https://cdn.example.com/bundle', + }); + expect(calls.some((c) => c.url.endsWith('/download'))).to.be.false; + expect(fs.readFileSync(outPath, 'utf8')).to.equal('bundle-zip'); + }); + + it('does not send the auth header to the (pre-signed) bundle URL', async () => { + mockBundleFetch(); + + await service.downloadArtifacts({ + ...BASE, + artifactsPath: path.join(tempDir, 'bundle-noauth.zip'), + downloadType: 'ALL', + }); + + const bundlePost = calls.find( + (c) => c.url === 'https://cdn.example.com/bundle', + ); + expect(bundlePost).to.not.be.undefined; + expect(bundlePost!.headers['x-app-api-key']).to.be.undefined; + }); + + it('falls back to the inline download when unavailable (501)', async () => { + mockBundleFetch({ manifestStatus: 501 }); + const outPath = path.join(tempDir, 'bundle-fallback.zip'); + + await service.downloadArtifacts({ + ...BASE, + artifactsPath: outPath, + downloadType: 'ALL', + }); + + expect( + calls.some((c) => c.url.endsWith('/artifacts-bundle?results=ALL')), + ).to.be.true; + expect( + calls.some((c) => c.method === 'POST' && c.url.endsWith('/download')), + ).to.be.true; + expect(fs.readFileSync(outPath, 'utf8')).to.equal('inline-zip'); + }); + + it('uses bundle delivery for the html report', async () => { + mockBundleFetch(); + + await service.downloadReports({ + ...BASE, + htmlPath: path.join(tempDir, 'report-bundle.zip'), + reportType: 'html', + }); + + expect(calls[0].url).to.equal( + 'https://api.example.com/results/run-42/report-bundle', + ); + expect(calls[1]).to.include({ + method: 'POST', + url: 'https://cdn.example.com/bundle', + }); + }); + }); }); diff --git a/test/unit/version.service.test.ts b/test/unit/version.service.test.ts new file mode 100644 index 0000000..4ffe6ff --- /dev/null +++ b/test/unit/version.service.test.ts @@ -0,0 +1,35 @@ +import { expect } from 'chai'; + +import { VersionService } from '../../src/services/version.service.js'; + +describe('VersionService.isOutdated', () => { + const svc = new VersionService(); + + it('detects a release-level upgrade', () => { + expect(svc.isOutdated('5.0.0', '5.0.1')).to.equal(true); + expect(svc.isOutdated('4.9.0', '5.0.0')).to.equal(true); + expect(svc.isOutdated('5.1.0', '5.0.9')).to.equal(false); + }); + + it('detects a beta-to-beta prerelease upgrade', () => { + // Regression: both reduce to 5.0.0 under a naive major.minor.patch compare. + expect(svc.isOutdated('5.0.0-beta.0', '5.0.0-beta.1')).to.equal(true); + expect(svc.isOutdated('5.0.0-beta.1', '5.0.0-beta.0')).to.equal(false); + expect(svc.isOutdated('5.0.0-beta.10', '5.0.0-beta.2')).to.equal(false); + }); + + it('ranks a prerelease below its final release', () => { + expect(svc.isOutdated('5.0.0-beta.1', '5.0.0')).to.equal(true); + expect(svc.isOutdated('5.0.0', '5.0.0-beta.1')).to.equal(false); + }); + + it('returns false when versions are equal', () => { + expect(svc.isOutdated('5.0.0', '5.0.0')).to.equal(false); + expect(svc.isOutdated('5.0.0-beta.1', '5.0.0-beta.1')).to.equal(false); + }); + + it('tolerates a leading v and short versions', () => { + expect(svc.isOutdated('v5.0.0', 'v5.0.1')).to.equal(true); + expect(svc.isOutdated('5.0', '5.0.1')).to.equal(true); + }); +});