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..1cb3bc1 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 @@ -38,18 +40,22 @@ jobs: # 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 path: dcd @@ -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' 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..a690fc9 100644 --- a/.release-please-manifest-beta.json +++ b/.release-please-manifest-beta.json @@ -1,3 +1,3 @@ { - ".": "5.0.0-beta.1" + ".": "5.0.0-beta.3" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 25de72f..f4548ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## [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/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..7dd3ebe 100644 --- a/package.json +++ b/package.json @@ -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.0.0-beta.3", "bugs": { "url": "https://discord.gg/gm3mJwcNw8" }, 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..e8358cf 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'; @@ -36,6 +37,7 @@ import { isCI } from '../utils/ci.js'; import { CliError, coerceArray, + collectRepeatedFlag, getCliVersion, getUpgradeCommand, logger, @@ -109,7 +111,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 +121,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 +171,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, @@ -204,7 +215,7 @@ export const cloudCommand = defineCommand({ 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; @@ -367,11 +378,17 @@ 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`, + // Soft deprecation notice for Maestro versions slated for removal on + // 26 June 2026. Non-fatal — these still run during the grace period. + const DEPRECATED_MAESTRO_VERSIONS = ['1.39.5', '1.41.0']; + if (DEPRECATED_MAESTRO_VERSIONS.includes(resolvedMaestroVersion)) { + warnOut(ui.warn(colors.bold(`Maestro ${resolvedMaestroVersion} is deprecated`))); + warnOut( + ui.branch([ + `Maestro ${resolvedMaestroVersion} will be removed on 26 June 2026; after that, tests pinned to it will fail.`, + 'Upgrade to Maestro 2.6.0 or above.', + `${colors.dim('See:')} ${colors.url('https://docs.devicecloud.dev/configuration/maestro-versions')}`, + ]), ); } @@ -431,6 +448,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 +486,21 @@ export const cloudCommand = defineCommand({ logger: (m: string) => out(m), }); + // iOS 16 deprecation notice (soft warning during the grace period; + // removed on 23 August 2026). Only fires on an explicit --ios-version 16 — + // when omitted the API defaults to iOS 17, so no false warning. + const DEPRECATED_IOS_VERSIONS = ['16']; + if (iOSVersion && DEPRECATED_IOS_VERSIONS.includes(iOSVersion)) { + warnOut(ui.warn(colors.bold('iOS 16 is deprecated'))); + warnOut( + ui.branch([ + 'iOS 16 will be removed on 23 August 2026; after that, tests targeting it will fail.', + 'Switch to iOS 17 or newer — iPhone 14 also supports 17 and 18.', + `${colors.dim('See:')} ${colors.url('https://docs.devicecloud.dev/getting-started/devices-configuration')}`, + ]), + ); + } + deviceValidationService.validateAndroidDevice( androidApiLevel, androidDevice, @@ -598,14 +638,27 @@ 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, + }; 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( 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/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/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/results-polling.service.ts b/src/services/results-polling.service.ts index 9146f69..c1cf800 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'; @@ -160,12 +161,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 +210,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 { @@ -644,12 +657,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 +675,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/version.service.ts b/src/services/version.service.ts index ceda057..1ed8239 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. + */ +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/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/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/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); + }); +});