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..ba697bc --- /dev/null +++ b/.github/workflows/cla.yml @@ -0,0 +1,72 @@ +name: CLA Assistant + +# Gates merges on a signed Contributor License Agreement. +# +# Uses CLA Assistant Lite (contributor-assistant/github-action): 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. +# +# 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: contributor-assistant/github-action@v2.6.1 + 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..961eb4a 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 @@ -34,24 +36,28 @@ jobs: lint-and-test: runs-on: ubuntu-latest - # The mock-api lives in the private moropo-com/dcd repo, checked out via an + # The mock-api lives in the private devicecloud-dev/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. + # + # Dependabot PRs branch from this repo (so the fork check passes) but ALSO run + # without secrets — treat them like forks and skip the private checkout, or + # the mock-api clone fails with an empty DCD_SSH_DEPLOY_KEY. env: - HAS_PRIVATE_ACCESS: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + HAS_PRIVATE_ACCESS: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && github.actor != 'dependabot[bot]' }} 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 + uses: actions/checkout@v7 with: - repository: moropo-com/dcd + repository: devicecloud-dev/dcd path: dcd ssh-key: ${{ secrets.DCD_SSH_DEPLOY_KEY }} # api/swagger.json is a file, which cone-mode sparse checkout rejects @@ -62,13 +68,13 @@ jobs: /api/swagger.json - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 with: version: 10 run_install: false - name: Setup Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version: '22' cache: 'pnpm' @@ -100,7 +106,7 @@ jobs: - 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." + run: echo "::notice::Integration tests skipped — the mock-api (private devicecloud-dev/dcd) is not accessible from fork PRs. Lint, typecheck, and build still ran." - name: Build CLI working-directory: ./cli diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index dbee47d..8ff9b19 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 with: run_install: false - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 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..ba1b227 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 with: run_install: false - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 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/.release-please-manifest-beta.json b/.release-please-manifest-beta.json index 86cb1d6..b3b3a94 100644 --- a/.release-please-manifest-beta.json +++ b/.release-please-manifest-beta.json @@ -1,3 +1,3 @@ { - ".": "5.0.0-beta.1" + ".": "5.2.0-beta.3" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 25de72f..6965596 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,100 @@ # Changelog +## [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..2cd76a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,11 +5,12 @@ 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, then runs all `test/**/*.test.ts` via 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 mock API lives in the **sibling `dcd/` repo** (`../dcd/mock-api`, started via `npm run start:auth` on port 3001). Override its location with `MOCK_API_DIR=/path/to/mock-api`. The runner isolates `DCD_CONFIG_DIR` to a temp dir so tests never touch your real `dcd login` session. +- Tests split into `test/unit/*` (pure, no backend) and `test/integration/*` (drive the built CLI against the mock API). 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 +20,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 +39,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 on every PR** including forks: gitleaks secret scan, `pnpm lint`, `pnpm typecheck`, `pnpm build`, `pnpm audit --audit-level moderate`. The **integration tests need the private `devicecloud-dev/dcd` mock-api** (cloned via the `DCD_SSH_DEPLOY_KEY` secret), and GitHub withholds secrets from fork and Dependabot PRs — so `pnpm test` is **skipped there** and a maintainer runs the full suite before merge. 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..1761173 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,147 @@ +# 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` | Build + boot the mock API + run integration/unit tests | + +**Before pushing, make sure `pnpm lint`, `pnpm typecheck`, and `pnpm build` +pass.** These run for every PR (including from forks) and are required to merge. + +### About the test suite + +`pnpm test` boots a **mock API that lives in a private repository**, so the full +integration suite only runs on branches inside this repo. **On pull requests from +forks the integration tests are automatically skipped** — you'll see a CI notice +saying so. That's expected: lint, typecheck, and build still run and gate your +PR, and a maintainer runs the full suite before merge. You don't need backend +access to contribute. + +### 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..f0da192 100644 --- a/README.md +++ b/README.md @@ -92,3 +92,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/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..47e3b7c 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "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": "^69.0.0", "husky": "^9.1.7", "mocha": "^11.7.6", "prettier": "^3.8.4", @@ -69,7 +69,7 @@ "test": "node scripts/test-runner.mjs", "typecheck": "tsc --noEmit -p tsconfig.test.json" }, - "version": "5.0.0-beta.1", + "version": "5.2.0-beta.3", "bugs": { "url": "https://discord.gg/gm3mJwcNw8" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5db7755..43ccd66 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,13 +37,13 @@ importers: dependencies: '@clack/prompts': specifier: ^1.6.0 - version: 1.6.0 + version: 1.7.0 '@modelcontextprotocol/sdk': specifier: ^1.29.0 version: 1.29.0(zod@4.4.3) '@supabase/supabase-js': specifier: ^2.108.2 - version: 2.108.2 + version: 2.110.2 bplist-parser: specifier: ^0.3.2 version: 0.3.2 @@ -55,7 +55,7 @@ importers: version: 0.2.2 js-yaml: specifier: ^5.0.0 - version: 5.0.0 + version: 5.2.1 node-apk: specifier: ^1.2.1 version: 1.2.1 @@ -67,7 +67,7 @@ importers: version: 5.0.0 tar: specifier: ^7.5.16 - version: 7.5.16 + version: 7.5.19 tus-js-client: specifier: ^4.3.1 version: 4.3.1 @@ -80,7 +80,7 @@ importers: devDependencies: '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.5.0) + version: 10.0.1(eslint@10.6.0) '@types/chai': specifier: ^5.2.3 version: 5.2.3 @@ -92,7 +92,7 @@ importers: version: 10.0.10 '@types/node': specifier: ^26.0.0 - version: 26.0.0 + version: 26.1.1 '@types/yazl': specifier: ^3.3.1 version: 3.3.1 @@ -101,16 +101,16 @@ importers: version: 6.2.2 eslint: specifier: ^10.5.0 - version: 10.5.0 + version: 10.6.0 eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.5.0) + version: 10.1.8(eslint@10.6.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: 2.32.0(@typescript-eslint/parser@8.63.0(eslint@10.6.0)(typescript@6.0.3))(eslint@10.6.0) eslint-plugin-unicorn: - specifier: ^68.0.0 - version: 68.0.0(eslint@10.5.0) + specifier: ^69.0.0 + version: 69.0.0(eslint@10.6.0) husky: specifier: ^9.1.7 version: 9.1.7 @@ -119,19 +119,19 @@ importers: version: 11.7.6 prettier: specifier: ^3.8.4 - version: 3.8.4 + version: 3.9.5 shx: specifier: ^0.4.0 version: 0.4.0 tsx: specifier: ^4.22.4 - version: 4.22.4 + version: 4.23.0 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.63.0(eslint@10.6.0)(typescript@6.0.3) packages: @@ -139,12 +139,12 @@ packages: resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@clack/core@1.4.2': - resolution: {integrity: sha512-0Ty/1Gfm+Kb07sXcuESjyKfwEhSy4Ns1AgeEisHb/bDY5fWme0tTeTkU14T1Gmcs17YIjB/teiDe4uaCghbYqQ==} + '@clack/core@1.4.3': + resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==} engines: {node: '>= 20.12.0'} - '@clack/prompts@1.6.0': - resolution: {integrity: sha512-EYlRokl8szrP9Z25qT5aepMdBjzBvHF9ZEhzIiUBc9guz/T31EqRgvD0QSgZcpE93xiwrr+OkB4nz0BZyF6fSA==} + '@clack/prompts@1.7.0': + resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} engines: {node: '>= 20.12.0'} '@esbuild/aix-ppc64@0.28.1': @@ -405,32 +405,32 @@ packages: '@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.110.2': + resolution: {integrity: sha512-Qj7a6EDP+AMMQFWqGv+qFa8r6re//dk+qQI5bA0KK+PZmnI3JPu97TDeNt6SMiQ2FkklP79hP2yDFYSnA989OA==} + 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.110.2': + resolution: {integrity: sha512-ZjjqrXpxM9/rE+eAtZxiK45EWy9EBoJQ322Q5Y75LccYQNh212neHTgXP/o4MIzmH0LNXT8UzvTZtQOfOzyoeQ==} + engines: {node: '>=22.0.0'} '@supabase/phoenix@0.4.4': resolution: {integrity: sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==} - '@supabase/postgrest-js@2.108.2': - resolution: {integrity: sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==} - engines: {node: '>=20.0.0'} + '@supabase/postgrest-js@2.110.2': + resolution: {integrity: sha512-++LBmcIMwCtgO4tISQUmo9+2xkRwHQqS8ZKMCnhXLe9P8k8YQRXuMoh/RiSzQSoev8gqet0W7yOboW0cUxnt0Q==} + engines: {node: '>=22.0.0'} - '@supabase/realtime-js@2.108.2': - resolution: {integrity: sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==} - engines: {node: '>=20.0.0'} + '@supabase/realtime-js@2.110.2': + resolution: {integrity: sha512-z3jTOTPgyn6E3r6dVOOQ10He4yAMB2czjFw7xVdX3s16MHElna5rY1gVaePs0NIo6xvtMYbtmOXlFaFt/ePLpg==} + 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.110.2': + resolution: {integrity: sha512-EhsRSwSnmQefKJsAxoRUZ0hvHr92ECM8DDGAKR5z0HdoJx4heI60PjHUTruVNZxKX6XeobLGDyLud020Bw1iwg==} + engines: {node: '>=22.0.0'} - '@supabase/supabase-js@2.108.2': - resolution: {integrity: sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==} - engines: {node: '>=20.0.0'} + '@supabase/supabase-js@2.110.2': + resolution: {integrity: sha512-r9q9w4ZQ6mOjh36aqUNFSisBF611vzpO8JphBESr2Q1SWvmGFQeI7Jq7Y+PaNMZ6Zszz+S2yTlJStCpnaMSnQg==} + engines: {node: '>=22.0.0'} '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -456,69 +456,69 @@ packages: '@types/mocha@10.0.10': resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==} - '@types/node@26.0.0': - resolution: {integrity: sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==} + '@types/node@26.1.1': + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} '@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.63.0': + resolution: {integrity: sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.61.1 + '@typescript-eslint/parser': ^8.63.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.63.0': + resolution: {integrity: sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==} 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.63.0': + resolution: {integrity: sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==} 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.63.0': + resolution: {integrity: sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==} 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.63.0': + resolution: {integrity: sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==} 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.63.0': + resolution: {integrity: sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==} 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.63.0': + resolution: {integrity: sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==} 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.63.0': + resolution: {integrity: sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==} 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.63.0': + resolution: {integrity: sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==} 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.63.0': + resolution: {integrity: sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@xmldom/xmldom@0.9.10': @@ -615,8 +615,8 @@ packages: 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.10.40: + resolution: {integrity: sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==} engines: {node: '>=6.0.0'} hasBin: true @@ -658,8 +658,8 @@ 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: @@ -840,8 +840,8 @@ packages: 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==} + electron-to-chromium@1.5.381: + resolution: {integrity: sha512-n9Wa6yB+vDsGuA8AKbl/0z7HbvWqt5jxIdvr1IUicd0ryPrk7/xzwqLv8D9AbbvZ6avVNtXYLTfmgFHkwkyelg==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -944,8 +944,8 @@ packages: '@typescript-eslint/parser': optional: true - eslint-plugin-unicorn@68.0.0: - resolution: {integrity: sha512-mHYWvX948Q4H3bGc39bsNMxD/leOuNI+Iws9NVsoSz5VA7EGP86wnz7mZ/SPSvRhefT8L4hd8DHfDuGC+lIoCQ==} + eslint-plugin-unicorn@69.0.0: + resolution: {integrity: sha512-ZN/KtHr9hQ6AOByANSNJpsDbo/+Nn+EyQ6blK4w+dcmS/xpYkqLLfrUc+NA/wOK6vF5uEUvhn8my5B/3sruB9g==} engines: {node: '>=22'} peerDependencies: eslint: '>=10.4' @@ -962,8 +962,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.6.0: + resolution: {integrity: sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -1154,8 +1154,8 @@ packages: 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 - globals@17.6.0: - resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} + globals@17.7.0: + resolution: {integrity: sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==} engines: {node: '>=18'} globalthis@1.0.4: @@ -1225,8 +1225,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} engines: {node: '>= 4'} imurmurhash@0.1.4: @@ -1406,8 +1406,8 @@ packages: 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.2.1: + resolution: {integrity: sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==} hasBin: true jsesc@3.1.0: @@ -1559,8 +1559,8 @@ packages: 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.50: + resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} node-stream-zip@1.15.0: @@ -1666,8 +1666,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.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} pkce-challenge@5.0.1: @@ -1690,8 +1690,8 @@ packages: 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.5: + resolution: {integrity: sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==} engines: {node: '>=14'} hasBin: true @@ -1954,8 +1954,8 @@ 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.19: + resolution: {integrity: sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==} engines: {node: '>=18'} tinyglobby@0.2.17: @@ -1982,8 +1982,8 @@ packages: 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.0: + resolution: {integrity: sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==} engines: {node: '>=18.0.0'} hasBin: true @@ -2015,8 +2015,8 @@ packages: 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.63.0: + resolution: {integrity: sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2140,14 +2140,14 @@ snapshots: '@babel/helper-validator-identifier@7.29.7': {} - '@clack/core@1.4.2': + '@clack/core@1.4.3': dependencies: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@clack/prompts@1.6.0': + '@clack/prompts@1.7.0': dependencies: - '@clack/core': 1.4.2 + '@clack/core': 1.4.3 fast-string-width: 3.0.2 fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 @@ -2230,9 +2230,9 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.5.0)': + '@eslint-community/eslint-utils@4.9.1(eslint@10.6.0)': dependencies: - eslint: 10.5.0 + eslint: 10.6.0 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -2253,9 +2253,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.5.0)': + '@eslint/js@10.0.1(eslint@10.6.0)': optionalDependencies: - eslint: 10.5.0 + eslint: 10.6.0 '@eslint/object-schema@3.0.5': {} @@ -2336,37 +2336,37 @@ snapshots: '@rtsao/scc@1.1.0': {} - '@supabase/auth-js@2.108.2': + '@supabase/auth-js@2.110.2': dependencies: tslib: 2.8.1 - '@supabase/functions-js@2.108.2': + '@supabase/functions-js@2.110.2': dependencies: tslib: 2.8.1 '@supabase/phoenix@0.4.4': {} - '@supabase/postgrest-js@2.108.2': + '@supabase/postgrest-js@2.110.2': dependencies: tslib: 2.8.1 - '@supabase/realtime-js@2.108.2': + '@supabase/realtime-js@2.110.2': dependencies: '@supabase/phoenix': 0.4.4 tslib: 2.8.1 - '@supabase/storage-js@2.108.2': + '@supabase/storage-js@2.110.2': dependencies: iceberg-js: 0.8.1 tslib: 2.8.1 - '@supabase/supabase-js@2.108.2': + '@supabase/supabase-js@2.110.2': 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.110.2 + '@supabase/functions-js': 2.110.2 + '@supabase/postgrest-js': 2.110.2 + '@supabase/realtime-js': 2.110.2 + '@supabase/storage-js': 2.110.2 '@types/chai@5.2.3': dependencies: @@ -2387,80 +2387,80 @@ snapshots: '@types/mocha@10.0.10': {} - '@types/node@26.0.0': + '@types/node@26.1.1': dependencies: undici-types: 8.3.0 '@types/yazl@3.3.1': dependencies: - '@types/node': 26.0.0 + '@types/node': 26.1.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.63.0(@typescript-eslint/parser@8.63.0(eslint@10.6.0)(typescript@6.0.3))(eslint@10.6.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.63.0(eslint@10.6.0)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.63.0 + '@typescript-eslint/type-utils': 8.63.0(eslint@10.6.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.63.0(eslint@10.6.0)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.63.0 + eslint: 10.6.0 + ignore: 7.0.6 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.63.0(eslint@10.6.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.63.0 + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.63.0 debug: 4.4.3(supports-color@8.1.1) - eslint: 10.5.0 + eslint: 10.6.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.61.1(typescript@6.0.3)': + '@typescript-eslint/project-service@8.63.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.63.0(typescript@6.0.3) + '@typescript-eslint/types': 8.63.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.63.0': dependencies: - '@typescript-eslint/types': 8.61.1 - '@typescript-eslint/visitor-keys': 8.61.1 + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/visitor-keys': 8.63.0 - '@typescript-eslint/tsconfig-utils@8.61.1(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.63.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.63.0(eslint@10.6.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.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.63.0(eslint@10.6.0)(typescript@6.0.3) debug: 4.4.3(supports-color@8.1.1) - eslint: 10.5.0 + eslint: 10.6.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.63.0': {} - '@typescript-eslint/typescript-estree@8.61.1(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.63.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.63.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.63.0(typescript@6.0.3) + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/visitor-keys': 8.63.0 debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.3 semver: 7.8.5 @@ -2470,20 +2470,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.61.1(eslint@10.5.0)(typescript@6.0.3)': + '@typescript-eslint/utils@8.63.0(eslint@10.6.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.9.1(eslint@10.6.0) + '@typescript-eslint/scope-manager': 8.63.0 + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3) + eslint: 10.6.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.61.1': + '@typescript-eslint/visitor-keys@8.63.0': dependencies: - '@typescript-eslint/types': 8.61.1 + '@typescript-eslint/types': 8.63.0 eslint-visitor-keys: 5.0.1 '@xmldom/xmldom@0.9.10': {} @@ -2591,7 +2591,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.10.38: {} + baseline-browser-mapping@2.10.40: {} big-integer@1.6.52: {} @@ -2630,17 +2630,17 @@ snapshots: browserslist@4.28.4: dependencies: - baseline-browser-mapping: 2.10.38 + baseline-browser-mapping: 2.10.40 caniuse-lite: 1.0.30001799 - electron-to-chromium: 1.5.376 - node-releases: 2.0.48 + electron-to-chromium: 1.5.381 + node-releases: 2.0.50 update-browserslist-db: 1.2.3(browserslist@4.28.4) buffer-crc32@1.0.0: {} buffer-from@1.1.2: {} - builtin-modules@5.2.0: {} + builtin-modules@5.3.0: {} bytes@3.1.2: {} @@ -2804,7 +2804,7 @@ snapshots: ee-first@1.1.1: {} - electron-to-chromium@1.5.376: {} + electron-to-chromium@1.5.381: {} emoji-regex@8.0.0: {} @@ -2942,9 +2942,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.5.0): + eslint-config-prettier@10.1.8(eslint@10.6.0): dependencies: - eslint: 10.5.0 + eslint: 10.6.0 eslint-import-resolver-node@0.3.10: dependencies: @@ -2954,17 +2954,17 @@ snapshots: 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-module-utils@2.13.0(@typescript-eslint/parser@8.63.0(eslint@10.6.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.6.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 + '@typescript-eslint/parser': 8.63.0(eslint@10.6.0)(typescript@6.0.3) + eslint: 10.6.0 eslint-import-resolver-node: 0.3.10 transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.1(eslint@10.5.0)(typescript@6.0.3))(eslint@10.5.0): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.63.0(eslint@10.6.0)(typescript@6.0.3))(eslint@10.6.0): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -2973,9 +2973,9 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 10.5.0 + eslint: 10.6.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) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.63.0(eslint@10.6.0)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@10.6.0) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -2987,24 +2987,24 @@ snapshots: 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) + '@typescript-eslint/parser': 8.63.0(eslint@10.6.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@69.0.0(eslint@10.6.0): dependencies: '@babel/helper-validator-identifier': 7.29.7 - '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0) browserslist: 4.28.4 change-case: 5.4.4 ci-info: 4.4.0 core-js-compat: 3.49.0 detect-indent: 7.0.2 - eslint: 10.5.0 + eslint: 10.6.0 find-up-simple: 1.0.1 - globals: 17.6.0 + globals: 17.7.0 indent-string: 5.0.0 is-builtin-module: 5.0.0 jsesc: 3.1.0 @@ -3024,9 +3024,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.5.0: + eslint@10.6.0: dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.5 '@eslint/config-helpers': 0.6.0 @@ -3163,9 +3163,9 @@ snapshots: dependencies: reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.4): + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: - picomatch: 4.0.4 + picomatch: 4.0.5 file-entry-cache@8.0.0: dependencies: @@ -3283,7 +3283,7 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 - globals@17.6.0: {} + globals@17.7.0: {} globalthis@1.0.4: dependencies: @@ -3338,7 +3338,7 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.5: {} + ignore@7.0.6: {} imurmurhash@0.1.4: {} @@ -3383,7 +3383,7 @@ snapshots: is-builtin-module@5.0.0: dependencies: - builtin-modules: 5.2.0 + builtin-modules: 5.3.0 is-callable@1.2.7: {} @@ -3502,7 +3502,7 @@ snapshots: js-base64@3.7.8: {} - js-yaml@5.0.0: + js-yaml@5.2.1: dependencies: argparse: 2.0.1 @@ -3622,7 +3622,7 @@ snapshots: glob: 10.5.0 he: 1.2.0 is-path-inside: 3.0.3 - js-yaml: 5.0.0 + js-yaml: 5.2.1 log-symbols: 4.1.0 minimatch: 9.0.7 ms: 2.1.3 @@ -3656,7 +3656,7 @@ snapshots: node-forge@1.4.0: {} - node-releases@2.0.48: {} + node-releases@2.0.50: {} node-stream-zip@1.15.0: {} @@ -3762,7 +3762,7 @@ snapshots: picomatch@2.3.2: {} - picomatch@4.0.4: {} + picomatch@4.0.5: {} pkce-challenge@5.0.1: {} @@ -3777,7 +3777,7 @@ snapshots: prelude-ls@1.2.1: {} - prettier@3.8.4: {} + prettier@3.9.5: {} proper-lockfile@4.1.2: dependencies: @@ -4089,7 +4089,7 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - tar@7.5.16: + tar@7.5.19: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 @@ -4099,8 +4099,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 to-regex-range@5.0.1: dependencies: @@ -4121,7 +4121,7 @@ snapshots: tslib@2.8.1: {} - tsx@4.22.4: + tsx@4.23.0: dependencies: esbuild: 0.28.1 optionalDependencies: @@ -4180,13 +4180,13 @@ snapshots: 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): + typescript-eslint@8.63.0(eslint@10.6.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.63.0(@typescript-eslint/parser@8.63.0(eslint@10.6.0)(typescript@6.0.3))(eslint@10.6.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.63.0(eslint@10.6.0)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.63.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.63.0(eslint@10.6.0)(typescript@6.0.3) + eslint: 10.6.0 typescript: 6.0.3 transitivePeerDependencies: - supports-color 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/src/commands/cloud.ts b/src/commands/cloud.ts index 3476c0d..bedb952 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,19 @@ 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 { detectCiContext, isCI } from '../utils/ci.js'; import { CliError, coerceArray, + collectRepeatedFlag, getCliVersion, getUpgradeCommand, logger, @@ -46,6 +55,7 @@ import { CompatibilityData, fetchCompatibilityData, } from '../utils/compatibility.js'; +import { renderNotices } from '../services/notices.service.js'; import { resolveApiUrl } from '../utils/config-store.js'; import { downloadExpoUrl, extractTarGz, findAppBundle, isUrl } from '../utils/expo.js'; import { @@ -109,7 +119,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 +129,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 +179,23 @@ 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']); const includeTags = coerceArray( - args['include-tags'] as string | string[] | undefined, + collectRepeatedFlag(rawArgs, ['--include-tags']), ); const iOSDevice = validateEnum( args['ios-device'] as string | undefined, @@ -199,12 +218,16 @@ 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; @@ -335,9 +358,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 +395,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( @@ -431,6 +451,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 +489,22 @@ 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. + renderNotices( + compatibilityData.notices, + { + ios_version: iOSVersion, + android_api_level: androidApiLevel, + cli_version: cliVersion, + ci_provider: ciContext.provider, + ci_wrapper_version: ciContext.wrapperVersion, + }, + { out }, + ); + deviceValidationService.validateAndroidDevice( androidApiLevel, androidDevice, @@ -469,6 +513,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.', @@ -598,14 +664,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( @@ -716,6 +797,7 @@ export const cloudCommand = defineCommand({ continueOnFailure, debug, deviceLocale, + deviceMatrix, env, executionPlan, flowFile, @@ -738,6 +820,53 @@ export const cloudCommand = defineCommand({ disableAnimations, }); + // 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 +948,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 || 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..cf925a5 100644 --- a/src/commands/live.ts +++ b/src/commands/live.ts @@ -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/config/flags/device.flags.ts b/src/config/flags/device.flags.ts index 37135d4..62df3de 100644 --- a/src/config/flags/device.flags.ts +++ b/src/config/flags/device.flags.ts @@ -42,6 +42,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/gateways/api-gateway.ts b/src/gateways/api-gateway.ts index 7ba37b4..48eeb18 100644 --- a/src/gateways/api-gateway.ts +++ b/src/gateways/api-gateway.ts @@ -588,6 +588,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 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/methods.ts b/src/methods.ts index 42ab30d..ebc7a0f 100644 --- a/src/methods.ts +++ b/src/methods.ts @@ -924,8 +924,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 +951,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 +1086,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/notices.service.ts b/src/services/notices.service.ts new file mode 100644 index 0000000..9a8070d --- /dev/null +++ b/src/services/notices.service.ts @@ -0,0 +1,145 @@ +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. + */ +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..bbff215 100644 --- a/src/services/test-submission.service.ts +++ b/src/services/test-submission.service.ts @@ -2,6 +2,7 @@ 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 { toPortableRelativePath } from '../utils/paths.js'; import { IExecutionPlan } from './execution-plan.service.js'; @@ -15,6 +16,7 @@ export interface TestSubmissionConfig { continueOnFailure?: boolean; debug?: boolean; deviceLocale?: string; + deviceMatrix?: DeviceMatrixConfig[]; disableAnimations?: boolean; env?: string[]; executionPlan: IExecutionPlan; @@ -85,6 +87,7 @@ export class TestSubmissionService { maestroChromeOnboarding, raw, disableAnimations, + deviceMatrix, debug = false, logger, } = config; @@ -183,7 +186,23 @@ export class TestSubmissionService { // 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) 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/types/domain/device.types.ts b/src/types/domain/device.types.ts index 064c23a..45e2632 100644 --- a/src/types/domain/device.types.ts +++ b/src/types/domain/device.types.ts @@ -40,3 +40,17 @@ export enum EAndroidApiLevels { '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/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/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/integration/cloud.integration.test.ts b/test/integration/cloud.integration.test.ts index e87f06f..7d052af 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); 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/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); + }); +});