diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index 74c3af966f..bc7c6eef49 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -1,11 +1,13 @@ --- name: release -description: Kandev release & versioning conventions — single SemVer across npm, Homebrew, GitHub release. Use when cutting a release, debugging release artifacts, or answering questions about version channels. +description: Kandev release and version-channel conventions — unified Stable SemVer plus deterministic npm-only Nightlies. Use when cutting a release, changing channels, debugging artifacts, or answering version-channel questions. --- # Release & Versioning -Kandev uses a **single SemVer** `X.Y.Z` shared across all distribution channels. +Kandev Stable releases use a **single SemVer** `X.Y.Z` shared across all distribution channels. +npm also has an explicit prerelease-only `nightly` channel; it is not part of the unified Stable +artifact set. ## Version targets @@ -18,18 +20,46 @@ Kandev uses a **single SemVer** `X.Y.Z` shared across all distribution channels. **npm and Homebrew are sibling channels**, not chained. Both consume the same GitHub release artifacts; neither depends on the other. +For npm Nightly, Stable `X.Y.Z` plus a full `main` SHA produces +`X.Y.(Z+1)-nightly.sha`. `kandev` and all five runtime packages publish at +that exact version under npm's `nightly` dist-tag. Nightly never moves `latest` and creates no Git +tag, GitHub Release, Homebrew formula, Desktop feed/build, or container tag. + ## Release flow -Entirely in CI via `.github/workflows/release.yml`, triggered by a maintainer from the GitHub Actions UI: +Stable runs entirely in CI via `.github/workflows/release.yml`, triggered by a maintainer from the GitHub Actions UI: -1. Maintainer clicks "Run workflow" → picks `bump` (patch/minor/major) → optional `dry_run` or `desktop_validation_only`. +1. Maintainer clicks "Run workflow" → keeps `channel=stable` → picks `bump` (patch/minor/major) → optional `dry_run` or `desktop_validation_only`. 2. `prepare` job bumps version + regenerates CHANGELOG, opens release PR, squash-merges, tags `vX.Y.Z`. 3. `build-web` + `build-cli` + `build-bundles` (5 platforms) build the release artifacts. 4. `publish-release` creates the GitHub release with platform tarballs + sha256 + auto-generated notes. 5. `publish-npm` publishes 5 `@kdlbs/runtime-*` packages + main `kandev` package to npmjs. 6. `update-homebrew-tap` pushes updated `Formula/kandev.rb` to `kdlbs/homebrew-kandev` via SSH deploy key. -There is no local release script — the entire flow runs in GHA. +Stable has no local release driver; the entire Stable flow runs in GHA. The Nightly metadata and +publication revalidation state machine lives in `scripts/release/nightly-release.sh`, which GHA +invokes for scheduled and manual Nightly runs. + +The same workflow schedules npm Nightly with cron `0 12 * * *`. It skips before building when +`main` has no commit after the latest Stable tag, the exact commit is already published, or a same +or newer `main` Nightly supersedes the scheduled commit. Eligible runs build only the shared web +bundle and five native runtime archives, then publish runtimes first and `kandev` last with OIDC +provenance. Stable and Nightly workflow runs share one non-cancelling release-wide concurrency +slot. Before publishing, Nightly rechecks the stable Git/npm baseline and the previously observed +`nightly` tag; a pending Stable tag or moved value safely suppresses stale publication. + +Maintainers may run that same Nightly path from the Actions UI with the `main` ref and +`channel=nightly`. `dry_run=true` retains the real metadata and registry preflight but skips shared +builds and all npm writes. The shared form's required `bump` value is ignored for Nightly; +`desktop_validation_only` and `backfill_tag` are Stable-only and rejected when combined with it. + +Validate Nightly automation changes with: + +```bash +node --test scripts/release/nightly-version.test.mjs scripts/release/nightly-release.test.mjs +python3 .github/scripts/release-workflow-contract_test.py +bash -n scripts/release/nightly-release.sh scripts/release/publish-npm.sh +``` ## Release-tag signing configuration diff --git a/.github/scripts/release-workflow-contract_test.py b/.github/scripts/release-workflow-contract_test.py index 1b7b50dea8..12e6463664 100644 --- a/.github/scripts/release-workflow-contract_test.py +++ b/.github/scripts/release-workflow-contract_test.py @@ -12,12 +12,14 @@ REPO_ROOT = Path(__file__).resolve().parents[2] WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "release.yml" DIAGNOSTICS_PATH = REPO_ROOT / ".github" / "scripts" / "collect-macos-desktop-diagnostics.sh" PUBLISH_NPM_PATH = REPO_ROOT / "scripts" / "release" / "publish-npm.sh" +NPM_PACKAGES_PATH = REPO_ROOT / "scripts" / "release" / "npm-packages.sh" PUBLIC_KEY_PATH = REPO_ROOT / ".github" / "release-signing-key.asc" RELEASE_PROCESS_PATH = REPO_ROOT / "docs" / "public" / "release-process.md" LINT_WORKFLOW_PATH = REPO_ROOT / ".github" / "workflows" / "lint-action-pinning.yml" WORKFLOW = WORKFLOW_PATH.read_text() DIAGNOSTICS = DIAGNOSTICS_PATH.read_text() PUBLISH_NPM = PUBLISH_NPM_PATH.read_text() +NPM_PACKAGES = NPM_PACKAGES_PATH.read_text() RELEASE_PROCESS = RELEASE_PROCESS_PATH.read_text() LINT_WORKFLOW = LINT_WORKFLOW_PATH.read_text() NORMAL_RELEASE_IF = ( @@ -46,7 +48,195 @@ def job_block(name: str) -> str: return WORKFLOW[start:end] +def job_condition(name: str) -> str: + match = re.search(r"(?m)^ if: [^\n]*(?:\n {6,}[^\n]*)*", job_block(name)) + if match is None: + raise AssertionError(f"job condition not found: {name}") + return " ".join(match.group().split()) + + class ReleaseWorkflowContractTest(unittest.TestCase): + def test_nightly_runs_on_schedule_or_manual_channel_and_delegates_metadata_resolution( + self, + ) -> None: + self.assertIn('schedule:\n - cron: "0 12 * * *"', WORKFLOW) + self.assertRegex( + WORKFLOW, + r"(?ms) channel:\n" + r" description: \"Release channel\"\n" + r" required: true\n" + r" type: choice\n" + r" default: stable\n" + r" options:\n" + r" - stable\n" + r" - nightly", + ) + + nightly = job_block("nightly-prepare") + self.assertIn("github.event_name == 'schedule'", nightly) + self.assertIn( + "github.event_name == 'workflow_dispatch' && inputs.channel == 'nightly'", + nightly, + ) + self.assertIn("ref: ${{ github.sha }}", nightly) + validation = step_block("Validate manual Nightly request") + self.assertIn("refs/heads/main", validation) + self.assertIn("DESKTOP_VALIDATION_ONLY", validation) + self.assertIn("BACKFILL_TAG", validation) + metadata = step_block("Resolve nightly metadata") + self.assertIn("id: metadata", metadata) + self.assertIn("bash scripts/release/nightly-release.sh prepare", metadata) + self.assertIn('--scheduled-sha "${{ github.sha }}"', metadata) + self.assertIn('--output "$GITHUB_OUTPUT"', metadata) + self.assertNotIn("npm-view-version.sh", metadata) + self.assertIn( + "nightly_tags_at_start: ${{ steps.metadata.outputs.nightly_tags_at_start }}", + nightly, + ) + + def test_manual_nightly_dry_run_stops_after_preflight(self) -> None: + summary = step_block("Nightly dry-run summary") + self.assertIn("github.event_name == 'workflow_dispatch' && inputs.dry_run", summary) + self.assertIn("steps.metadata.outputs.should_publish", summary) + self.assertIn("steps.metadata.outputs.version", summary) + + for name in ("build-web", "build-bundles", "publish-npm-nightly"): + condition = job_condition(name) + self.assertIn("inputs.channel == 'nightly'", condition) + self.assertIn("!inputs.dry_run", condition) + + for name in ("build-web", "build-bundles"): + block = job_block(name) + self.assertIn( + "needs.nightly-prepare.result == 'success' && " + "needs.nightly-prepare.outputs.ref || needs.prepare.outputs.ref", + block, + ) + self.assertIn( + "needs.nightly-prepare.result == 'success' && " + "needs.nightly-prepare.outputs.tag || needs.prepare.outputs.tag", + block, + ) + + def test_nightly_package_inventory_remains_shared(self) -> None: + for package in ( + "kandev", + "@kdlbs/runtime-linux-x64", + "@kdlbs/runtime-linux-arm64", + "@kdlbs/runtime-darwin-x64", + "@kdlbs/runtime-darwin-arm64", + "@kdlbs/runtime-win32-x64", + ): + self.assertIn(package, NPM_PACKAGES) + + self.assertIn('NIGHTLY_PACKAGES=("kandev" "${RUNTIME_PACKAGES[@]}")', NPM_PACKAGES) + + def test_only_shared_runtime_builds_run_for_a_scheduled_nightly(self) -> None: + for name in ("build-web", "build-bundles"): + block = job_block(name) + self.assertIn("needs: [prepare, nightly-prepare", block) + self.assertIn("github.event_name == 'workflow_dispatch'", block) + self.assertIn("github.event_name == 'schedule'", block) + self.assertIn("inputs.channel == 'nightly'", block) + self.assertIn("needs.nightly-prepare.outputs.should_publish == 'true'", block) + + for name in ( + "prepare", + "build-desktop", + "docker-amd64", + "docker-arm64", + "docker-manifest", + "docker-universal-amd64", + "docker-universal-arm64", + "docker-universal-manifest", + "publish-release", + "publish-npm", + "update-homebrew-tap", + ): + block = job_block(name) + self.assertIn("github.event_name == 'workflow_dispatch'", block) + self.assertIn("inputs.channel == 'stable'", block) + self.assertNotIn("github.event_name == 'schedule'", block) + + def test_nightly_publish_uses_exact_sha_local_assets_and_release_serialization(self) -> None: + stable = job_block("publish-npm") + nightly = job_block("publish-npm-nightly") + + workflow_preamble = WORKFLOW.split("\njobs:", 1)[0] + self.assertIn("group: release-npm-publication", workflow_preamble) + self.assertIn("cancel-in-progress: false", workflow_preamble) + self.assertIn("queue: max", workflow_preamble) + + for block in (stable, nightly): + self.assertNotIn("\n concurrency:", block) + self.assertIn("id-token: write", block) + + self.assertIn("needs: [nightly-prepare, build-bundles]", nightly) + self.assertIn("needs.build-bundles.result == 'success'", nightly) + self.assertIn("needs.build-web.result == 'success'", job_block("build-bundles")) + self.assertIn("ref: ${{ needs.nightly-prepare.outputs.ref }}", nightly) + self.assertIn("fetch-depth: 0", nightly) + self.assertIn("pattern: bundle-*", nightly) + self.assertIn("merge-multiple: true", nightly) + self.assertIn('--version "${{ needs.nightly-prepare.outputs.version }}"', nightly) + self.assertIn('--assets-dir dist/nightly-assets', nightly) + publish = step_block("Publish npm nightly packages") + self.assertIn("bash scripts/release/nightly-release.sh publish", publish) + self.assertIn('--stable-at-start "$NIGHTLY_BASELINE"', publish) + self.assertIn('--nightly-at-start "$NIGHTLY_AT_START"', publish) + self.assertIn('--tags-at-start "$NIGHTLY_TAGS_AT_START"', publish) + self.assertIn( + "NIGHTLY_TAGS_AT_START: ${{ needs.nightly-prepare.outputs.nightly_tags_at_start }}", + nightly, + ) + self.assertNotIn("npm-view-version.sh", publish) + self.assertNotIn("publish-npm.sh", publish) + + self.assertIn('--version "${{ needs.prepare.outputs.version }}"', stable) + self.assertIn('--dist-tag latest', stable) + self.assertIn('--release-tag "${{ needs.prepare.outputs.tag }}"', stable) + + self.assertIn('elif [[ "$DIST_TAG" == "latest" && -z "$RELEASE_TAG" ]]', PUBLISH_NPM) + self.assertIn( + 'elif [[ "$DIST_TAG" == "nightly" && -z "$SOURCE_ASSETS_DIR" ]]', + PUBLISH_NPM, + ) + self.assertIn('bash "$ROOT_DIR/scripts/release/npm-view-version.sh"', PUBLISH_NPM) + self.assertIn('CLI_PACKAGE_BACKUP="$WORK_DIR/cli-package.json"', PUBLISH_NPM) + self.assertIn('cp "$CLI_PACKAGE_BACKUP" "$CLI_PACKAGE_JSON"', PUBLISH_NPM) + + def test_publish_npm_rejects_version_dist_tag_mismatches(self) -> None: + cases = ( + ( + "1.2.3-nightly.shaabcdef123456", + "latest", + "--version must be stable X.Y.Z for --dist-tag latest", + ), + ( + "1.2.3", + "nightly", + "--version must be X.Y.Z-nightly.sha<12-hex> for --dist-tag nightly", + ), + ) + for version, dist_tag, expected in cases: + with self.subTest(dist_tag=dist_tag): + result = subprocess.run( + [ + "bash", + str(PUBLISH_NPM_PATH), + "--version", + version, + "--dist-tag", + dist_tag, + ], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn(expected, result.stderr) + def test_normal_release_uses_release_environment_and_requires_main(self) -> None: prepare = job_block("prepare") self.assertIn( @@ -211,6 +401,33 @@ class ReleaseWorkflowContractTest(unittest.TestCase): self.assertIsNotNone(trigger_block) self.assertIn('".github/release-signing-key.asc"', trigger_block.group(0)) self.assertIn('"docs/public/release-process.md"', trigger_block.group(0)) + self.assertIn('"scripts/release/package-npm-runtime.sh"', trigger_block.group(0)) + self.assertIn('"scripts/release/publish-npm.sh"', trigger_block.group(0)) + self.assertIn('"scripts/release/publish-npm.test.mjs"', trigger_block.group(0)) + self.assertIn('"scripts/release/npm-packages.sh"', trigger_block.group(0)) + self.assertIn('"scripts/release/npm-view-version.sh"', trigger_block.group(0)) + self.assertIn('"scripts/release/npm-view-version.test.mjs"', trigger_block.group(0)) + self.assertIn('"scripts/release/nightly-version.mjs"', trigger_block.group(0)) + self.assertIn('"scripts/release/nightly-version.test.mjs"', trigger_block.group(0)) + self.assertIn('"scripts/release/nightly-release.sh"', trigger_block.group(0)) + self.assertIn('"scripts/release/nightly-release.test.mjs"', trigger_block.group(0)) + + setup_node = ( + "uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6" + ) + self.assertIn(setup_node, LINT_WORKFLOW) + self.assertIn('node-version: "24"', LINT_WORKFLOW) + self.assertLess( + LINT_WORKFLOW.index(setup_node), + LINT_WORKFLOW.index("- name: Test npm release helpers"), + ) + self.assertIn( + "node --test scripts/release/nightly-version.test.mjs " + "scripts/release/nightly-release.test.mjs " + "scripts/release/npm-view-version.test.mjs " + "scripts/release/publish-npm.test.mjs", + LINT_WORKFLOW, + ) def test_tag_push_recovery_recreates_tag_at_logged_merge_commit(self) -> None: tag = step_block("Create and push signed release tag") @@ -235,7 +452,7 @@ class ReleaseWorkflowContractTest(unittest.TestCase): "@kdlbs/runtime-darwin-arm64", "@kdlbs/runtime-win32-x64", ): - self.assertIn(f'"{package}"', PUBLISH_NPM) + self.assertIn(f'"{package}"', NPM_PACKAGES) def test_backfill_tag_input_uses_existing_tag_without_recreating_it(self) -> None: self.assertIn("backfill_tag:", WORKFLOW) @@ -284,7 +501,10 @@ class ReleaseWorkflowContractTest(unittest.TestCase): "update-homebrew-tap", ): block = job_block(name) - self.assertIn("if: ${{ !inputs.dry_run", block) + self.assertRegex( + job_condition(name), + r"github\.event_name == 'workflow_dispatch'.*!inputs\.dry_run", + ) self.assertNotIn("inputs.backfill_tag == ''", block) def test_updater_signing_validation_uses_workflow_control_revision(self) -> None: diff --git a/.github/workflows/lint-action-pinning.yml b/.github/workflows/lint-action-pinning.yml index 084461b0a8..d7f2eb0c29 100644 --- a/.github/workflows/lint-action-pinning.yml +++ b/.github/workflows/lint-action-pinning.yml @@ -12,6 +12,16 @@ on: - ".github/scripts/release-workflow-contract_test.py" - ".github/release-signing-key.asc" - "docs/public/release-process.md" + - "scripts/release/npm-packages.sh" + - "scripts/release/nightly-version.mjs" + - "scripts/release/nightly-version.test.mjs" + - "scripts/release/nightly-release.sh" + - "scripts/release/nightly-release.test.mjs" + - "scripts/release/npm-view-version.sh" + - "scripts/release/npm-view-version.test.mjs" + - "scripts/release/package-npm-runtime.sh" + - "scripts/release/publish-npm.sh" + - "scripts/release/publish-npm.test.mjs" pull_request: branches: [main] paths: @@ -23,6 +33,16 @@ on: - ".github/scripts/release-workflow-contract_test.py" - ".github/release-signing-key.asc" - "docs/public/release-process.md" + - "scripts/release/npm-packages.sh" + - "scripts/release/nightly-version.mjs" + - "scripts/release/nightly-version.test.mjs" + - "scripts/release/nightly-release.sh" + - "scripts/release/nightly-release.test.mjs" + - "scripts/release/npm-view-version.sh" + - "scripts/release/npm-view-version.test.mjs" + - "scripts/release/package-npm-runtime.sh" + - "scripts/release/publish-npm.sh" + - "scripts/release/publish-npm.test.mjs" concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -52,5 +72,13 @@ jobs: - name: Test release workflow contract run: python3 .github/scripts/release-workflow-contract_test.py + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: "24" + + - name: Test npm release helpers + run: node --test scripts/release/nightly-version.test.mjs scripts/release/nightly-release.test.mjs scripts/release/npm-view-version.test.mjs scripts/release/publish-npm.test.mjs + - name: Verify action refs run: python3 .github/scripts/lint-action-pinning.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5067f09ad4..9cb49f38ac 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,7 @@ name: release -# Maintainer-only release workflow. -# Trigger: GitHub Actions UI → "Run workflow" (workflow_dispatch). +# Stable releases are maintainer-dispatched. npm nightlies run from main at +# 12:00 UTC when main has changed since the latest stable npm release. # # Flow (one click → everything below): # 1. prepare — bump version, regen CHANGELOG, open + merge release PR, push tag vX.Y.Z @@ -39,10 +39,20 @@ name: release # TAURI_SIGNING_PRIVATE_KEY / TAURI_SIGNING_PRIVATE_KEY_PASSWORD — updater artifact signing on: + schedule: + - cron: "0 12 * * *" workflow_dispatch: inputs: + channel: + description: "Release channel" + required: true + type: choice + default: stable + options: + - stable + - nightly bump: - description: "Version bump" + description: "Stable only: version bump" required: true type: choice default: minor @@ -51,19 +61,26 @@ on: - minor - major dry_run: - description: "Dry run (no PR, tag, npm publish, or tap update)" + description: "Validate selected channel without publishing" type: boolean default: false desktop_validation_only: - description: "Build desktop artifacts only (no PR, tag, publish, npm, or tap update)" + description: "Stable only: build desktop artifacts without publishing" type: boolean default: false backfill_tag: - description: "Existing release tag to rebuild/publish (for recovery; leave empty for normal release)" + description: "Stable only: existing release tag to rebuild/publish" required: false default: "" type: string +# A Stable tag is pushed before its npm job is ready. Serializing whole runs +# prevents a Nightly from using the previous npm baseline during that window. +concurrency: + group: release-npm-publication + cancel-in-progress: false + queue: max + # Default to read-only at the workflow level. Each job opts into the writes # it actually needs. permissions: @@ -72,6 +89,7 @@ permissions: jobs: prepare: name: Prepare release (bump version, PR, tag) + if: ${{ github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' }} runs-on: ubuntu-latest environment: ${{ (!inputs.dry_run && !inputs.desktop_validation_only && inputs.backfill_tag == '') && 'release' || 'release-validation' }} permissions: @@ -574,15 +592,91 @@ jobs: echo "DRY RUN: apps/cli/package.json bumped to $NEXT (in-runner only)." echo "DRY RUN: CHANGELOG.md regenerated (in-runner only)." + nightly-prepare: + name: Prepare npm nightly metadata + if: >- + ${{ + github.event_name == 'schedule' || + (github.event_name == 'workflow_dispatch' && inputs.channel == 'nightly') + }} + runs-on: ubuntu-latest + outputs: + should_publish: ${{ steps.metadata.outputs.should_publish }} + version: ${{ steps.metadata.outputs.version }} + tag: ${{ steps.metadata.outputs.tag }} + ref: ${{ steps.metadata.outputs.ref }} + stable_version: ${{ steps.metadata.outputs.stable_version }} + nightly_version_at_start: ${{ steps.metadata.outputs.nightly_version_at_start }} + nightly_tags_at_start: ${{ steps.metadata.outputs.nightly_tags_at_start }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + + - name: Validate manual Nightly request + if: ${{ github.event_name == 'workflow_dispatch' }} + env: + CURRENT_REF: ${{ github.ref }} + DESKTOP_VALIDATION_ONLY: ${{ inputs.desktop_validation_only }} + BACKFILL_TAG: ${{ inputs.backfill_tag }} + BUMP: ${{ inputs.bump }} + run: | + set -euo pipefail + if [ "$CURRENT_REF" != "refs/heads/main" ]; then + echo "Manual Nightlies must be dispatched from refs/heads/main (got $CURRENT_REF)." >&2 + exit 1 + fi + if [ "$DESKTOP_VALIDATION_ONLY" = "true" ]; then + echo "desktop_validation_only cannot be combined with channel=nightly." >&2 + exit 1 + fi + if [ -n "$BACKFILL_TAG" ]; then + echo "backfill_tag cannot be combined with channel=nightly." >&2 + exit 1 + fi + echo "::notice::The Stable-only bump input ($BUMP) is ignored for Nightly." + + - name: Resolve nightly metadata + id: metadata + run: | + bash scripts/release/nightly-release.sh prepare \ + --scheduled-sha "${{ github.sha }}" \ + --output "$GITHUB_OUTPUT" + + - name: Nightly dry-run summary + if: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} + env: + SHOULD_PUBLISH: ${{ steps.metadata.outputs.should_publish }} + VERSION: ${{ steps.metadata.outputs.version }} + REF: ${{ steps.metadata.outputs.ref }} + run: | + if [ "$SHOULD_PUBLISH" = "true" ]; then + echo "DRY RUN: Would publish $VERSION from $REF under npm's nightly dist-tag." + else + echo "DRY RUN: Nightly preflight found no eligible publication." + fi + echo "DRY RUN: No bundles were built and npm was not changed." + build-web: name: Build web SPA - needs: prepare - if: ${{ !inputs.dry_run }} + needs: [prepare, nightly-prepare] + if: >- + ${{ !cancelled() && ( + (github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && needs.prepare.result == 'success' && !inputs.dry_run) || + ((github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.channel == 'nightly' && !inputs.dry_run)) && needs.nightly-prepare.result == 'success' && needs.nightly-prepare.outputs.should_publish == 'true') + ) }} runs-on: ubuntu-latest steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ needs.prepare.outputs.ref }} + ref: ${{ needs.nightly-prepare.result == 'success' && needs.nightly-prepare.outputs.ref || needs.prepare.outputs.ref }} persist-credentials: false - uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 with: @@ -596,7 +690,7 @@ jobs: run: pnpm -C apps install --frozen-lockfile - name: Build web (Vite) env: - KANDEV_VERSION: ${{ needs.prepare.outputs.tag }} + KANDEV_VERSION: ${{ needs.nightly-prepare.result == 'success' && needs.nightly-prepare.outputs.tag || needs.prepare.outputs.tag }} VITE_KANDEV_API_PORT: "" VITE_KANDEV_DEBUG: "" run: pnpm -C apps --filter @kandev/web build @@ -608,8 +702,12 @@ jobs: build-bundles: name: Build platform bundle (${{ matrix.platform }}) - needs: [prepare, build-web] - if: ${{ !inputs.dry_run }} + needs: [prepare, nightly-prepare, build-web] + if: >- + ${{ !cancelled() && needs.build-web.result == 'success' && ( + (github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && needs.prepare.result == 'success' && !inputs.dry_run) || + ((github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.channel == 'nightly' && !inputs.dry_run)) && needs.nightly-prepare.result == 'success' && needs.nightly-prepare.outputs.should_publish == 'true') + ) }} strategy: matrix: include: @@ -642,7 +740,7 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - ref: ${{ needs.prepare.outputs.ref }} + ref: ${{ needs.nightly-prepare.result == 'success' && needs.nightly-prepare.outputs.ref || needs.prepare.outputs.ref }} persist-credentials: false - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6 with: @@ -671,7 +769,7 @@ jobs: - name: Build backend binaries env: CC: ${{ matrix.cc || '' }} - KANDEV_VERSION: ${{ needs.prepare.outputs.tag }} + KANDEV_VERSION: ${{ needs.nightly-prepare.result == 'success' && needs.nightly-prepare.outputs.tag || needs.prepare.outputs.tag }} run: | test -f apps/backend/internal/webapp/embedded/generated/index.html cd apps/backend @@ -725,7 +823,7 @@ jobs: build-desktop: name: Build desktop app (${{ matrix.platform }}) needs: [prepare, build-bundles] - if: ${{ !inputs.dry_run }} + if: ${{ github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.dry_run }} strategy: matrix: include: @@ -1271,7 +1369,7 @@ jobs: docker-amd64: name: Build & push container image (amd64) needs: [prepare, build-bundles] - if: ${{ !inputs.dry_run && !inputs.desktop_validation_only }} + if: ${{ github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.dry_run && !inputs.desktop_validation_only }} runs-on: ubuntu-latest permissions: contents: read @@ -1342,7 +1440,7 @@ jobs: docker-arm64: name: Build & push container image (arm64) needs: [prepare, build-bundles] - if: ${{ !inputs.dry_run && !inputs.desktop_validation_only }} + if: ${{ github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.dry_run && !inputs.desktop_validation_only }} runs-on: ubuntu-24.04-arm permissions: contents: read @@ -1401,7 +1499,7 @@ jobs: docker-manifest: name: Assemble multi-arch base manifest needs: [prepare, docker-amd64, docker-arm64] - if: ${{ !inputs.dry_run && !inputs.desktop_validation_only }} + if: ${{ github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.dry_run && !inputs.desktop_validation_only }} runs-on: ubuntu-latest permissions: contents: read @@ -1455,7 +1553,7 @@ jobs: docker-universal-amd64: name: Build & push universal image (amd64) needs: [prepare, docker-manifest] - if: ${{ !inputs.dry_run && !inputs.desktop_validation_only }} + if: ${{ github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.dry_run && !inputs.desktop_validation_only }} runs-on: ubuntu-latest permissions: contents: read @@ -1507,7 +1605,7 @@ jobs: docker-universal-arm64: name: Build & push universal image (arm64) needs: [prepare, docker-manifest] - if: ${{ !inputs.dry_run && !inputs.desktop_validation_only }} + if: ${{ github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.dry_run && !inputs.desktop_validation_only }} runs-on: ubuntu-24.04-arm permissions: contents: read @@ -1558,7 +1656,7 @@ jobs: # docker-universal-manifest is included in publish-release's `needs` so # its size gate (and any per-arch build failure) blocks the release. needs: [prepare, docker-universal-amd64, docker-universal-arm64] - if: ${{ !inputs.dry_run && !inputs.desktop_validation_only }} + if: ${{ github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.dry_run && !inputs.desktop_validation_only }} runs-on: ubuntu-latest permissions: contents: read @@ -1615,7 +1713,7 @@ jobs: publish-release: name: Publish GitHub release needs: [prepare, build-bundles, build-desktop, docker-universal-manifest] - if: ${{ !inputs.dry_run && !inputs.desktop_validation_only }} + if: ${{ github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.dry_run && !inputs.desktop_validation_only }} runs-on: ubuntu-latest permissions: contents: write # softprops/action-gh-release creates the release @@ -1765,7 +1863,7 @@ jobs: publish-npm: name: Publish npm packages needs: [prepare, publish-release] - if: ${{ !inputs.dry_run && !inputs.desktop_validation_only }} + if: ${{ github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.dry_run && !inputs.desktop_validation_only }} runs-on: ubuntu-latest permissions: id-token: write # Required for OIDC trusted publishing @@ -1794,13 +1892,68 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | bash scripts/release/publish-npm.sh \ - "${{ needs.prepare.outputs.version }}" \ - "${{ needs.prepare.outputs.tag }}" + --version "${{ needs.prepare.outputs.version }}" \ + --dist-tag latest \ + --release-tag "${{ needs.prepare.outputs.tag }}" + + publish-npm-nightly: + name: Publish npm nightly packages + needs: [nightly-prepare, build-bundles] + if: >- + ${{ !cancelled() && + (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.channel == 'nightly' && !inputs.dry_run)) && + needs.nightly-prepare.result == 'success' && + needs.nightly-prepare.outputs.should_publish == 'true' && + needs.build-bundles.result == 'success' + }} + runs-on: ubuntu-latest + permissions: + id-token: write # Required for OIDC trusted publishing + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + ref: ${{ needs.nightly-prepare.outputs.ref }} + fetch-depth: 0 + persist-credentials: false + + - uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 + with: + version: "9.15.9" + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + cache: pnpm + cache-dependency-path: apps/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm -C apps install --frozen-lockfile + + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + pattern: bundle-* + path: dist/nightly-assets + merge-multiple: true + + - name: Publish npm nightly packages + env: + NIGHTLY_BASELINE: ${{ needs.nightly-prepare.outputs.stable_version }} + NIGHTLY_AT_START: ${{ needs.nightly-prepare.outputs.nightly_version_at_start }} + NIGHTLY_TAGS_AT_START: ${{ needs.nightly-prepare.outputs.nightly_tags_at_start }} + run: | + bash scripts/release/nightly-release.sh publish \ + --stable-at-start "$NIGHTLY_BASELINE" \ + --nightly-at-start "$NIGHTLY_AT_START" \ + --tags-at-start "$NIGHTLY_TAGS_AT_START" \ + --version "${{ needs.nightly-prepare.outputs.version }}" \ + --assets-dir dist/nightly-assets update-homebrew-tap: name: Update Homebrew tap needs: [prepare, publish-release] - if: ${{ !inputs.dry_run && !inputs.desktop_validation_only }} + if: ${{ github.event_name == 'workflow_dispatch' && inputs.channel == 'stable' && !inputs.dry_run && !inputs.desktop_validation_only }} runs-on: ubuntu-latest steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 312cb74d51..11fdafc88e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,7 +36,7 @@ repos: - id: go-lint name: Go lint (PR changed code) - entry: bash -c 'unset GIT_DIR GIT_WORK_TREE; base=$(git merge-base HEAD origin/main 2>/dev/null || git rev-parse HEAD); cd apps/backend && golangci-lint run ./... --new-from-rev="$base" --timeout=5m' + entry: bash -c 'unset GIT_DIR GIT_WORK_TREE; merge_head=$(git rev-parse --verify MERGE_HEAD 2>/dev/null || true); base_tip=$(git rev-parse --verify origin/main 2>/dev/null || true); if [[ -n "$merge_head" && "$merge_head" == "$base_tip" ]]; then base="$merge_head"; else base=$(git merge-base HEAD origin/main 2>/dev/null || git rev-parse HEAD); fi; cd apps/backend && golangci-lint run ./... --new-from-rev="$base" --timeout=5m' language: system files: ^apps/backend/.*\.go$ pass_filenames: false diff --git a/AGENTS.md b/AGENTS.md index 6aece6d80a..0bd1f3fb97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,7 @@ The commitlint hook caps the header at **100 characters** (`type(scope): descrip ### Release & Versioning -Kandev uses a **single SemVer** `X.Y.Z` across npm, Homebrew, and GitHub release; release flow runs entirely in CI via `.github/workflows/release.yml`. Full details in the `/release` skill — load it when cutting a release or debugging release artifacts. +Stable Kandev releases use one **SemVer** `X.Y.Z` across npm, Homebrew, GitHub Releases, Desktop, and containers. Scheduled npm-only Nightlies use `X.Y.(Z+1)-nightly.sha<12-hex>` without moving any Stable channel. Both flows run in `.github/workflows/release.yml`. Full details are in the `/release` skill — load it when cutting a release, changing version channels, or debugging release artifacts. ### Code Quality diff --git a/Makefile b/Makefile index 39c69598fd..cc6b90ac8b 100644 --- a/Makefile +++ b/Makefile @@ -501,6 +501,8 @@ test-scripts: @python3 scripts/lint-architecture.test.py @bash scripts/release-desktop.test.sh @node --test apps/desktop/e2e/desktop-launch-smoke.test.mjs + @python3 .github/scripts/release-workflow-contract_test.py + @node --test scripts/release/nightly-version.test.mjs scripts/release/nightly-release.test.mjs scripts/release/npm-view-version.test.mjs scripts/release/publish-npm.test.mjs @node --test scripts/validate-public-docs.test.mjs .PHONY: test-e2e diff --git a/README.md b/README.md index 97e4173a90..9b75f29735 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,23 @@ kandev - `npx kandev@latest` (always uses the latest published version) - `npm install -g kandev@latest` +To try the current prerelease from `main`, use npm's `nightly` tag: + +```bash +npx -y kandev@nightly +# or +npm install -g kandev@nightly +``` + +`npx` runs a transient Nightly without changing a global package. The global install replaces your +installed Kandev until you reinstall `kandev@latest`. + +Nightlies are best-effort builds scheduled for 12:00 UTC when `main` has changed since the latest +stable release. They do not move `latest`, and no Homebrew or Desktop nightly channel is published. +A verified Kandev-managed npm/npx user service can switch between Stable and Nightly in +**Settings > System > Updates**. Other install types expose only Stable in that page; npm/npx users +can still invoke `@nightly` directly with the commands above. + The worktrees and sqlite db live in `~/.kandev` by default. Works on macOS, Linux, and Windows (WSL or native). ### From Source diff --git a/apps/backend/internal/persistence/meta.go b/apps/backend/internal/persistence/meta.go index 5089d63611..1f816eacb7 100644 --- a/apps/backend/internal/persistence/meta.go +++ b/apps/backend/internal/persistence/meta.go @@ -11,17 +11,45 @@ import ( ) const ( - metaKeyLatestVersion = "latest_version" - metaKeyLatestVersionURL = "latest_version_url" - metaKeyLatestVersionCheckedAt = "latest_version_checked_at" + metaKeyLatestVersion = "latest_version" + metaKeyLatestVersionURL = "latest_version_url" + metaKeyLatestVersionCheckedAt = "latest_version_checked_at" + metaKeyLatestNightlyVersion = "latest_version_nightly" + metaKeyLatestNightlyVersionURL = "latest_version_nightly_url" + metaKeyLatestNightlyVersionCheckedAt = "latest_version_nightly_checked_at" ) +type latestVersionKeys struct { + version string + url string + checkedAt string +} + +var stableLatestVersionKeys = latestVersionKeys{ + version: metaKeyLatestVersion, + url: metaKeyLatestVersionURL, + checkedAt: metaKeyLatestVersionCheckedAt, +} + +var nightlyLatestVersionKeys = latestVersionKeys{ + version: metaKeyLatestNightlyVersion, + url: metaKeyLatestNightlyVersionURL, + checkedAt: metaKeyLatestNightlyVersionCheckedAt, +} + const metaTableDDL = ` CREATE TABLE IF NOT EXISTS kandev_meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '' )` +const metaKeyUpsert = `INSERT INTO kandev_meta (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value` + +type metaKeyExecutor interface { + Exec(query string, args ...interface{}) (sql.Result, error) +} + // ensureMetaTable creates the kandev_meta table if it does not exist. func ensureMetaTable(db *sqlx.DB) error { if _, err := db.Exec(metaTableDDL); err != nil { @@ -45,15 +73,7 @@ func readKey(db *sqlx.DB, key string) (string, error) { // writeKey upserts key=value into kandev_meta. func writeKey(db *sqlx.DB, key, value string) error { - _, err := db.Exec( - db.Rebind(`INSERT INTO kandev_meta (key, value) VALUES (?, ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value`), - key, value, - ) - if err != nil { - return fmt.Errorf("write meta key %q: %w", key, err) - } - return nil + return writeMetaKey(db, db.Rebind(metaKeyUpsert), key, value) } // WriteVersion records currentVersion as the binary version that last @@ -68,18 +88,34 @@ func WriteVersion(db *sqlx.DB, version string) error { // WriteLatestVersion persists the highest semver tag from the GitHub Releases // poll, its release URL, and the timestamp of the successful poll. The three -// values are written under separate keys (latest_version, -// latest_version_url, latest_version_checked_at) on the existing key/value -// kandev_meta table. +// values use separate keys (latest_version, latest_version_url, +// latest_version_checked_at) on the existing key/value kandev_meta table, but +// the tuple is committed atomically. func WriteLatestVersion(db *sqlx.DB, version, url string, checkedAt time.Time) error { - if err := writeKey(db, metaKeyLatestVersion, version); err != nil { + return writeLatestVersion(db, stableLatestVersionKeys, version, url, checkedAt) +} + +func writeLatestVersion(db *sqlx.DB, keys latestVersionKeys, version, url string, checkedAt time.Time) error { + tx, err := db.Beginx() + if err != nil { + return fmt.Errorf("begin latest version write: %w", err) + } + defer func() { _ = tx.Rollback() }() + query := db.Rebind(metaKeyUpsert) + if err := writeMetaKey(tx, query, keys.version, version); err != nil { return err } - if err := writeKey(db, metaKeyLatestVersionURL, url); err != nil { + if err := writeMetaKey(tx, query, keys.url, url); err != nil { return err } ts := strconv.FormatInt(checkedAt.UTC().Unix(), 10) - return writeKey(db, metaKeyLatestVersionCheckedAt, ts) + if err := writeMetaKey(tx, query, keys.checkedAt, ts); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit latest version write: %w", err) + } + return nil } // ReadLatestVersion returns the last-known latest release tag, its URL, and @@ -87,29 +123,52 @@ func WriteLatestVersion(db *sqlx.DB, version, url string, checkedAt time.Time) e // the keys are absent (e.g. on a fresh install before the first poll // completes) and tolerates a subset of the three keys being missing. func ReadLatestVersion(db *sqlx.DB) (string, string, time.Time, error) { - version, err := readKey(db, metaKeyLatestVersion) - if err != nil { - return "", "", time.Time{}, err - } - url, err := readKey(db, metaKeyLatestVersionURL) - if err != nil { - return "", "", time.Time{}, err - } - tsRaw, err := readKey(db, metaKeyLatestVersionCheckedAt) - if err != nil { - return "", "", time.Time{}, err + return readLatestVersion(db, stableLatestVersionKeys) +} + +func readLatestVersion(db *sqlx.DB, keys latestVersionKeys) (string, string, time.Time, error) { + query := db.Rebind(`SELECT + COALESCE(MAX(CASE WHEN key = ? THEN value END), ''), + COALESCE(MAX(CASE WHEN key = ? THEN value END), ''), + COALESCE(MAX(CASE WHEN key = ? THEN value END), '') + FROM kandev_meta`) + var version, url, tsRaw string + if err := db.QueryRow(query, keys.version, keys.url, keys.checkedAt).Scan( + &version, + &url, + &tsRaw, + ); err != nil { + return "", "", time.Time{}, fmt.Errorf("read latest version tuple: %w", err) } var checkedAt time.Time if tsRaw != "" { secs, perr := strconv.ParseInt(tsRaw, 10, 64) if perr != nil { - return "", "", time.Time{}, fmt.Errorf("parse latest_version_checked_at %q: %w", tsRaw, perr) + return "", "", time.Time{}, fmt.Errorf("parse %s %q: %w", keys.checkedAt, tsRaw, perr) } checkedAt = time.Unix(secs, 0).UTC() } return version, url, checkedAt, nil } +func writeMetaKey(executor metaKeyExecutor, query, key, value string) error { + if _, err := executor.Exec(query, key, value); err != nil { + return fmt.Errorf("write meta key %q: %w", key, err) + } + return nil +} + +// WriteLatestNightlyVersion persists the npm nightly target independently +// from the stable GitHub release cache. +func WriteLatestNightlyVersion(db *sqlx.DB, version, url string, checkedAt time.Time) error { + return writeLatestVersion(db, nightlyLatestVersionKeys, version, url, checkedAt) +} + +// ReadLatestNightlyVersion returns the isolated npm nightly target cache. +func ReadLatestNightlyVersion(db *sqlx.DB) (string, string, time.Time, error) { + return readLatestVersion(db, nightlyLatestVersionKeys) +} + // hasUserTables returns true when the DB contains at least one table that is // not part of the SQLite internal schema and not kandev_meta itself. This // distinguishes a genuinely fresh DB from a pre-meta DB being upgraded. diff --git a/apps/backend/internal/persistence/meta_test.go b/apps/backend/internal/persistence/meta_test.go index b56f428e66..0e020779be 100644 --- a/apps/backend/internal/persistence/meta_test.go +++ b/apps/backend/internal/persistence/meta_test.go @@ -1,6 +1,7 @@ package persistence import ( + "fmt" "testing" "time" @@ -14,6 +15,7 @@ func memSQLiteDB(t *testing.T) *sqlx.DB { if err != nil { t.Fatalf("open sqlite: %v", err) } + db.SetMaxOpenConns(1) t.Cleanup(func() { _ = db.Close() }) return db } @@ -170,6 +172,74 @@ func TestReadLatestVersion_PartialPersistedState(t *testing.T) { } } +func TestWriteLatestNightlyVersionRollsBackPartialTuple(t *testing.T) { + db := memSQLiteDB(t) + if err := ensureMetaTable(db); err != nil { + t.Fatalf("ensureMetaTable: %v", err) + } + if _, err := db.Exec(fmt.Sprintf(` + CREATE TRIGGER fail_nightly_url + BEFORE INSERT ON kandev_meta + WHEN NEW.key = '%s' + BEGIN + SELECT RAISE(ABORT, 'forced nightly URL failure'); + END`, metaKeyLatestNightlyVersionURL)); err != nil { + t.Fatalf("create trigger: %v", err) + } + + err := WriteLatestNightlyVersion( + db, + "1.2.4-nightly.shaabc123def456", + "https://example/nightly", + time.Unix(1_700_000_000, 0).UTC(), + ) + if err == nil { + t.Fatal("WriteLatestNightlyVersion error = nil, want forced failure") + } + version, readErr := readKey(db, metaKeyLatestNightlyVersion) + if readErr != nil { + t.Fatalf("read nightly version: %v", readErr) + } + if version != "" { + t.Fatalf("partial nightly version persisted: %q", version) + } +} + +func TestStableAndNightlyLatestVersionsAreIsolated(t *testing.T) { + db := memSQLiteDB(t) + if err := ensureMetaTable(db); err != nil { + t.Fatalf("ensureMetaTable: %v", err) + } + stableAt := time.Unix(1_700_000_000, 0).UTC() + nightlyAt := stableAt.Add(time.Hour) + if err := WriteLatestVersion(db, "v1.2.3", "https://example/stable", stableAt); err != nil { + t.Fatalf("WriteLatestVersion: %v", err) + } + if err := WriteLatestNightlyVersion( + db, + "1.2.4-nightly.shaabc123def456", + "https://example/nightly", + nightlyAt, + ); err != nil { + t.Fatalf("WriteLatestNightlyVersion: %v", err) + } + + stable, stableURL, stableChecked, err := ReadLatestVersion(db) + if err != nil { + t.Fatalf("ReadLatestVersion: %v", err) + } + nightly, nightlyURL, nightlyChecked, err := ReadLatestNightlyVersion(db) + if err != nil { + t.Fatalf("ReadLatestNightlyVersion: %v", err) + } + if stable != "v1.2.3" || stableURL != "https://example/stable" || !stableChecked.Equal(stableAt) { + t.Fatalf("stable cache changed: version=%q url=%q checked=%v", stable, stableURL, stableChecked) + } + if nightly != "1.2.4-nightly.shaabc123def456" || nightlyURL != "https://example/nightly" || !nightlyChecked.Equal(nightlyAt) { + t.Fatalf("nightly cache mismatch: version=%q url=%q checked=%v", nightly, nightlyURL, nightlyChecked) + } +} + // TestShouldBackup verifies the backup decision logic. func TestShouldBackup(t *testing.T) { tests := []struct { diff --git a/apps/backend/internal/persistence/postgres_meta_test.go b/apps/backend/internal/persistence/postgres_meta_test.go index 926dfe4d17..56932da870 100644 --- a/apps/backend/internal/persistence/postgres_meta_test.go +++ b/apps/backend/internal/persistence/postgres_meta_test.go @@ -25,4 +25,31 @@ func TestPostgresLatestVersionMetaRoundTrip(t *testing.T) { if version != "v1.2.3" || url != "https://example.test/release" || !gotCheckedAt.Equal(checkedAt) { t.Fatalf("meta round-trip = (%q, %q, %s)", version, url, gotCheckedAt) } + + nightlyCheckedAt := checkedAt.Add(time.Hour) + if err := WriteLatestNightlyVersion( + db, + "v1.2.4-nightly.shaabc123def456", + "https://example.test/nightly", + nightlyCheckedAt, + ); err != nil { + t.Fatalf("write latest nightly version: %v", err) + } + nightlyVersion, nightlyURL, gotNightlyCheckedAt, err := ReadLatestNightlyVersion(db) + if err != nil { + t.Fatalf("read latest nightly version: %v", err) + } + if nightlyVersion != "v1.2.4-nightly.shaabc123def456" || + nightlyURL != "https://example.test/nightly" || + !gotNightlyCheckedAt.Equal(nightlyCheckedAt) { + t.Fatalf("nightly meta round-trip = (%q, %q, %s)", nightlyVersion, nightlyURL, gotNightlyCheckedAt) + } + + version, url, gotCheckedAt, err = ReadLatestVersion(db) + if err != nil { + t.Fatalf("read stable version after nightly write: %v", err) + } + if version != "v1.2.3" || url != "https://example.test/release" || !gotCheckedAt.Equal(checkedAt) { + t.Fatalf("stable meta changed after nightly write = (%q, %q, %s)", version, url, gotCheckedAt) + } } diff --git a/apps/backend/internal/system/system.go b/apps/backend/internal/system/system.go index 9524acb246..362f31c4c5 100644 --- a/apps/backend/internal/system/system.go +++ b/apps/backend/internal/system/system.go @@ -11,7 +11,9 @@ package system import ( "context" + "os" "path/filepath" + "strings" "github.com/gin-gonic/gin" @@ -35,6 +37,8 @@ import ( "go.uber.org/zap" ) +const e2eNPMRegistryURLEnv = "KANDEV_E2E_NPM_REGISTRY_URL" + // BuildInfo holds the ldflag-injected build metadata that cmd/kandev // passes to Provide. type BuildInfo struct { @@ -101,6 +105,12 @@ func Provide(cfg *config.Config, log *logger.Logger, pool *db.Pool, eventBus bus if settingsStore != nil { metricsStore := metrics.NewStore(settingsStore) metricsSvc = metrics.NewService(metricsStore, metrics.NewCollector()) + updatesOpts = append(updatesOpts, updates.WithSettingsStore(settingsStore)) + } + + updatesSvc := updates.NewService(pool, build.Version, nil, log, updatesOpts...) + if registryURL := e2eNPMRegistryURL(); registryURL != "" { + updatesSvc.SetNightlyURL(registryURL) } return &Service{ @@ -115,11 +125,18 @@ func Provide(cfg *config.Config, log *logger.Logger, pool *db.Pool, eventBus bus }), FrontendErrors: frontenderrors.New(log, nil), Metrics: metricsSvc, - Updates: updates.NewService(pool, build.Version, nil, log, updatesOpts...), + Updates: updatesSvc, Restart: restart.NewManagerFromEnv(), } } +func e2eNPMRegistryURL() string { + if os.Getenv("KANDEV_E2E_MOCK") != "true" { + return "" + } + return strings.TrimSpace(os.Getenv(e2eNPMRegistryURLEnv)) +} + // RegisterRoutes mounts every system endpoint under /api/v1/system. // The health endpoint is mounted by internal/health.RegisterRoutes, // not here, to keep the existing package's surface unchanged. @@ -159,6 +176,7 @@ func (s *Service) RegisterRoutes(router *gin.Engine, log *logger.Logger) { g.GET("/updates", updates.HandleGet(s.Updates)) admin.POST("/updates/check", updates.HandleCheck(s.Updates)) + admin.PATCH("/updates/channel", updates.HandleSetChannel(s.Updates)) admin.POST("/updates/apply", updates.HandleApply(s.Updates)) g.GET("/restart-capability", restart.HandleCapability(s.Restart)) admin.POST("/restart", restart.HandleRequest(s.Restart)) diff --git a/apps/backend/internal/system/system_e2e_test.go b/apps/backend/internal/system/system_e2e_test.go new file mode 100644 index 0000000000..0c7469fb3f --- /dev/null +++ b/apps/backend/internal/system/system_e2e_test.go @@ -0,0 +1,17 @@ +package system + +import "testing" + +func TestE2ENPMRegistryURLIsStrictlyTestGated(t *testing.T) { + t.Setenv(e2eNPMRegistryURLEnv, " http://127.0.0.1:1234/kandev ") + + t.Setenv("KANDEV_E2E_MOCK", "false") + if got := e2eNPMRegistryURL(); got != "" { + t.Fatalf("e2eNPMRegistryURL() without test gate = %q, want empty", got) + } + + t.Setenv("KANDEV_E2E_MOCK", "true") + if got := e2eNPMRegistryURL(); got != "http://127.0.0.1:1234/kandev" { + t.Fatalf("e2eNPMRegistryURL() = %q", got) + } +} diff --git a/apps/backend/internal/system/updates/apply.go b/apps/backend/internal/system/updates/apply.go index e96857fa40..bab791eedd 100644 --- a/apps/backend/internal/system/updates/apply.go +++ b/apps/backend/internal/system/updates/apply.go @@ -13,8 +13,6 @@ import ( "strconv" "strings" "time" - - "github.com/kandev/kandev/internal/persistence" ) type applyRunner func(context.Context, applyRequest) (map[string]interface{}, error) @@ -43,24 +41,38 @@ type updateIntent struct { CreatedAt string `json:"created_at"` } -func (s *Service) applyPreflight() (UpdatesResponse, *serviceInstallMetadata, error) { - version, releaseURL, checkedAt, err := persistence.ReadLatestVersion(s.pool.Reader()) +func (s *Service) applyPreflight( + ctx context.Context, + expectedTarget string, +) (UpdatesResponse, *serviceInstallMetadata, error) { + s.updateMu.Lock() + defer s.updateMu.Unlock() + + // Read install state once so channel capability, ApplySupported, and the + // intent file all reflect the same service metadata snapshot. + install, metadata := s.detectInstallState() + applySupported, reason := install.applySupport() + if !applySupported { + return UpdatesResponse{}, nil, fmt.Errorf("%w: %s", ErrApplyUnsupported, reason) + } + channel, err := s.effectiveChannel(ctx, install) if err != nil { return UpdatesResponse{}, nil, err } - // Read install state once so the ApplySupported gate below and the intent - // file written by the caller both reflect the same snapshot. - install, metadata := s.detectInstallState() - resp := s.buildResponseFrom(install, version, releaseURL, checkedAt) + version, releaseURL, checkedAt, err := s.readLatestVersion(channel) + if err != nil { + return UpdatesResponse{}, nil, err + } + // Bind Apply to the exact immutable version presented to the user. Update + // discovery may move the cache between rendering and confirmation; reject + // that stale request instead of silently installing a different artifact. + if expectedTarget == "" || version != expectedTarget { + return UpdatesResponse{}, nil, ErrUpdateTargetChanged + } + resp := s.buildResponseFromChannel(channel, install, version, releaseURL, checkedAt) if !resp.UpdateAvailable { return UpdatesResponse{}, nil, ErrNoUpdateAvailable } - if !resp.ApplySupported { - return UpdatesResponse{}, nil, fmt.Errorf("%w: %s", ErrApplyUnsupported, resp.ApplyUnsupportedReason) - } - if metadata == nil { - return UpdatesResponse{}, nil, ErrApplyUnsupported - } return resp, metadata, nil } @@ -284,6 +296,9 @@ func selfUpdateEnvironment() []string { return env } +// Install-wide mutations use exact browser same-origin checks, stricter than +// general UI CORS allowances. Requests without Origin remain available to +// trusted non-browser clients such as the local CLI. func sameOriginOrNoOrigin(r *http.Request) bool { origin := r.Header.Get("Origin") if origin == "" { diff --git a/apps/backend/internal/system/updates/apply_test.go b/apps/backend/internal/system/updates/apply_test.go index 166b78bc69..3bcb79964a 100644 --- a/apps/backend/internal/system/updates/apply_test.go +++ b/apps/backend/internal/system/updates/apply_test.go @@ -7,6 +7,8 @@ import ( "os" "path/filepath" "strings" + "sync" + "sync/atomic" "testing" "time" @@ -53,7 +55,7 @@ func TestService_ApplyQueuesSelfUpdateJobAndWritesIntent(t *testing.T) { }), ) - jobID, err := svc.Apply(context.Background(), "UPDATE") + jobID, err := svc.Apply(context.Background(), "UPDATE", "v1.0.1") if err != nil { t.Fatalf("Apply: %v", err) } @@ -77,6 +79,125 @@ func TestService_ApplyQueuesSelfUpdateJobAndWritesIntent(t *testing.T) { } } +func TestService_ApplyUsesCachedExactNightlyTarget(t *testing.T) { + homeDir := configureManagedNPMInstall(t) + pool := newTestPool(t) + const packageVersion = "1.2.4-nightly.shaabc123def456" + const targetTag = "v" + packageVersion + if err := persistence.WriteLatestNightlyVersion( + pool.Writer(), + targetTag, + "https://example/cached-nightly", + time.Now().UTC(), + ); err != nil { + t.Fatal(err) + } + store := &memorySettingsStore{value: []byte(ChannelNightly), present: true} + tracker := jobs.NewTracker(nil, logger.Default()) + var gotReq applyRequest + svc := NewService( + pool, + "v1.2.3", + nil, + logger.Default(), + WithHomeDir(homeDir), + WithSettingsStore(store), + WithJobs(tracker), + WithApplyRunner(func(_ context.Context, req applyRequest) (map[string]interface{}, error) { + gotReq = req + return map[string]interface{}{"status": "started"}, nil + }), + ) + var resolutions atomic.Int32 + svc.SetNightlyFetcher(func(context.Context) (string, string, error) { + resolutions.Add(1) + return "v1.2.5-nightly.shafedcba654321", "https://example/new-nightly", nil + }) + + jobID, err := svc.Apply(context.Background(), "UPDATE", targetTag) + if err != nil { + t.Fatalf("Apply: %v", err) + } + waitForJobState(t, tracker, jobID, jobs.StateSucceeded) + if gotReq.Intent.TargetTag != targetTag || gotReq.Intent.TargetVersion != packageVersion { + t.Fatalf( + "intent target tag=%q version=%q want tag=%q version=%q", + gotReq.Intent.TargetTag, + gotReq.Intent.TargetVersion, + targetTag, + packageVersion, + ) + } + if got := resolutions.Load(); got != 0 { + t.Fatalf("nightly resolutions=%d want 0", got) + } +} + +func TestService_ApplyRejectsChangedCachedTarget(t *testing.T) { + homeDir := configureManagedNPMInstall(t) + pool := newTestPool(t) + const cachedTarget = "v1.2.5-nightly.shafedcba654321" + if err := persistence.WriteLatestNightlyVersion( + pool.Writer(), + cachedTarget, + "https://example/new-nightly", + time.Now().UTC(), + ); err != nil { + t.Fatal(err) + } + svc := NewService( + pool, + "v1.2.3", + nil, + logger.Default(), + WithHomeDir(homeDir), + WithSettingsStore(&memorySettingsStore{value: []byte(ChannelNightly), present: true}), + WithJobs(jobs.NewTracker(nil, logger.Default())), + ) + + _, err := svc.Apply(context.Background(), "UPDATE", "v1.2.4-nightly.shaabc123def456") + if !errors.Is(err, ErrUpdateTargetChanged) { + t.Fatalf("Apply error=%v want ErrUpdateTargetChanged", err) + } +} + +func TestService_ApplyRejectsConcurrentNightlyWhileFirstApplyIsRunning(t *testing.T) { + homeDir := configureManagedNPMInstall(t) + pool := newTestPool(t) + release := make(chan struct{}) + closeRelease := sync.OnceFunc(func() { close(release) }) + t.Cleanup(closeRelease) + svc := NewService( + pool, + "v1.2.3", + nil, + logger.Default(), + WithHomeDir(homeDir), + WithSettingsStore(&memorySettingsStore{value: []byte(ChannelNightly), present: true}), + WithJobs(jobs.NewTracker(nil, logger.Default())), + WithApplyRunner(func(_ context.Context, _ applyRequest) (map[string]interface{}, error) { + <-release + return map[string]interface{}{"status": "started"}, nil + }), + ) + const target = "v1.2.4-nightly.shaabc123def456" + if err := persistence.WriteLatestNightlyVersion( + pool.Writer(), + target, + "https://example/nightly", + time.Now().UTC(), + ); err != nil { + t.Fatal(err) + } + if _, err := svc.Apply(context.Background(), "UPDATE", target); err != nil { + t.Fatalf("first Apply: %v", err) + } + if _, err := svc.Apply(context.Background(), "UPDATE", target); !errors.Is(err, ErrApplyInProgress) { + t.Fatalf("second Apply error=%v want ErrApplyInProgress", err) + } + closeRelease() +} + func TestWriteApplyIntentPreservesNativeNoBootStart(t *testing.T) { homeDir := t.TempDir() metadataPath := filepath.Join(homeDir, "service", "install.json") @@ -128,7 +249,7 @@ func TestService_ApplyRejectsUnsupportedInstall(t *testing.T) { } svc := NewService(pool, "v1.0.0", nil, logger.Default(), WithJobs(jobs.NewTracker(nil, logger.Default()))) - _, err := svc.Apply(context.Background(), "UPDATE") + _, err := svc.Apply(context.Background(), "UPDATE", "v1.0.1") if !errors.Is(err, ErrApplyUnsupported) { t.Fatalf("err=%v want ErrApplyUnsupported", err) } @@ -159,6 +280,8 @@ func TestService_ApplyRejectsConcurrentSelfUpdate(t *testing.T) { // Block the first helper so its job stays running while the second apply is // attempted, making the in-flight guard deterministic. release := make(chan struct{}) + closeRelease := sync.OnceFunc(func() { close(release) }) + t.Cleanup(closeRelease) svc := NewService(pool, "v1.0.0", nil, logger.Default(), WithHomeDir(homeDir), WithJobs(jobs.NewTracker(nil, logger.Default())), @@ -168,13 +291,13 @@ func TestService_ApplyRejectsConcurrentSelfUpdate(t *testing.T) { }), ) - if _, err := svc.Apply(context.Background(), "UPDATE"); err != nil { + if _, err := svc.Apply(context.Background(), "UPDATE", "v1.0.1"); err != nil { t.Fatalf("first Apply: %v", err) } - if _, err := svc.Apply(context.Background(), "UPDATE"); !errors.Is(err, ErrApplyInProgress) { + if _, err := svc.Apply(context.Background(), "UPDATE", "v1.0.1"); !errors.Is(err, ErrApplyInProgress) { t.Fatalf("second Apply err=%v want ErrApplyInProgress", err) } - close(release) + closeRelease() } func TestService_ApplyGuardExpiresAfterTTL(t *testing.T) { @@ -211,23 +334,23 @@ func TestService_ApplyGuardExpiresAfterTTL(t *testing.T) { clock := time.Now() svc.now = func() time.Time { return clock } - if _, err := svc.Apply(context.Background(), "UPDATE"); err != nil { + if _, err := svc.Apply(context.Background(), "UPDATE", "v1.0.1"); err != nil { t.Fatalf("first Apply: %v", err) } // Within the TTL a second apply is still refused. - if _, err := svc.Apply(context.Background(), "UPDATE"); !errors.Is(err, ErrApplyInProgress) { + if _, err := svc.Apply(context.Background(), "UPDATE", "v1.0.1"); !errors.Is(err, ErrApplyInProgress) { t.Fatalf("within TTL err=%v want ErrApplyInProgress", err) } // Past the TTL the guard expires (helper assumed dead) and a retry succeeds. clock = clock.Add(applyGuardTTL + time.Second) - if _, err := svc.Apply(context.Background(), "UPDATE"); err != nil { + if _, err := svc.Apply(context.Background(), "UPDATE", "v1.0.1"); err != nil { t.Fatalf("after TTL Apply: %v", err) } } func TestService_ApplyRejectsWrongConfirm(t *testing.T) { svc := NewService(newTestPool(t), "v1.0.0", nil, logger.Default()) - _, err := svc.Apply(context.Background(), "NOPE") + _, err := svc.Apply(context.Background(), "NOPE", "v1.0.1") if !errors.Is(err, ErrApplyConfirm) { t.Fatalf("err=%v want ErrApplyConfirm", err) } diff --git a/apps/backend/internal/system/updates/channel.go b/apps/backend/internal/system/updates/channel.go new file mode 100644 index 0000000000..b66acf1912 --- /dev/null +++ b/apps/backend/internal/system/updates/channel.go @@ -0,0 +1,140 @@ +package updates + +import ( + "context" + "errors" + "fmt" + "regexp" +) + +type Channel string + +const ( + ChannelStable Channel = "stable" + ChannelNightly Channel = "nightly" +) + +const updatesChannelSettingKey = "updates_channel" + +const channelSettingsUnavailableReason = "Update channel settings are unavailable." + +var ErrInvalidChannel = errors.New("invalid updates channel") +var ErrChannelUnsupported = errors.New("updates channel unsupported") +var ErrUpdateResolve = errors.New("update target resolution failed") +var ErrUpdateTargetChanged = errors.New("update target changed; refresh updates before applying") + +var nightlyVersionPattern = regexp.MustCompile( + `^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-nightly\.sha[0-9a-f]{12}$`, +) + +type settingsStore interface { + Get(ctx context.Context, key string) ([]byte, bool, error) + Save(ctx context.Context, key string, value []byte) error +} + +// WithSettingsStore wires the install-wide settings store used for channel selection. +func WithSettingsStore(store settingsStore) Option { + return func(s *Service) { + s.settings = store + } +} + +func parseChannel(value string) (Channel, bool) { + channel := Channel(value) + switch channel { + case ChannelStable, ChannelNightly: + return channel, true + default: + return ChannelStable, false + } +} + +func (s *Service) selectedChannel(ctx context.Context) (Channel, error) { + if s.settings == nil { + return ChannelStable, nil + } + raw, ok, err := s.settings.Get(ctx, updatesChannelSettingKey) + if err != nil { + return ChannelStable, fmt.Errorf("read updates channel: %w", err) + } + if !ok { + return ChannelStable, nil + } + channel, valid := parseChannel(string(raw)) + if !valid { + return ChannelStable, nil + } + return channel, nil +} + +func (s *Service) persistChannel(ctx context.Context, channel Channel) error { + if _, valid := parseChannel(string(channel)); !valid { + return fmt.Errorf("%w: %q", ErrInvalidChannel, channel) + } + if s.settings == nil { + return errors.New("updates settings store is unavailable") + } + if err := s.settings.Save(ctx, updatesChannelSettingKey, []byte(channel)); err != nil { + return fmt.Errorf("save updates channel: %w", err) + } + return nil +} + +func (s *Service) effectiveChannel( + ctx context.Context, + install InstallStateResponse, +) (Channel, error) { + editable, _ := s.channelSupport(install) + if !editable { + return ChannelStable, nil + } + return s.selectedChannel(ctx) +} + +func (s *Service) channelSupport(install InstallStateResponse) (bool, string) { + editable, reason := install.nightlySupport() + if !editable { + return false, reason + } + if s.settings == nil { + return false, channelSettingsUnavailableReason + } + return true, "" +} + +func isNightlyVersion(version string) bool { + return nightlyVersionPattern.MatchString(version) +} + +// SelectChannel validates and persists a new update channel, returning its +// resolved update state. +func (s *Service) SelectChannel(ctx context.Context, value string) (UpdatesResponse, error) { + channel, valid := parseChannel(value) + if !valid { + return UpdatesResponse{}, fmt.Errorf("%w: %q", ErrInvalidChannel, value) + } + install, _ := s.detectInstallState() + editable, reason := s.channelSupport(install) + if !editable { + return UpdatesResponse{}, fmt.Errorf("%w: %s", ErrChannelUnsupported, reason) + } + if ok, _ := s.limiter.Allow(); !ok { + return UpdatesResponse{}, ErrRateLimited + } + + s.updateMu.Lock() + defer s.updateMu.Unlock() + + version, targetURL, err := s.resolveLatest(ctx, channel) + if err != nil { + return UpdatesResponse{}, fmt.Errorf("%w: %v", ErrUpdateResolve, err) + } + checkedAt := s.now().UTC() + if err := s.writeLatestVersion(channel, version, targetURL, checkedAt); err != nil { + return UpdatesResponse{}, err + } + if err := s.persistChannel(ctx, channel); err != nil { + return UpdatesResponse{}, err + } + return s.buildResponseFromChannel(channel, install, version, targetURL, checkedAt), nil +} diff --git a/apps/backend/internal/system/updates/channel_test.go b/apps/backend/internal/system/updates/channel_test.go new file mode 100644 index 0000000000..ef795dd7e9 --- /dev/null +++ b/apps/backend/internal/system/updates/channel_test.go @@ -0,0 +1,437 @@ +package updates + +import ( + "context" + "errors" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/persistence" +) + +func configureManagedNPMInstall(t *testing.T) string { + t.Helper() + homeDir := t.TempDir() + metadataPath, _ := writeServiceInstallForTest(t, homeDir, serviceInstallMetadata{ + Manager: serviceManagerSystemd, + Mode: installModeUser, + Kind: installKindNPM, + HomeDir: homeDir, + LogDir: filepath.Join(homeDir, "logs"), + ServicePath: filepath.Join(homeDir, "kandev.service"), + NodePath: "/usr/bin/node", + CLIEntry: "/usr/lib/node_modules/kandev/bin/cli.js", + }) + t.Setenv(envRunningAsService, "true") + t.Setenv(envServiceMode, installModeUser) + t.Setenv(envServiceManager, serviceManagerSystemd) + t.Setenv(envInstallKind, installKindNPM) + t.Setenv(envServiceMetadata, metadataPath) + return homeDir +} + +type memorySettingsStore struct { + mu sync.Mutex + value []byte + present bool + getErr error + saveErr error +} + +type cancelAwareSettingsStore struct { + calls atomic.Int32 +} + +type failingSettingsStore struct { + getCalls atomic.Int32 +} + +func (s *failingSettingsStore) Get(context.Context, string) ([]byte, bool, error) { + s.getCalls.Add(1) + return nil, false, errors.New("settings unavailable") +} + +func (*failingSettingsStore) Save(context.Context, string, []byte) error { + return errors.New("settings unavailable") +} + +func (s *cancelAwareSettingsStore) Get(ctx context.Context, _ string) ([]byte, bool, error) { + if s.calls.Add(1) == 1 { + return []byte(ChannelNightly), true, nil + } + return nil, false, ctx.Err() +} + +func (*cancelAwareSettingsStore) Save(context.Context, string, []byte) error { + return nil +} + +func (s *memorySettingsStore) Get(context.Context, string) ([]byte, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + return append([]byte(nil), s.value...), s.present, s.getErr +} + +func (s *memorySettingsStore) Save(_ context.Context, _ string, value []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.saveErr != nil { + return s.saveErr + } + s.value = append([]byte(nil), value...) + s.present = true + return nil +} + +func TestSelectedChannelDefaultsInvalidAndMissingValuesToStable(t *testing.T) { + for _, tc := range []struct { + name string + store *memorySettingsStore + wantErr bool + }{ + {name: "missing", store: &memorySettingsStore{}}, + {name: "invalid", store: &memorySettingsStore{value: []byte("preview"), present: true}}, + {name: "read failure", store: &memorySettingsStore{getErr: errors.New("db down")}, wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + svc := NewService(newTestPool(t), "v1.0.0", nil, logger.Default(), WithSettingsStore(tc.store)) + got, err := svc.selectedChannel(context.Background()) + if tc.wantErr { + if err == nil { + t.Fatal("selectedChannel error = nil, want error") + } + return + } + if err != nil { + t.Fatalf("selectedChannel: %v", err) + } + if got != ChannelStable { + t.Fatalf("channel=%q want %q", got, ChannelStable) + } + }) + } +} + +func TestPersistedNightlyChannelSurvivesServiceRestart(t *testing.T) { + store := &memorySettingsStore{} + first := NewService(newTestPool(t), "v1.0.0", nil, logger.Default(), WithSettingsStore(store)) + if err := first.persistChannel(context.Background(), ChannelNightly); err != nil { + t.Fatalf("persistChannel: %v", err) + } + second := NewService(first.pool, "v1.0.0", nil, logger.Default(), WithSettingsStore(store)) + got, err := second.selectedChannel(context.Background()) + if err != nil { + t.Fatalf("selectedChannel: %v", err) + } + if got != ChannelNightly { + t.Fatalf("channel=%q want nightly", got) + } + + if err := second.persistChannel(context.Background(), Channel("preview")); err == nil { + t.Fatal("persistChannel accepted invalid channel") + } +} + +func TestGetReadsOnlyTheSelectedChannelCache(t *testing.T) { + homeDir := configureManagedNPMInstall(t) + pool := newTestPool(t) + stableAt := time.Unix(1_700_000_000, 0).UTC() + nightlyAt := stableAt.Add(time.Hour) + if err := persistence.WriteLatestVersion(pool.Writer(), "v1.2.3", "https://example/stable", stableAt); err != nil { + t.Fatal(err) + } + if err := persistence.WriteLatestNightlyVersion( + pool.Writer(), + "1.2.4-nightly.shaabc123def456", + "https://example/nightly", + nightlyAt, + ); err != nil { + t.Fatal(err) + } + store := &memorySettingsStore{value: []byte(ChannelNightly), present: true} + svc := NewService(pool, "v1.2.3", nil, logger.Default(), WithHomeDir(homeDir), WithSettingsStore(store)) + resp, err := svc.Get(context.Background()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if resp.Latest != "1.2.4-nightly.shaabc123def456" || resp.LatestURL != "https://example/nightly" || !resp.LatestCheckedAt.Equal(nightlyAt) { + t.Fatalf("Get returned wrong cache: %+v", resp) + } +} + +func TestNightlyAvailabilityTreatsUnequalSHAsAsNewTargets(t *testing.T) { + svc := NewService(nil, "v1.2.4-nightly.shaffffffffffff", nil, logger.Default()) + if !svc.updateAvailableFor(ChannelNightly, "1.2.4-nightly.sha000000000000") { + t.Fatal("unequal authoritative nightly SHA should be available regardless of lexical order") + } + if svc.updateAvailableFor(ChannelNightly, "1.2.4-nightly.shaffffffffffff") { + t.Fatal("identical nightly version should not be available") + } +} + +func TestNightlyAvailabilityRejectsLowerNumericVersion(t *testing.T) { + svc := NewService(nil, "v1.2.5-nightly.shaaaaaaaaaaaaa", nil, logger.Default()) + if svc.updateAvailableFor(ChannelNightly, "v1.2.4-nightly.shabbbbbbbbbbbb") { + t.Fatal("lower numeric nightly version must not be offered as an update") + } + if !svc.updateAvailableFor(ChannelNightly, "v1.2.6-nightly.shabbbbbbbbbbbb") { + t.Fatal("higher numeric nightly version should be offered as an update") + } +} + +func TestNightlyChannelDoesNotOfferPrereleaseOfInstalledStable(t *testing.T) { + svc := NewService(nil, "v1.2.4", nil, logger.Default()) + if svc.updateAvailableFor(ChannelNightly, "v1.2.4-nightly.shaaaaaaaaaaaaa") { + t.Fatal("a prerelease of the installed stable version must not be offered as an update") + } + if !svc.updateAvailableFor(ChannelNightly, "v1.2.5-nightly.shaaaaaaaaaaaaa") { + t.Fatal("the next patch nightly should be offered to a stable install") + } +} + +func TestCheckUsesNightlyResolverAndWritesOnlyNightlyCache(t *testing.T) { + homeDir := configureManagedNPMInstall(t) + pool := newTestPool(t) + store := &memorySettingsStore{value: []byte(ChannelNightly), present: true} + svc := NewService(pool, "v1.2.3", nil, logger.Default(), WithHomeDir(homeDir), WithSettingsStore(store)) + stableCalled := false + svc.SetFetcher(func(context.Context) (string, string, error) { + stableCalled = true + return "", "", errors.New("stable resolver should not run") + }) + svc.SetNightlyFetcher(func(context.Context) (string, string, error) { + return "1.2.4-nightly.shaabc123def456", "https://example/nightly", nil + }) + + resp, err := svc.Check(context.Background()) + if err != nil { + t.Fatalf("Check: %v", err) + } + if stableCalled { + t.Fatal("stable resolver ran for nightly channel") + } + if resp.Latest != "1.2.4-nightly.shaabc123def456" { + t.Fatalf("latest=%q", resp.Latest) + } + stable, _, _, err := persistence.ReadLatestVersion(pool.Reader()) + if err != nil { + t.Fatal(err) + } + nightly, _, _, err := persistence.ReadLatestNightlyVersion(pool.Reader()) + if err != nil { + t.Fatal(err) + } + if stable != "" || nightly != "1.2.4-nightly.shaabc123def456" { + t.Fatalf("cache isolation failed: stable=%q nightly=%q", stable, nightly) + } +} + +func TestUnsupportedInstallForcesPersistedNightlyPreferenceToStable(t *testing.T) { + pool := newTestPool(t) + if err := persistence.WriteLatestVersion(pool.Writer(), "v1.2.3", "https://example/stable", time.Now()); err != nil { + t.Fatal(err) + } + if err := persistence.WriteLatestNightlyVersion( + pool.Writer(), + "1.2.4-nightly.shaabc123def456", + "https://example/nightly", + time.Now(), + ); err != nil { + t.Fatal(err) + } + store := &memorySettingsStore{value: []byte(ChannelNightly), present: true} + svc := NewService(pool, "v1.2.3", nil, logger.Default(), WithSettingsStore(store)) + resp, err := svc.Get(context.Background()) + if err != nil { + t.Fatal(err) + } + if resp.Channel != ChannelStable || resp.Latest != "v1.2.3" || resp.ChannelEditable { + t.Fatalf("unsupported effective response=%+v", resp) + } +} + +func TestUnsupportedInstallDoesNotReadChannelSettings(t *testing.T) { + store := &failingSettingsStore{} + svc := NewService(newTestPool(t), "v1.2.3", nil, logger.Default(), WithSettingsStore(store)) + + resp, err := svc.Get(context.Background()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if resp.Channel != ChannelStable || resp.ChannelEditable { + t.Fatalf("unsupported effective response=%+v", resp) + } + if got := store.getCalls.Load(); got != 0 { + t.Fatalf("settings reads=%d want 0", got) + } +} + +func TestManagedNPMInstallWithoutSettingsStoreIsNotChannelEditable(t *testing.T) { + homeDir := configureManagedNPMInstall(t) + svc := NewService(newTestPool(t), "v1.2.3", nil, logger.Default(), WithHomeDir(homeDir)) + + resp, err := svc.Get(context.Background()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if resp.ChannelEditable || resp.ChannelUnsupportedReason == "" { + t.Fatalf("channel capability editable=%v reason=%q", resp.ChannelEditable, resp.ChannelUnsupportedReason) + } +} + +func TestSelectChannelWithoutSettingsStoreRejectsBeforeResolution(t *testing.T) { + homeDir := configureManagedNPMInstall(t) + svc := NewService(newTestPool(t), "v1.2.3", nil, logger.Default(), WithHomeDir(homeDir)) + var resolutions atomic.Int32 + svc.SetNightlyFetcher(func(context.Context) (string, string, error) { + resolutions.Add(1) + return "v1.2.4-nightly.shaabc123def456", "https://example/nightly", nil + }) + + _, err := svc.SelectChannel(context.Background(), string(ChannelNightly)) + if !errors.Is(err, ErrChannelUnsupported) { + t.Fatalf("SelectChannel error=%v want ErrChannelUnsupported", err) + } + if got := resolutions.Load(); got != 0 { + t.Fatalf("upstream resolutions=%d want 0", got) + } +} + +func TestFailedCheckReturnsCapturedChannelCacheAfterContextCancellation(t *testing.T) { + homeDir := configureManagedNPMInstall(t) + pool := newTestPool(t) + checkedAt := time.Unix(1_700_000_000, 0).UTC() + if err := persistence.WriteLatestNightlyVersion( + pool.Writer(), + "v1.2.4-nightly.shaabc123def456", + "https://example/nightly", + checkedAt, + ); err != nil { + t.Fatal(err) + } + store := &cancelAwareSettingsStore{} + svc := NewService(pool, "v1.2.3", nil, logger.Default(), WithHomeDir(homeDir), WithSettingsStore(store)) + ctx, cancel := context.WithCancel(context.Background()) + svc.SetNightlyFetcher(func(context.Context) (string, string, error) { + cancel() + return "", "", errors.New("registry unavailable") + }) + + resp, err := svc.fetchAndPersist(ctx) + if err == nil || !strings.Contains(err.Error(), "registry unavailable") { + t.Fatalf("error=%v", err) + } + if resp.Latest != "v1.2.4-nightly.shaabc123def456" || !resp.LatestCheckedAt.Equal(checkedAt) { + t.Fatalf("fallback response lost cached state: %+v", resp) + } + if got := store.calls.Load(); got != 1 { + t.Fatalf("settings reads=%d want 1", got) + } +} + +func TestSelectChannelSharesManualUpstreamRateLimit(t *testing.T) { + homeDir := configureManagedNPMInstall(t) + svc := NewService( + newTestPool(t), + "v1.2.3", + nil, + logger.Default(), + WithHomeDir(homeDir), + WithSettingsStore(&memorySettingsStore{}), + ) + var resolutions atomic.Int32 + svc.SetNightlyFetcher(func(context.Context) (string, string, error) { + resolutions.Add(1) + return "v1.2.4-nightly.shaabc123def456", "https://example/nightly", nil + }) + + if _, err := svc.SelectChannel(context.Background(), string(ChannelNightly)); err != nil { + t.Fatalf("first SelectChannel: %v", err) + } + if _, err := svc.SelectChannel(context.Background(), string(ChannelNightly)); !errors.Is(err, ErrRateLimited) { + t.Fatalf("second SelectChannel error=%v want ErrRateLimited", err) + } + if got := resolutions.Load(); got != 1 { + t.Fatalf("upstream resolutions=%d want 1", got) + } +} + +func TestFetchAndPersistSerializesCacheRefreshes(t *testing.T) { + homeDir := configureManagedNPMInstall(t) + pool := newTestPool(t) + store := &memorySettingsStore{value: []byte(ChannelNightly), present: true} + svc := NewService(pool, "v1.2.3", nil, logger.Default(), WithHomeDir(homeDir), WithSettingsStore(store)) + firstEntered := make(chan struct{}) + releaseFirst := make(chan struct{}) + closeReleaseFirst := sync.OnceFunc(func() { close(releaseFirst) }) + t.Cleanup(closeReleaseFirst) + secondEntered := make(chan struct{}) + releaseSecond := make(chan struct{}) + closeReleaseSecond := sync.OnceFunc(func() { close(releaseSecond) }) + t.Cleanup(closeReleaseSecond) + var calls atomic.Int32 + svc.SetNightlyFetcher(func(context.Context) (string, string, error) { + if calls.Add(1) == 1 { + close(firstEntered) + <-releaseFirst + return "v1.2.4-nightly.shaaaaaaaaaaaaa", "https://example/first", nil + } + close(secondEntered) + <-releaseSecond + return "v1.2.5-nightly.shabbbbbbbbbbbb", "https://example/second", nil + }) + + firstDone := make(chan error, 1) + secondDone := make(chan error, 1) + go func() { + _, err := svc.fetchAndPersist(context.Background()) + firstDone <- err + }() + select { + case <-firstEntered: + case err := <-firstDone: + t.Fatalf("first refresh returned before resolver: %v", err) + } + if svc.updateMu.TryLock() { + svc.updateMu.Unlock() + t.Fatal("refresh lock was not held while first resolver was active") + } + go func() { + _, err := svc.fetchAndPersist(context.Background()) + secondDone <- err + }() + + closeReleaseFirst() + if err := <-firstDone; err != nil { + t.Fatalf("first refresh: %v", err) + } + select { + case <-secondEntered: + case err := <-secondDone: + t.Fatalf("second refresh returned before resolver: %v", err) + } + version, targetURL, _, err := persistence.ReadLatestNightlyVersion(pool.Reader()) + if err != nil { + t.Fatal(err) + } + if version != "v1.2.4-nightly.shaaaaaaaaaaaaa" || targetURL != "https://example/first" { + t.Fatalf("cache before second release version=%q url=%q", version, targetURL) + } + + closeReleaseSecond() + if err := <-secondDone; err != nil { + t.Fatalf("second refresh: %v", err) + } + version, targetURL, _, err = persistence.ReadLatestNightlyVersion(pool.Reader()) + if err != nil { + t.Fatal(err) + } + if version != "v1.2.5-nightly.shabbbbbbbbbbbb" || targetURL != "https://example/second" { + t.Fatalf("final cache version=%q url=%q", version, targetURL) + } +} diff --git a/apps/backend/internal/system/updates/goleak_test.go b/apps/backend/internal/system/updates/goleak_test.go new file mode 100644 index 0000000000..3e36a1b1d3 --- /dev/null +++ b/apps/backend/internal/system/updates/goleak_test.go @@ -0,0 +1,11 @@ +package updates + +import ( + "testing" + + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} diff --git a/apps/backend/internal/system/updates/handler.go b/apps/backend/internal/system/updates/handler.go index 4d3325875f..e9dd14d65b 100644 --- a/apps/backend/internal/system/updates/handler.go +++ b/apps/backend/internal/system/updates/handler.go @@ -2,20 +2,27 @@ package updates import ( "context" + "encoding/json" "errors" + "io" "math" "net/http" "github.com/gin-gonic/gin" + "go.uber.org/zap" ) -// HandleGet returns the cached kandev_meta view of latest version. It never -// hits GitHub. Errors from the meta read are surfaced as 500. +const errorResponseKey = "error" + +// HandleGet returns the cached kandev_meta view of the selected channel. It +// never contacts an upstream. Read failures are logged and returned as a +// generic 500 so storage details do not cross the API boundary. func HandleGet(svc *Service) gin.HandlerFunc { return func(c *gin.Context) { - resp, err := svc.Get() + resp, err := svc.Get(c.Request.Context()) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + svc.log.Error("updates: get failed", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{errorResponseKey: "failed to load updates"}) return } c.JSON(http.StatusOK, resp) @@ -23,58 +30,116 @@ func HandleGet(svc *Service) gin.HandlerFunc { } type applyRequestBody struct { - Confirm string `json:"confirm"` + Confirm string `json:"confirm"` + TargetVersion string `json:"target_version"` } -// HandleCheck triggers a synchronous GitHub poll. When the per-process -// limiter denies the request a 429 is returned with retry_after_seconds. -// Other errors are surfaced as 502 since the upstream is GitHub. +type channelRequestBody struct { + Channel string `json:"channel"` +} + +// HandleSetChannel changes the install-wide update source. +func HandleSetChannel(svc *Service) gin.HandlerFunc { + return func(c *gin.Context) { + if !sameOriginOrNoOrigin(c.Request) { + c.JSON(http.StatusForbidden, gin.H{errorResponseKey: "cross-origin update channel change is not allowed"}) + return + } + var req channelRequestBody + if err := decodeSingleJSON(c.Request.Body, &req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{errorResponseKey: "invalid channel request"}) + return + } + resp, err := svc.SelectChannel(c.Request.Context(), req.Channel) + switch { + case errors.Is(err, ErrInvalidChannel): + c.JSON(http.StatusBadRequest, gin.H{errorResponseKey: err.Error()}) + case errors.Is(err, ErrChannelUnsupported): + c.JSON(http.StatusConflict, gin.H{errorResponseKey: err.Error()}) + case errors.Is(err, ErrRateLimited): + writeRateLimited(c, svc) + case errors.Is(err, ErrUpdateResolve): + c.JSON(http.StatusBadGateway, gin.H{errorResponseKey: err.Error()}) + case err != nil: + svc.log.Error("updates: set channel failed", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{errorResponseKey: "failed to set update channel"}) + default: + c.JSON(http.StatusOK, resp) + } + } +} + +// HandleCheck triggers a synchronous poll of the selected source. When the +// per-process limiter denies the request a 429 is returned with +// retry_after_seconds. Other upstream errors are surfaced as 502. func HandleCheck(svc *Service) gin.HandlerFunc { return func(c *gin.Context) { resp, err := svc.Check(c.Request.Context()) if errors.Is(err, ErrRateLimited) { - retry := svc.RetryAfter() - seconds := int64(math.Ceil(retry.Seconds())) - if seconds < 1 { - seconds = 1 - } - c.JSON(http.StatusTooManyRequests, gin.H{ - "error": ErrRateLimited.Error(), - "retry_after_seconds": seconds, - }) + writeRateLimited(c, svc) return } if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + c.JSON(http.StatusBadGateway, gin.H{errorResponseKey: err.Error()}) return } c.JSON(http.StatusOK, resp) } } +func writeRateLimited(c *gin.Context, svc *Service) { + retry := svc.RetryAfter() + seconds := int64(math.Ceil(retry.Seconds())) + if seconds < 1 { + seconds = 1 + } + c.JSON(http.StatusTooManyRequests, gin.H{ + errorResponseKey: ErrRateLimited.Error(), + "retry_after_seconds": seconds, + }) +} + +func decodeSingleJSON(reader io.Reader, target any) error { + decoder := json.NewDecoder(reader) + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("request body contains multiple JSON values") + } + return err + } + return nil +} + // HandleApply queues a service-managed self-update. It is deliberately gated // behind service metadata and a browser same-origin check because the helper // mutates the local Kandev installation and restarts the service. func HandleApply(svc *Service) gin.HandlerFunc { return func(c *gin.Context) { if !sameOriginOrNoOrigin(c.Request) { - c.JSON(http.StatusForbidden, gin.H{"error": "cross-origin update apply is not allowed"}) + c.JSON(http.StatusForbidden, gin.H{errorResponseKey: "cross-origin update apply is not allowed"}) return } var req applyRequestBody - _ = c.ShouldBindJSON(&req) - jobID, err := svc.Apply(context.Background(), req.Confirm) - if errors.Is(err, ErrApplyConfirm) { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + if err := decodeSingleJSON(c.Request.Body, &req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{errorResponseKey: "invalid update request"}) return } - if errors.Is(err, ErrNoUpdateAvailable) || errors.Is(err, ErrApplyUnsupported) || + jobID, err := svc.Apply(context.Background(), req.Confirm, req.TargetVersion) + if errors.Is(err, ErrApplyConfirm) { + c.JSON(http.StatusBadRequest, gin.H{errorResponseKey: err.Error()}) + return + } + if errors.Is(err, ErrNoUpdateAvailable) || errors.Is(err, ErrUpdateTargetChanged) || + errors.Is(err, ErrApplyUnsupported) || errors.Is(err, ErrApplyInProgress) { - c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + c.JSON(http.StatusConflict, gin.H{errorResponseKey: err.Error()}) return } if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{errorResponseKey: err.Error()}) return } c.JSON(http.StatusAccepted, ApplyResponse{JobID: jobID}) diff --git a/apps/backend/internal/system/updates/handler_test.go b/apps/backend/internal/system/updates/handler_test.go index b8405da2f7..4b19a06785 100644 --- a/apps/backend/internal/system/updates/handler_test.go +++ b/apps/backend/internal/system/updates/handler_test.go @@ -2,14 +2,21 @@ package updates import ( "bytes" + "context" "encoding/json" + "errors" "net/http" "net/http/httptest" + "strings" + "sync/atomic" "testing" + "time" "github.com/gin-gonic/gin" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/persistence" + "github.com/kandev/kandev/internal/system/jobs" ) func init() { @@ -21,6 +28,7 @@ func newRouter(svc *Service) *gin.Engine { api := r.Group("/api/v1/system") api.GET("/updates", HandleGet(svc)) api.POST("/updates/check", HandleCheck(svc)) + api.PATCH("/updates/channel", HandleSetChannel(svc)) api.POST("/updates/apply", HandleApply(svc)) return r } @@ -75,6 +83,54 @@ func TestHandleGet_IncludesNonServiceInstallState(t *testing.T) { if got := body["apply_supported"]; got != false { t.Errorf("apply_supported=%v want false", got) } + if got := body["channel"]; got != "stable" { + t.Errorf("channel=%v want stable", got) + } + if got := body["channel_editable"]; got != false { + t.Errorf("channel_editable=%v want false", got) + } + if got, ok := body["channel_unsupported_reason"].(string); !ok || got == "" { + t.Errorf("channel_unsupported_reason=%v want non-empty string", body["channel_unsupported_reason"]) + } +} + +type requestContextSettingsStore struct{} + +func (requestContextSettingsStore) Get(ctx context.Context, _ string) ([]byte, bool, error) { + return nil, false, ctx.Err() +} + +func (requestContextSettingsStore) Save(context.Context, string, []byte) error { + return nil +} + +func TestHandleGetPropagatesRequestContext(t *testing.T) { + homeDir := configureManagedNPMInstall(t) + pool := newTestPool(t) + svc := NewService( + pool, + "v1.0.0", + nil, + logger.Default(), + WithHomeDir(homeDir), + WithSettingsStore(requestContextSettingsStore{}), + ) + r := newRouter(svc) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + req := httptest.NewRequest(http.MethodGet, "/api/v1/system/updates", nil).WithContext(ctx) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("status=%d body=%s want 500", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), context.Canceled.Error()) { + t.Fatalf("response exposed settings read details: %s", w.Body.String()) + } + if !strings.Contains(w.Body.String(), "failed to load updates") { + t.Fatalf("response=%s want generic error", w.Body.String()) + } } func TestHandleCheck_FirstCall200(t *testing.T) { @@ -159,6 +215,306 @@ func TestHandleCheck_GitHubFailureReturns502(t *testing.T) { } } +func newManagedNPMServiceForHandler(t *testing.T) (*Service, *memorySettingsStore) { + t.Helper() + homeDir := configureManagedNPMInstall(t) + store := &memorySettingsStore{} + svc := NewService( + newTestPool(t), + "v1.2.3", + nil, + logger.Default(), + WithHomeDir(homeDir), + WithSettingsStore(store), + ) + return svc, store +} + +func readMemorySetting(t *testing.T, store *memorySettingsStore) ([]byte, bool) { + t.Helper() + value, present, err := store.Get(context.Background(), updatesChannelSettingKey) + if err != nil { + t.Fatal(err) + } + return value, present +} + +func TestHandleSetChannelPersistsSupportedNightlyAndReturnsResolvedTarget(t *testing.T) { + svc, store := newManagedNPMServiceForHandler(t) + svc.SetNightlyFetcher(func(context.Context) (string, string, error) { + return "1.2.4-nightly.shaabc123def456", "https://example/nightly", nil + }) + r := newRouter(svc) + req := httptest.NewRequest( + http.MethodPatch, + "/api/v1/system/updates/channel", + bytes.NewBufferString(`{"channel":"nightly"}`), + ) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + var resp UpdatesResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.Channel != ChannelNightly || !resp.ChannelEditable { + t.Fatalf("channel response=%+v", resp) + } + if resp.Latest != "1.2.4-nightly.shaabc123def456" || !resp.UpdateAvailable { + t.Fatalf("resolved response=%+v", resp) + } + selected, err := svc.selectedChannel(context.Background()) + raw, present := readMemorySetting(t, store) + if err != nil || selected != ChannelNightly || !present || string(raw) != string(ChannelNightly) { + t.Fatalf("persisted channel=%q raw=%q present=%v err=%v", selected, raw, present, err) + } +} + +func TestHandleSetChannelRejectsTrailingJSON(t *testing.T) { + svc, store := newManagedNPMServiceForHandler(t) + svc.SetNightlyFetcher(func(context.Context) (string, string, error) { + return "1.2.4-nightly.shaabc123def456", "https://example/nightly", nil + }) + r := newRouter(svc) + req := httptest.NewRequest( + http.MethodPatch, + "/api/v1/system/updates/channel", + bytes.NewBufferString(`{"channel":"nightly"} {}`), + ) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s want 400", w.Code, w.Body.String()) + } + _, present := readMemorySetting(t, store) + if present { + t.Fatal("channel selection with trailing JSON was persisted") + } +} + +func TestHandleSetChannelRejectsInvalidAndUnsupportedNightly(t *testing.T) { + t.Run("invalid", func(t *testing.T) { + svc, _ := newManagedNPMServiceForHandler(t) + r := newRouter(svc) + req := httptest.NewRequest(http.MethodPatch, "/api/v1/system/updates/channel", bytes.NewBufferString(`{"channel":"preview"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + }) + + t.Run("unsupported install", func(t *testing.T) { + store := &memorySettingsStore{} + svc := NewService(newTestPool(t), "v1.2.3", nil, logger.Default(), WithSettingsStore(store)) + r := newRouter(svc) + req := httptest.NewRequest(http.MethodPatch, "/api/v1/system/updates/channel", bytes.NewBufferString(`{"channel":"nightly"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusConflict { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + _, present := readMemorySetting(t, store) + if present { + t.Fatal("unsupported channel selection was persisted") + } + }) +} + +func TestHandleSetChannelResolverFailureReturns502WithoutPersisting(t *testing.T) { + svc, store := newManagedNPMServiceForHandler(t) + svc.SetNightlyFetcher(func(context.Context) (string, string, error) { + return "", "", errors.New("registry down") + }) + r := newRouter(svc) + req := httptest.NewRequest(http.MethodPatch, "/api/v1/system/updates/channel", bytes.NewBufferString(`{"channel":"nightly"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusBadGateway { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + _, present := readMemorySetting(t, store) + if present { + t.Fatal("failed resolver selection was persisted") + } +} + +func TestHandleSetChannelPersistenceFailureDoesNotExposeStorageDetails(t *testing.T) { + svc, store := newManagedNPMServiceForHandler(t) + store.saveErr = errors.New("sqlite: secret storage detail") + svc.SetNightlyFetcher(func(context.Context) (string, string, error) { + return "1.2.4-nightly.shaabc123def456", "https://example/nightly", nil + }) + r := newRouter(svc) + req := httptest.NewRequest( + http.MethodPatch, + "/api/v1/system/updates/channel", + bytes.NewBufferString(`{"channel":"nightly"}`), + ) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("status=%d body=%s want 500", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "secret storage detail") { + t.Fatalf("response exposed storage details: %s", w.Body.String()) + } + if !strings.Contains(w.Body.String(), "failed to set update channel") { + t.Fatalf("response=%s want generic error", w.Body.String()) + } +} + +func TestHandleSetChannelRejectsCrossOrigin(t *testing.T) { + svc, store := newManagedNPMServiceForHandler(t) + svc.SetNightlyFetcher(func(context.Context) (string, string, error) { + return "1.2.4-nightly.shaabc123def456", "https://example/nightly", nil + }) + r := newRouter(svc) + req := httptest.NewRequest( + http.MethodPatch, + "/api/v1/system/updates/channel", + bytes.NewBufferString(`{"channel":"nightly"}`), + ) + req.Host = "kandev.local" + req.Header.Set("Origin", "https://evil.example") + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("status=%d body=%s want 403", w.Code, w.Body.String()) + } + _, present := readMemorySetting(t, store) + if present { + t.Fatal("cross-origin channel selection was persisted") + } +} + +func TestHandleSetChannelRejectsCrossPortOrigin(t *testing.T) { + svc, store := newManagedNPMServiceForHandler(t) + svc.SetNightlyFetcher(func(context.Context) (string, string, error) { + return "1.2.4-nightly.shaabc123def456", "https://example/nightly", nil + }) + r := newRouter(svc) + req := httptest.NewRequest( + http.MethodPatch, + "/api/v1/system/updates/channel", + bytes.NewBufferString(`{"channel":"nightly"}`), + ) + req.Host = "localhost:38429" + req.Header.Set("Origin", "http://localhost:5173") + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("status=%d body=%s want 403", w.Code, w.Body.String()) + } + _, present := readMemorySetting(t, store) + if present { + t.Fatal("cross-port channel selection was persisted") + } +} + +func TestHandleApplyRejectsChangedNightlyTarget(t *testing.T) { + svc, store := newManagedNPMServiceForHandler(t) + if err := store.Save(context.Background(), updatesChannelSettingKey, []byte(ChannelNightly)); err != nil { + t.Fatal(err) + } + if err := persistence.WriteLatestNightlyVersion( + svc.pool.Writer(), + "v1.2.5-nightly.shafedcba654321", + "https://example/new-nightly", + time.Now().UTC(), + ); err != nil { + t.Fatal(err) + } + svc.jobs = jobs.NewTracker(nil, logger.Default()) + r := newRouter(svc) + req := httptest.NewRequest( + http.MethodPost, + "/api/v1/system/updates/apply", + bytes.NewBufferString(`{"confirm":"UPDATE","target_version":"v1.2.4-nightly.shaabc123def456"}`), + ) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusConflict { + t.Fatalf("status=%d body=%s want 409", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "update target changed") { + t.Fatalf("body=%s want target-changed error", w.Body.String()) + } +} + +func TestHandleApplyRejectsInvalidJSONBeforeApplying(t *testing.T) { + for _, tc := range []struct { + name string + body string + }{ + { + name: "type mismatch after valid target", + body: `{"confirm":"UPDATE","target_version":"v1.2.4","target_version":123}`, + }, + { + name: "truncated object after valid fields", + body: `{"confirm":"UPDATE","target_version":"v1.2.4","broken":`, + }, + { + name: "trailing JSON value", + body: `{"confirm":"UPDATE","target_version":"v1.2.4"} {}`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + svc, _ := newManagedNPMServiceForHandler(t) + if err := persistence.WriteLatestVersion( + svc.pool.Writer(), + "v1.2.4", + "https://example/v1.2.4", + time.Now().UTC(), + ); err != nil { + t.Fatal(err) + } + svc.jobs = jobs.NewTracker(nil, logger.Default()) + var runnerCalls atomic.Int32 + svc.applyRun = func(context.Context, applyRequest) (map[string]interface{}, error) { + runnerCalls.Add(1) + return map[string]interface{}{"status": "started"}, nil + } + + req := httptest.NewRequest( + http.MethodPost, + "/api/v1/system/updates/apply", + bytes.NewBufferString(tc.body), + ) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + newRouter(svc).ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status=%d body=%s want 400", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "invalid update request") { + t.Errorf("body=%s want generic invalid-request error", w.Body.String()) + } + if got := runnerCalls.Load(); got != 0 { + t.Errorf("apply runner calls=%d want 0", got) + } + }) + } +} + func TestHandleApply_RejectsCrossOrigin(t *testing.T) { pool := newTestPool(t) svc := NewService(pool, "v1.0.0", nil, logger.Default()) diff --git a/apps/backend/internal/system/updates/install_state.go b/apps/backend/internal/system/updates/install_state.go index b9d725184b..38d1d608ab 100644 --- a/apps/backend/internal/system/updates/install_state.go +++ b/apps/backend/internal/system/updates/install_state.go @@ -49,6 +49,16 @@ type serviceInstallMetadata struct { InstalledAt string `json:"installed_at"` } +type managedUserServiceProblem uint8 + +const ( + managedUserServiceSupported managedUserServiceProblem = iota + managedUserServiceNotRunning + managedUserServiceUnmanaged + managedUserServiceWrongMode + managedUserServiceUnsupportedManager +) + func (s *Service) detectInstallState() (InstallStateResponse, *serviceInstallMetadata) { if s.getenv(envRunningAsService) != "true" { return InstallStateResponse{}, nil @@ -141,17 +151,8 @@ func (s *Service) metadataMatchesService(state InstallStateResponse, metadata *s } func (s InstallStateResponse) applySupport() (bool, string) { - if !s.RunningAsService { - return false, "Kandev is not running as a managed service." - } - if !s.ManagedService { - return false, "Kandev service metadata is missing or invalid." - } - if s.Mode != installModeUser { - return false, "Self-update is only available for user services." - } - if s.Manager != serviceManagerSystemd && s.Manager != serviceManagerLaunchd { - return false, "This service manager does not support UI self-update." + if problem := s.managedUserServiceProblem(); problem != managedUserServiceSupported { + return false, problem.reason(false) } if s.Kind != installKindHomebrew && s.Kind != installKindNPM && s.Kind != installKindNPX { return false, "This installation method does not support UI self-update." @@ -159,6 +160,58 @@ func (s InstallStateResponse) applySupport() (bool, string) { return true, "" } +func (s InstallStateResponse) nightlySupport() (bool, string) { + if problem := s.managedUserServiceProblem(); problem != managedUserServiceSupported { + return false, problem.reason(true) + } + if s.Kind != installKindNPM && s.Kind != installKindNPX { + return false, "Nightly updates are only available for npm or npx installations." + } + return true, "" +} + +func (s InstallStateResponse) managedUserServiceProblem() managedUserServiceProblem { + if !s.RunningAsService { + return managedUserServiceNotRunning + } + if !s.ManagedService { + return managedUserServiceUnmanaged + } + if s.Mode != installModeUser { + return managedUserServiceWrongMode + } + if s.Manager != serviceManagerSystemd && s.Manager != serviceManagerLaunchd { + return managedUserServiceUnsupportedManager + } + return managedUserServiceSupported +} + +func (p managedUserServiceProblem) reason(nightly bool) string { + if nightly { + switch p { + case managedUserServiceNotRunning: + return "Nightly updates require a Kandev-managed npm or npx user service." + case managedUserServiceUnmanaged: + return "Nightly updates require valid Kandev service metadata." + case managedUserServiceWrongMode: + return "Nightly updates are only available for user services." + case managedUserServiceUnsupportedManager: + return "This service manager does not support nightly updates." + } + } + switch p { + case managedUserServiceNotRunning: + return "Kandev is not running as a managed service." + case managedUserServiceUnmanaged: + return "Kandev service metadata is missing or invalid." + case managedUserServiceWrongMode: + return "Self-update is only available for user services." + case managedUserServiceUnsupportedManager: + return "This service manager does not support UI self-update." + } + return "" +} + func manualCommands(install InstallStateResponse, latest string) []string { target := strings.TrimPrefix(latest, "v") if target == "" { diff --git a/apps/backend/internal/system/updates/install_state_test.go b/apps/backend/internal/system/updates/install_state_test.go index eaf046ff8a..11ec96d6e2 100644 --- a/apps/backend/internal/system/updates/install_state_test.go +++ b/apps/backend/internal/system/updates/install_state_test.go @@ -1,6 +1,7 @@ package updates import ( + "context" "encoding/json" "os" "path/filepath" @@ -28,8 +29,15 @@ func TestService_GetManagedUserServiceSupportsApply(t *testing.T) { t.Setenv(envInstallKind, "npm") t.Setenv(envServiceMetadata, metadataPath) - svc := NewService(newTestPool(t), "v1.0.0", nil, logger.Default(), WithHomeDir(homeDir)) - resp, err := svc.Get() + svc := NewService( + newTestPool(t), + "v1.0.0", + nil, + logger.Default(), + WithHomeDir(homeDir), + WithSettingsStore(&memorySettingsStore{}), + ) + resp, err := svc.Get(context.Background()) if err != nil { t.Fatalf("Get: %v", err) } @@ -39,6 +47,9 @@ func TestService_GetManagedUserServiceSupportsApply(t *testing.T) { if !resp.ApplySupported { t.Fatalf("ApplySupported=false reason=%q", resp.ApplyUnsupportedReason) } + if !resp.ChannelEditable || resp.ChannelUnsupportedReason != "" { + t.Fatalf("nightly capability editable=%v reason=%q", resp.ChannelEditable, resp.ChannelUnsupportedReason) + } } func TestService_GetManagedNativeServiceSupportsApply(t *testing.T) { @@ -80,13 +91,16 @@ func TestService_GetManagedNativeServiceSupportsApply(t *testing.T) { t.Setenv(envServiceMetadata, metadataPath) svc := NewService(newTestPool(t), "v1.0.0", nil, logger.Default(), WithHomeDir(homeDir)) - resp, err := svc.Get() + resp, err := svc.Get(context.Background()) if err != nil { t.Fatalf("Get: %v", err) } if !resp.Install.ManagedService || !resp.ApplySupported { t.Fatalf("native install state = %+v apply_supported=%v reason=%q", resp.Install, resp.ApplySupported, resp.ApplyUnsupportedReason) } + if resp.ChannelEditable || resp.ChannelUnsupportedReason == "" { + t.Fatalf("Homebrew nightly capability editable=%v reason=%q", resp.ChannelEditable, resp.ChannelUnsupportedReason) + } } func TestService_GetManagedSystemdServiceSupportsApplyWithPercentInMetadataPath(t *testing.T) { @@ -112,7 +126,7 @@ func TestService_GetManagedSystemdServiceSupportsApplyWithPercentInMetadataPath( t.Setenv(envServiceMetadata, metadataPath) svc := NewService(newTestPool(t), "v1.0.0", nil, logger.Default(), WithHomeDir(homeDir)) - resp, err := svc.Get() + resp, err := svc.Get(context.Background()) if err != nil { t.Fatalf("Get: %v", err) } @@ -141,7 +155,7 @@ func TestService_GetSystemServiceDisablesApply(t *testing.T) { t.Setenv(envServiceMetadata, metadataPath) svc := NewService(newTestPool(t), "v1.0.0", nil, logger.Default(), WithHomeDir(homeDir)) - resp, err := svc.Get() + resp, err := svc.Get(context.Background()) if err != nil { t.Fatalf("Get: %v", err) } @@ -184,7 +198,7 @@ func TestService_GetForeignServiceDisablesApply(t *testing.T) { t.Setenv(envServiceMetadata, metadataPath) svc := NewService(newTestPool(t), "v1.0.0", nil, logger.Default(), WithHomeDir(homeDir)) - resp, err := svc.Get() + resp, err := svc.Get(context.Background()) if err != nil { t.Fatalf("Get: %v", err) } @@ -216,7 +230,7 @@ func TestService_GetLocalBundleServiceDisablesApply(t *testing.T) { t.Setenv(envServiceMetadata, metadataPath) svc := NewService(newTestPool(t), "v1.0.0", nil, logger.Default(), WithHomeDir(homeDir)) - resp, err := svc.Get() + resp, err := svc.Get(context.Background()) if err != nil { t.Fatalf("Get: %v", err) } @@ -247,6 +261,60 @@ func TestManualCommandsNPXHasNoDuplicateBinaryName(t *testing.T) { } } +func TestManagedUserServiceCapabilitiesShareBaseEligibility(t *testing.T) { + for name, state := range map[string]InstallStateResponse{ + "not running": { + ManagedService: true, + Mode: installModeUser, + Manager: serviceManagerSystemd, + Kind: installKindNPM, + }, + "unmanaged": { + RunningAsService: true, + Mode: installModeUser, + Manager: serviceManagerSystemd, + Kind: installKindNPM, + }, + "system mode": { + RunningAsService: true, + ManagedService: true, + Mode: installModeSystem, + Manager: serviceManagerSystemd, + Kind: installKindNPM, + }, + "unsupported manager": { + RunningAsService: true, + ManagedService: true, + Mode: installModeUser, + Manager: "other", + Kind: installKindNPM, + }, + } { + t.Run(name, func(t *testing.T) { + if supported, _ := state.applySupport(); supported { + t.Fatal("apply unexpectedly supported") + } + if supported, _ := state.nightlySupport(); supported { + t.Fatal("Nightly unexpectedly supported") + } + }) + } + + homebrew := InstallStateResponse{ + RunningAsService: true, + ManagedService: true, + Mode: installModeUser, + Manager: serviceManagerLaunchd, + Kind: installKindHomebrew, + } + if supported, _ := homebrew.applySupport(); !supported { + t.Fatal("managed Homebrew user service should support Stable apply") + } + if supported, _ := homebrew.nightlySupport(); supported { + t.Fatal("managed Homebrew user service should not support Nightly") + } +} + func writeServiceInstallForTest(t *testing.T, homeDir string, metadata serviceInstallMetadata) (string, string) { t.Helper() metadata.Version = serviceMetadataVersion diff --git a/apps/backend/internal/system/updates/notify_dispatch_test.go b/apps/backend/internal/system/updates/notify_dispatch_test.go index 2ae6b370f7..da50716201 100644 --- a/apps/backend/internal/system/updates/notify_dispatch_test.go +++ b/apps/backend/internal/system/updates/notify_dispatch_test.go @@ -2,6 +2,8 @@ package updates import ( "context" + "sync" + "sync/atomic" "testing" "time" @@ -24,6 +26,23 @@ type updateNotification struct { url string } +type firstCallBlockingNotifier struct { + calls atomic.Int32 + entered chan struct{} + release chan struct{} +} + +func (n *firstCallBlockingNotifier) HandleUpdateAvailable(ctx context.Context, _, _ string) { + if n.calls.Add(1) != 1 { + return + } + close(n.entered) + select { + case <-n.release: + case <-ctx.Done(): + } +} + func (n *capturingNotifier) HandleUpdateAvailable(_ context.Context, version, releaseURL string) { n.calls = append(n.calls, updateNotification{version: version, url: releaseURL}) } @@ -69,6 +88,160 @@ func TestService_FetchAndPersist_NotifiesCanonicalServiceForNewerRelease(t *test } } +func TestService_ReplayNightlyNotifiesForUnequalAuthoritativeSHA(t *testing.T) { + homeDir := configureManagedNPMInstall(t) + pool := newTestPool(t) + store := &memorySettingsStore{value: []byte(ChannelNightly), present: true} + if err := persistence.WriteLatestNightlyVersion( + pool.Writer(), + "1.2.4-nightly.sha000000000000", + "https://example.test/nightly", + time.Now(), + ); err != nil { + t.Fatal(err) + } + svc := NewService( + pool, + "v1.2.4-nightly.shaffffffffffff", + nil, + logger.Default(), + WithHomeDir(homeDir), + WithSettingsStore(store), + ) + notifier := &capturingNotifier{} + svc.SetNotifier(notifier) + if err := svc.ReplayCachedUpdate(context.Background()); err != nil { + t.Fatal(err) + } + if len(notifier.calls) != 1 { + t.Fatalf("notifier calls=%d want 1", len(notifier.calls)) + } + if got := notifier.calls[0]; got.version != "1.2.4-nightly.sha000000000000" || got.url != "https://example.test/nightly" { + t.Errorf("notifier call = %+v, want cached nightly", got) + } +} + +func TestService_ReplayCachedUpdateSerializesWithChannelSelection(t *testing.T) { + homeDir := configureManagedNPMInstall(t) + pool := newTestPool(t) + store := &memorySettingsStore{value: []byte(ChannelStable), present: true} + if err := persistence.WriteLatestVersion( + pool.Writer(), + "v1.2.4", + "https://example.test/stable", + time.Now(), + ); err != nil { + t.Fatal(err) + } + svc := NewService( + pool, + "v1.2.3", + nil, + logger.Default(), + WithHomeDir(homeDir), + WithSettingsStore(store), + ) + notifier := &firstCallBlockingNotifier{ + entered: make(chan struct{}), + release: make(chan struct{}), + } + releaseReplay := sync.OnceFunc(func() { close(notifier.release) }) + t.Cleanup(releaseReplay) + svc.SetNotifier(notifier) + nightlyFetchEntered := make(chan struct{}) + svc.SetNightlyFetcher(func(context.Context) (string, string, error) { + close(nightlyFetchEntered) + return "v1.2.5-nightly.shaabcdef123456", "https://example.test/nightly", nil + }) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + replayDone := make(chan error, 1) + go func() { replayDone <- svc.ReplayCachedUpdate(ctx) }() + select { + case <-notifier.entered: + case err := <-replayDone: + t.Fatalf("cached replay returned before notifier: %v", err) + case <-ctx.Done(): + t.Fatal("cached replay did not reach notifier") + } + if svc.updateMu.TryLock() { + svc.updateMu.Unlock() + t.Fatal("update lock was not held while cached replay notified") + } + + selectDone := make(chan error, 1) + selectStarted := make(chan struct{}) + go func() { + close(selectStarted) + _, err := svc.SelectChannel(ctx, string(ChannelNightly)) + selectDone <- err + }() + select { + case <-selectStarted: + case <-ctx.Done(): + t.Fatal("channel selection did not start") + } + + releaseReplay() + select { + case err := <-replayDone: + if err != nil { + t.Fatalf("cached replay: %v", err) + } + case <-ctx.Done(): + t.Fatal("cached replay did not finish") + } + select { + case <-nightlyFetchEntered: + case err := <-selectDone: + t.Fatalf("channel selection returned before resolving Nightly: %v", err) + case <-ctx.Done(): + t.Fatal("channel selection did not resolve Nightly") + } + select { + case err := <-selectDone: + if err != nil { + t.Fatalf("channel selection: %v", err) + } + case <-ctx.Done(): + t.Fatal("channel selection did not finish") + } +} + +func TestService_ReturnToOlderStableIsAvailableWithoutUpgradeNotification(t *testing.T) { + homeDir := configureManagedNPMInstall(t) + pool := newTestPool(t) + store := &memorySettingsStore{value: []byte(ChannelStable), present: true} + if err := persistence.WriteLatestVersion(pool.Writer(), "v1.2.3", "https://example.test/stable", time.Now()); err != nil { + t.Fatal(err) + } + svc := NewService( + pool, + "v1.2.4-nightly.shaabc123def456", + nil, + logger.Default(), + WithHomeDir(homeDir), + WithSettingsStore(store), + ) + notifier := &capturingNotifier{} + svc.SetNotifier(notifier) + + resp, err := svc.Get(context.Background()) + if err != nil { + t.Fatal(err) + } + if !resp.UpdateAvailable { + t.Fatal("explicit stable return should be available") + } + if err := svc.ReplayCachedUpdate(context.Background()); err != nil { + t.Fatal(err) + } + if len(notifier.calls) != 0 { + t.Fatalf("downgrade-like stable return sent %d notification(s)", len(notifier.calls)) + } +} + func TestService_PollBeforeLocalSubscription_ReplaysCachedUpdateExactlyOnce(t *testing.T) { ctx := context.Background() t.Setenv("KANDEV_DESKTOP_NATIVE_NOTIFICATIONS", "true") diff --git a/apps/backend/internal/system/updates/npm.go b/apps/backend/internal/system/updates/npm.go new file mode 100644 index 0000000000..9376ab13b0 --- /dev/null +++ b/apps/backend/internal/system/updates/npm.go @@ -0,0 +1,97 @@ +package updates + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +const DefaultNPMRegistryURL = "https://registry.npmjs.org/kandev" +const maxNPMPackageResponseBytes = 8 << 20 + +type npmPackagePayload struct { + DistTags map[string]string `json:"dist-tags"` + Versions map[string]json.RawMessage `json:"versions"` +} + +type npmVersionRecord struct { + Name string `json:"name"` + Version string `json:"version"` +} + +func FetchLatestNightlyFrom(ctx context.Context, client *http.Client, registryURL string) (string, string, error) { + if client == nil { + client = &http.Client{Timeout: defaultClientTimeout} + } + if registryURL == "" { + registryURL = DefaultNPMRegistryURL + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, registryURL, nil) + if err != nil { + return "", "", fmt.Errorf("build npm request: %w", err) + } + req.Header.Set("Accept", "application/vnd.npm.install-v1+json") + req.Header.Set("User-Agent", "kandev-updates-poller") + + resp, err := client.Do(req) + if err != nil { + return "", "", fmt.Errorf("npm request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return "", "", fmt.Errorf("npm registry returned status %d", resp.StatusCode) + } + + payload, err := decodeNPMPackagePayload(resp.Body) + if err != nil { + return "", "", err + } + version := payload.DistTags[string(ChannelNightly)] + if version == "" { + return "", "", errors.New("npm response missing nightly dist-tag") + } + // npm package versions and dist-tag targets use canonical bare SemVer. + // Internal update tags add v only after this registry integrity check. + if !isCanonicalNPMNightlyVersion(version) { + return "", "", fmt.Errorf("npm response has invalid nightly version %q", version) + } + record, ok := payload.Versions[version] + if !ok || len(record) == 0 || string(record) == "null" { + return "", "", fmt.Errorf("npm response missing exact version %q", version) + } + if !isExactNPMVersionRecord(record, version) { + return "", "", fmt.Errorf("npm response has invalid exact version record %q", version) + } + + return "v" + version, "https://www.npmjs.com/package/kandev/v/" + url.PathEscape(version), nil +} + +func isExactNPMVersionRecord(record json.RawMessage, version string) bool { + var exact npmVersionRecord + return json.Unmarshal(record, &exact) == nil && + exact.Name == "kandev" && exact.Version == version +} + +func isCanonicalNPMNightlyVersion(version string) bool { + return !strings.HasPrefix(version, "v") && isNightlyVersion(version) +} + +func decodeNPMPackagePayload(reader io.Reader) (npmPackagePayload, error) { + body, err := io.ReadAll(io.LimitReader(reader, maxNPMPackageResponseBytes+1)) + if err != nil { + return npmPackagePayload{}, fmt.Errorf("read npm response: %w", err) + } + if len(body) > maxNPMPackageResponseBytes { + return npmPackagePayload{}, fmt.Errorf("npm response exceeds %d bytes", maxNPMPackageResponseBytes) + } + var payload npmPackagePayload + if err := json.Unmarshal(body, &payload); err != nil { + return npmPackagePayload{}, fmt.Errorf("decode npm response: %w", err) + } + return payload, nil +} diff --git a/apps/backend/internal/system/updates/npm_test.go b/apps/backend/internal/system/updates/npm_test.go new file mode 100644 index 0000000000..ae50320fb0 --- /dev/null +++ b/apps/backend/internal/system/updates/npm_test.go @@ -0,0 +1,102 @@ +package updates + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestFetchLatestNightlyFromAcceptsCanonicalUnprefixedVersion(t *testing.T) { + const version = "1.2.4-nightly.shaabc123def456" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Accept"); got != "application/vnd.npm.install-v1+json" { + t.Errorf("Accept=%q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"dist-tags":{"nightly":"` + version + `"},"versions":{"` + version + `":{"name":"kandev","version":"` + version + `"}}}`)) + })) + defer server.Close() + + got, packageURL, err := FetchLatestNightlyFrom(context.Background(), server.Client(), server.URL) + if err != nil { + t.Fatalf("FetchLatestNightlyFrom: %v", err) + } + if got != "v"+version { + t.Fatalf("version=%q want %q", got, "v"+version) + } + if packageURL != "https://www.npmjs.com/package/kandev/v/"+version { + t.Fatalf("packageURL=%q", packageURL) + } +} + +func TestFetchLatestNightlyFromRejectsInvalidRegistryDocuments(t *testing.T) { + const valid = "1.2.4-nightly.shaabc123def456" + tests := []struct { + name string + body string + want string + }{ + {name: "missing tag", body: `{"dist-tags":{},"versions":{}}`, want: "missing nightly dist-tag"}, + {name: "invalid version", body: `{"dist-tags":{"nightly":"1.2.4-nightly.shaBAD"},"versions":{}}`, want: "invalid nightly version"}, + { + name: "prefixed npm version", + body: `{"dist-tags":{"nightly":"v` + valid + `"},"versions":{"v` + valid + `":{"name":"kandev"}}}`, + want: "invalid nightly version", + }, + {name: "missing exact record", body: `{"dist-tags":{"nightly":"` + valid + `"},"versions":{}}`, want: "missing exact version"}, + {name: "null exact record", body: `{"dist-tags":{"nightly":"` + valid + `"},"versions":{"` + valid + `":null}}`, want: "missing exact version"}, + {name: "empty string exact record", body: `{"dist-tags":{"nightly":"` + valid + `"},"versions":{"` + valid + `":""}}`, want: "invalid exact version record"}, + {name: "empty exact record", body: `{"dist-tags":{"nightly":"` + valid + `"},"versions":{"` + valid + `":{}}}`, want: "invalid exact version record"}, + {name: "wrong package name", body: `{"dist-tags":{"nightly":"` + valid + `"},"versions":{"` + valid + `":{"name":"other","version":"` + valid + `"}}}`, want: "invalid exact version record"}, + {name: "wrong package version", body: `{"dist-tags":{"nightly":"` + valid + `"},"versions":{"` + valid + `":{"name":"kandev","version":"0.0.0"}}}`, want: "invalid exact version record"}, + {name: "malformed json", body: `{`, want: "decode npm response"}, + { + name: "trailing json value", + body: `{"dist-tags":{"nightly":"` + valid + `"},"versions":{"` + valid + `":{"name":"kandev","version":"` + valid + `"}}} {}`, + want: "decode npm response", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(tc.body)) + })) + defer server.Close() + _, _, err := FetchLatestNightlyFrom(context.Background(), server.Client(), server.URL) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error=%v want substring %q", err, tc.want) + } + }) + } +} + +func TestFetchLatestNightlyFromReturnsHTTPFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "registry down: upstream-secret", http.StatusServiceUnavailable) + })) + defer server.Close() + _, _, err := FetchLatestNightlyFrom(context.Background(), server.Client(), server.URL) + if err == nil || !strings.Contains(err.Error(), "npm registry returned status 503") { + t.Fatalf("error=%v", err) + } + if strings.Contains(err.Error(), "upstream-secret") { + t.Fatalf("error exposes registry response body: %v", err) + } +} + +func TestFetchLatestNightlyFromRejectsOversizedResponse(t *testing.T) { + const version = "1.2.4-nightly.shaabc123def456" + body := strings.Repeat(" ", (8<<20)+1) + + `{"dist-tags":{"nightly":"` + version + `"},"versions":{"` + version + `":{"name":"kandev"}}}` + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(body)) + })) + defer server.Close() + + _, _, err := FetchLatestNightlyFrom(context.Background(), server.Client(), server.URL) + if err == nil || !strings.Contains(err.Error(), "npm response exceeds") { + t.Fatalf("error=%v", err) + } +} diff --git a/apps/backend/internal/system/updates/poller.go b/apps/backend/internal/system/updates/poller.go index 018fd8fcdb..d23f99434b 100644 --- a/apps/backend/internal/system/updates/poller.go +++ b/apps/backend/internal/system/updates/poller.go @@ -71,7 +71,7 @@ func (s *Service) pollerLoop(ctx context.Context, interval time.Duration) { // tickOnce performs a single poll iteration. Intentionally package-internal // so deterministic tests can drive the loop without spinning up synctest. -// GitHub rate-limit responses are logged at info; everything else at warn. +// GitHub rate-limit responses are logged at info; other source errors at warn. func (s *Service) tickOnce(ctx context.Context) { if ctx.Err() != nil { return diff --git a/apps/backend/internal/system/updates/semver.go b/apps/backend/internal/system/updates/semver.go index 1801c38865..98f9541e31 100644 --- a/apps/backend/internal/system/updates/semver.go +++ b/apps/backend/internal/system/updates/semver.go @@ -58,6 +58,26 @@ func compareSemver(a, b string) int { return strings.Compare(ap, bp) } +// compareSemverCore compares only major.minor.patch and ignores prerelease +// identifiers. Callers use it when different nightly SHAs at the same numeric +// version are both valid targets, while a lower numeric target is a rollback. +func compareSemverCore(a, b string) int { + an, _, _, aok := parseSemver(a) + bn, _, _, bok := parseSemver(b) + if !aok || !bok { + return compareSemver(a, b) + } + for i := range an { + if an[i] < bn[i] { + return -1 + } + if an[i] > bn[i] { + return 1 + } + } + return 0 +} + // parseSemver returns the three numeric segments, the pre-release suffix (or // ""), the git-describe "commits ahead" count (0 when not a git-describe // build), and ok=false when the input is not a recognisable major.minor.patch diff --git a/apps/backend/internal/system/updates/semver_test.go b/apps/backend/internal/system/updates/semver_test.go index c24ba61584..bb7063716c 100644 --- a/apps/backend/internal/system/updates/semver_test.go +++ b/apps/backend/internal/system/updates/semver_test.go @@ -43,6 +43,25 @@ func TestCompareSemver(t *testing.T) { } } +func TestCompareSemverCoreFallsBackForInvalidInputs(t *testing.T) { + for _, tc := range []struct { + name string + a string + b string + }{ + {name: "invalid below valid", a: "not-a-version", b: "v1.2.3"}, + {name: "valid above invalid", a: "v1.2.3", b: "not-a-version"}, + {name: "both invalid", a: "invalid-b", b: "invalid-a"}, + } { + t.Run(tc.name, func(t *testing.T) { + want := compareSemver(tc.a, tc.b) + if got := compareSemverCore(tc.a, tc.b); got != want { + t.Fatalf("compareSemverCore(%q, %q)=%d want fallback %d", tc.a, tc.b, got, want) + } + }) + } +} + func TestGitDescribeCommits(t *testing.T) { cases := []struct { name string diff --git a/apps/backend/internal/system/updates/service.go b/apps/backend/internal/system/updates/service.go index 89c548c577..c1f8d75069 100644 --- a/apps/backend/internal/system/updates/service.go +++ b/apps/backend/internal/system/updates/service.go @@ -1,6 +1,6 @@ // Package updates implements the kandev background updates poller and the -// HTTP surface for the System -> Updates page. It polls GitHub Releases every -// 6 hours, persists the latest tag in the kandev_meta key/value table, and +// HTTP surface for the System -> Updates page. It resolves stable releases +// from GitHub or nightlies from npm, persists isolated channel caches, and // exposes a 30s rate-limited "check now" handler. package updates @@ -10,6 +10,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "sync" "sync/atomic" "time" @@ -22,7 +23,8 @@ import ( "github.com/kandev/kandev/internal/system/jobs" ) -// PollInterval is the cadence at which the background goroutine polls GitHub. +// PollInterval is the cadence at which the background goroutine polls the +// selected update source. const PollInterval = 6 * time.Hour // ManualCheckWindow is the minimum gap between two manual /check calls. @@ -60,15 +62,18 @@ const devVersion = "dev" // UpdatesResponse is the JSON shape returned by both Get() and Check(). type UpdatesResponse struct { - Current string `json:"current"` - Latest string `json:"latest"` - LatestURL string `json:"latest_url"` - LatestCheckedAt time.Time `json:"latest_checked_at"` - UpdateAvailable bool `json:"update_available"` - Install InstallStateResponse `json:"install"` - ApplySupported bool `json:"apply_supported"` - ApplyUnsupportedReason string `json:"apply_unsupported_reason,omitempty"` - ManualCommands []string `json:"manual_commands,omitempty"` + Current string `json:"current"` + Latest string `json:"latest"` + LatestURL string `json:"latest_url"` + LatestCheckedAt time.Time `json:"latest_checked_at"` + UpdateAvailable bool `json:"update_available"` + Channel Channel `json:"channel"` + ChannelEditable bool `json:"channel_editable"` + ChannelUnsupportedReason string `json:"channel_unsupported_reason"` + Install InstallStateResponse `json:"install"` + ApplySupported bool `json:"apply_supported"` + ApplyUnsupportedReason string `json:"apply_unsupported_reason,omitempty"` + ManualCommands []string `json:"manual_commands,omitempty"` } // ApplyResponse is the 202 payload returned when a self-update helper has @@ -89,9 +94,6 @@ type InstallStateResponse struct { MetadataPath string `json:"metadata_path,omitempty"` } -// releaseURL is the GitHub endpoint the service polls. Tests override it via -// SetReleaseURL. -// // Service holds the wiring needed to drive the poller and serve the two // HTTP endpoints. type Service struct { @@ -105,6 +107,7 @@ type Service struct { getenv func(string) string now func() time.Time applyRun applyRunner + settings settingsStore // notifier routes update availability through the canonical notification // service. It is nil-safe for isolated updates tests and minimal startup. @@ -117,16 +120,23 @@ type Service struct { // the backend cannot wedge apply behind a permanent 409. applyStartedAt atomic.Int64 + // updateMu serializes source resolution and cache persistence across the + // poller, manual checks, channel changes, and apply preflight. A slower, + // older resolution therefore cannot overwrite a newer operation's cache. + updateMu sync.Mutex + // releaseURL is the GitHub endpoint hit by Check + the poller; defaults // to DefaultReleaseURL and can be overridden by SetReleaseURL for tests. releaseURL string + nightlyURL string // fetcher is the function used to retrieve the latest release. Defaults // to FetchLatestReleaseFrom(httpClient). Tests inject a deterministic // stub via SetFetcher so the synctest poller test does not block on // real network I/O (which sits outside the fake-time bubble and prevents // synctest.Wait from settling). - fetcher Fetcher + fetcher Fetcher + nightlyFetcher Fetcher // mu protects pollerStarted/cancel/wg under concurrent Start calls. mu sync.Mutex @@ -136,8 +146,8 @@ type Service struct { pollerInterval time.Duration } -// Fetcher abstracts the GitHub release call so tests can drive -// the poller and Check() without spinning up an httptest server. +// Fetcher abstracts update-source resolution so tests can drive the poller +// and Check() without spinning up an httptest server. type Fetcher func(ctx context.Context) (tag, url string, err error) // UpdateNotifier is the narrow canonical notification-service boundary used @@ -207,12 +217,14 @@ func NewService(pool *db.Pool, current string, httpClient *http.Client, log *log log: log, limiter: NewLimiter(ManualCheckWindow), releaseURL: DefaultReleaseURL, + nightlyURL: DefaultNPMRegistryURL, pollerInterval: PollInterval, homeDir: homeDir, getenv: os.Getenv, now: time.Now, } s.fetcher = s.defaultFetcher + s.nightlyFetcher = s.defaultNightlyFetcher s.applyRun = s.defaultApplyRunner for _, opt := range opts { opt(s) @@ -230,6 +242,13 @@ func (s *Service) defaultFetcher(ctx context.Context) (string, string, error) { return FetchLatestReleaseFrom(ctx, s.httpClient, url) } +func (s *Service) defaultNightlyFetcher(ctx context.Context) (string, string, error) { + s.mu.Lock() + url := s.nightlyURL + s.mu.Unlock() + return FetchLatestNightlyFrom(ctx, s.httpClient, url) +} + // SetFetcher overrides the GitHub fetch implementation. Intended for tests // that need deterministic behaviour inside testing/synctest. func (s *Service) SetFetcher(f Fetcher) { @@ -242,6 +261,17 @@ func (s *Service) SetFetcher(f Fetcher) { s.mu.Unlock() } +// SetNightlyFetcher overrides npm nightly resolution. Intended for tests. +func (s *Service) SetNightlyFetcher(f Fetcher) { + s.mu.Lock() + if f == nil { + s.nightlyFetcher = s.defaultNightlyFetcher + } else { + s.nightlyFetcher = f + } + s.mu.Unlock() +} + // SetReleaseURL overrides the GitHub endpoint used by the poller and Check(). // Intended for tests that point at a httptest stub server. func (s *Service) SetReleaseURL(url string) { @@ -250,6 +280,13 @@ func (s *Service) SetReleaseURL(url string) { s.mu.Unlock() } +// SetNightlyURL overrides the npm registry endpoint used by Check + poller. +func (s *Service) SetNightlyURL(url string) { + s.mu.Lock() + s.nightlyURL = url + s.mu.Unlock() +} + // SetNotifier wires the canonical notifier after gateway composition. func (s *Service) SetNotifier(notifier UpdateNotifier) { s.mu.Lock() @@ -257,19 +294,24 @@ func (s *Service) SetNotifier(notifier UpdateNotifier) { s.mu.Unlock() } -// Get returns the last-known state from kandev_meta without contacting -// GitHub. Safe to call on every page load. -func (s *Service) Get() (UpdatesResponse, error) { - version, url, checkedAt, err := persistence.ReadLatestVersion(s.pool.Reader()) +// Get returns the selected channel's last-known state without contacting its +// upstream. Safe to call on every page load. +func (s *Service) Get(ctx context.Context) (UpdatesResponse, error) { + install, _ := s.detectInstallState() + channel, err := s.effectiveChannel(ctx, install) if err != nil { return UpdatesResponse{}, err } - return s.buildResponse(version, url, checkedAt), nil + version, url, checkedAt, err := s.readLatestVersion(channel) + if err != nil { + return UpdatesResponse{}, err + } + return s.buildResponseFromChannel(channel, install, version, url, checkedAt), nil } -// Check forces a synchronous poll against GitHub. Rate-limited per process by -// the 30s Limiter. On success the result is persisted and returned. On -// failure the previously persisted values are returned unchanged. +// Check forces a synchronous poll against the effective channel's source. +// It is rate-limited per process by the 30s Limiter. On failure the selected +// channel's previously persisted values remain unchanged. func (s *Service) Check(ctx context.Context) (UpdatesResponse, error) { if ok, _ := s.limiter.Allow(); !ok { return UpdatesResponse{}, ErrRateLimited @@ -277,20 +319,16 @@ func (s *Service) Check(ctx context.Context) (UpdatesResponse, error) { return s.fetchAndPersist(ctx) } -// Apply validates the cached update and current service install, writes an -// intent file, and queues the OS-manager helper that performs the actual -// package upgrade + service reinstall. -func (s *Service) Apply(ctx context.Context, confirm string) (string, error) { +// Apply validates that the expected target still matches the cached update and +// current service install, writes an exact-version intent, and queues the +// OS-manager helper that performs the package upgrade + service reinstall. +func (s *Service) Apply(ctx context.Context, confirm, expectedTarget string) (string, error) { if confirm != "UPDATE" { return "", ErrApplyConfirm } if s.jobs == nil { return "", ErrApplyUnsupported } - resp, metadata, err := s.applyPreflight() - if err != nil { - return "", err - } // Claim the in-flight guard across the launch. A successful launch keeps it // held because the helper restarts the backend shortly (which clears it by // replacing the process); a launch error releases it for an immediate retry. @@ -299,6 +337,11 @@ func (s *Service) Apply(ctx context.Context, confirm string) (string, error) { if !s.claimApplyGuard() { return "", ErrApplyInProgress } + resp, metadata, err := s.applyPreflight(ctx, expectedTarget) + if err != nil { + s.applyStartedAt.Store(0) + return "", err + } intentPath, intent, err := s.writeApplyIntent(resp, metadata) if err != nil { s.applyStartedAt.Store(0) @@ -357,43 +400,69 @@ func (s *Service) peekLimiter() (bool, time.Duration) { return false, retry } -// fetchAndPersist hits GitHub, persists the result on success, and always -// returns the now-current view of kandev_meta. A fetch failure preserves the -// previously persisted state and returns the underlying error. +// fetchAndPersist resolves the effective channel and persists its isolated +// cache on success. A fetch failure preserves both caches. func (s *Service) fetchAndPersist(ctx context.Context) (UpdatesResponse, error) { - s.mu.Lock() - fetch := s.fetcher - s.mu.Unlock() + s.updateMu.Lock() + defer s.updateMu.Unlock() - tag, releaseURL, err := fetch(ctx) + install, _ := s.detectInstallState() + channel, err := s.effectiveChannel(ctx, install) if err != nil { - // Preserve persisted state; surface the error to caller. - current, _ := s.Get() - return current, err + return UpdatesResponse{}, err } - now := time.Now().UTC() - if werr := persistence.WriteLatestVersion(s.pool.Writer(), tag, releaseURL, now); werr != nil { + tag, releaseURL, err := s.resolveLatest(ctx, channel) + if err != nil { + // Preserve persisted state without re-reading settings through a context + // the failed upstream request may already have canceled. + version, url, checkedAt, readErr := s.readLatestVersion(channel) + if readErr != nil { + s.log.Warn("updates: read cached version after fetch failure", zap.Error(readErr)) + return UpdatesResponse{}, err + } + return s.buildResponseFromChannel(channel, install, version, url, checkedAt), err + } + now := s.now().UTC() + if werr := s.writeLatestVersion(channel, tag, releaseURL, now); werr != nil { s.log.Warn("updates: persist latest version failed", zap.Error(werr)) return UpdatesResponse{}, werr } - s.notifyUpdateAvailable(ctx, tag, releaseURL) - return s.buildResponse(tag, releaseURL, now), nil + s.notifyUpdateAvailableFor(ctx, channel, tag, releaseURL) + return s.buildResponseFromChannel(channel, install, tag, releaseURL, now), nil +} + +func (s *Service) resolveLatest(ctx context.Context, channel Channel) (string, string, error) { + s.mu.Lock() + fetch := s.fetcher + if channel == ChannelNightly { + fetch = s.nightlyFetcher + } + s.mu.Unlock() + return fetch(ctx) } // ReplayCachedUpdate delivers a previously persisted newer release without -// contacting GitHub. It is called when the default user becomes eligible for -// Local delivery after an early startup poll. +// contacting the selected upstream. It is called when the default user becomes +// eligible for Local delivery after an early startup poll. func (s *Service) ReplayCachedUpdate(ctx context.Context) error { - tag, releaseURL, _, err := persistence.ReadLatestVersion(s.pool.Reader()) + s.updateMu.Lock() + defer s.updateMu.Unlock() + + install, _ := s.detectInstallState() + channel, err := s.effectiveChannel(ctx, install) if err != nil { return err } - s.notifyUpdateAvailable(ctx, tag, releaseURL) + tag, releaseURL, _, err := s.readLatestVersion(channel) + if err != nil { + return err + } + s.notifyUpdateAvailableFor(ctx, channel, tag, releaseURL) return nil } -func (s *Service) notifyUpdateAvailable(ctx context.Context, tag, releaseURL string) { - if !s.updateAvailable(tag) { +func (s *Service) notifyUpdateAvailableFor(ctx context.Context, channel Channel, tag, releaseURL string) { + if !s.notificationAvailableFor(channel, tag) { return } s.mu.Lock() @@ -405,33 +474,47 @@ func (s *Service) notifyUpdateAvailable(ctx context.Context, tag, releaseURL str notifier.HandleUpdateAvailable(ctx, tag, releaseURL) } -func (s *Service) buildResponse(latest, url string, checkedAt time.Time) UpdatesResponse { - install, _ := s.detectInstallState() - return s.buildResponseFrom(install, latest, url, checkedAt) +func (s *Service) notificationAvailableFor(channel Channel, latest string) bool { + if !s.updateAvailableFor(channel, latest) { + return false + } + // Returning from a nightly to an older stable is an explicit user action, + // not a newly available upgrade. Keep it actionable in the UI without + // broadcasting an upgrade notification. + if channel == ChannelStable && isNightlyVersion(s.current) && compareSemver(latest, s.current) <= 0 { + return false + } + return true } -// buildResponseFrom assembles the response from an already-detected install -// state. Apply reuses this so the ApplySupported gate and the intent file are -// derived from a single install-state snapshot rather than two independent -// reads (closing a narrow TOCTOU window). -func (s *Service) buildResponseFrom(install InstallStateResponse, latest, url string, checkedAt time.Time) UpdatesResponse { +// buildResponseFromChannel assembles a response from one install-state +// snapshot so capability gates and apply intent stay consistent. +func (s *Service) buildResponseFromChannel(channel Channel, install InstallStateResponse, latest, url string, checkedAt time.Time) UpdatesResponse { applySupported, reason := install.applySupport() + channelEditable, channelReason := s.channelSupport(install) return UpdatesResponse{ - Current: s.current, - Latest: latest, - LatestURL: url, - LatestCheckedAt: checkedAt, - UpdateAvailable: s.updateAvailable(latest), - Install: install, - ApplySupported: applySupported, - ApplyUnsupportedReason: reason, - ManualCommands: manualCommands(install, latest), + Current: s.current, + Latest: latest, + LatestURL: url, + LatestCheckedAt: checkedAt, + UpdateAvailable: s.updateAvailableFor(channel, latest), + Channel: channel, + ChannelEditable: channelEditable, + ChannelUnsupportedReason: channelReason, + Install: install, + ApplySupported: applySupported, + ApplyUnsupportedReason: reason, + ManualCommands: manualCommands(install, latest), } } // updateAvailable returns true iff latest is a valid semver strictly greater // than current. Current = "dev" or empty disables the flag. func (s *Service) updateAvailable(latest string) bool { + return s.updateAvailableFor(ChannelStable, latest) +} + +func (s *Service) updateAvailableFor(channel Channel, latest string) bool { if latest == "" || !isValidSemver(latest) { return false } @@ -441,5 +524,30 @@ func (s *Service) updateAvailable(latest string) bool { if !isValidSemver(s.current) { return false } + current := strings.TrimPrefix(s.current, "v") + target := strings.TrimPrefix(latest, "v") + if current == target { + return false + } + if channel == ChannelNightly && isNightlyVersion(s.current) && isNightlyVersion(latest) { + return compareSemverCore(latest, s.current) >= 0 + } + if channel == ChannelStable && isNightlyVersion(s.current) && !isNightlyVersion(latest) { + return true + } return compareSemver(latest, s.current) > 0 } + +func (s *Service) readLatestVersion(channel Channel) (string, string, time.Time, error) { + if channel == ChannelNightly { + return persistence.ReadLatestNightlyVersion(s.pool.Reader()) + } + return persistence.ReadLatestVersion(s.pool.Reader()) +} + +func (s *Service) writeLatestVersion(channel Channel, version, url string, checkedAt time.Time) error { + if channel == ChannelNightly { + return persistence.WriteLatestNightlyVersion(s.pool.Writer(), version, url, checkedAt) + } + return persistence.WriteLatestVersion(s.pool.Writer(), version, url, checkedAt) +} diff --git a/apps/backend/internal/system/updates/service_test.go b/apps/backend/internal/system/updates/service_test.go index e9530b1a0f..c4137a3360 100644 --- a/apps/backend/internal/system/updates/service_test.go +++ b/apps/backend/internal/system/updates/service_test.go @@ -61,7 +61,7 @@ func TestService_Get_FreshDB_ReturnsZeroValues(t *testing.T) { pool := newTestPool(t) svc := NewService(pool, "v1.0.0", nil, logger.Default()) - resp, err := svc.Get() + resp, err := svc.Get(context.Background()) if err != nil { t.Fatalf("Get: %v", err) } @@ -100,7 +100,7 @@ func TestService_Check_PersistsToMeta(t *testing.T) { } // Persistence side-effect: a fresh Get should return the same values. - got, err := svc.Get() + got, err := svc.Get(context.Background()) if err != nil { t.Fatalf("Get: %v", err) } diff --git a/apps/cli/src/release-config.test.ts b/apps/cli/src/release-config.test.ts index 66f395a3a3..2fc130873a 100644 --- a/apps/cli/src/release-config.test.ts +++ b/apps/cli/src/release-config.test.ts @@ -9,6 +9,10 @@ function readRepoFile(path: string): string { return readFileSync(resolve(repoRoot, path), "utf8"); } +function runtimePackagesIn(source: string): string[] { + return [...new Set(source.match(/@kdlbs\/runtime-[a-z0-9-]+/g) ?? [])].sort(); +} + function workflowFiles(): string[] { const workflowDir = resolve(repoRoot, ".github/workflows"); return readdirSync(workflowDir) @@ -226,16 +230,79 @@ describe("release runtime tooling configuration", () => { }); describe("release npm publishing", () => { - it("skips packages that are already published on npm", () => { + it("accepts either GitHub release assets or local nightly assets", () => { const script = readRepoFile("scripts/release/publish-npm.sh"); - expect(script).toContain('npm view "${pkg}@${VERSION}" version --silent'); - expect(script).toMatch( - /if package_already_published "\$pkg"; then\s+record_already_published "\$pkg"\s+continue\s+fi/, + for (const flag of ["--version", "--dist-tag", "--release-tag", "--assets-dir"]) { + expect(script).toContain(flag); + } + expect(script).toContain('DIST_TAG=""'); + expect(script).toContain('case "$DIST_TAG" in'); + expect(script).toContain("latest)"); + expect(script).toContain("nightly)"); + expect(script).toContain('if [[ -n "$RELEASE_TAG" && -n "$SOURCE_ASSETS_DIR" ]]'); + expect(script).toContain('elif [[ -z "$RELEASE_TAG" && -z "$SOURCE_ASSETS_DIR" ]]'); + expect(script).toContain('elif [[ "$DIST_TAG" == "latest" && -z "$RELEASE_TAG" ]]'); + expect(script).toContain('elif [[ "$DIST_TAG" == "nightly" && -z "$SOURCE_ASSETS_DIR" ]]'); + expect(script).toContain('ASSETS_DIR="$SOURCE_ASSETS_DIR"'); + }); + + it("publishes all packages under the requested dist-tag and pins the launcher version", () => { + const script = readRepoFile("scripts/release/publish-npm.sh"); + const packages = readRepoFile("scripts/release/npm-packages.sh"); + + const loopStart = script.indexOf('for pkg in "${RUNTIME_PACKAGES[@]}"; do'); + const loopEnd = script.indexOf("\ndone", loopStart); + expect(loopStart).toBeGreaterThanOrEqual(0); + expect(loopEnd).toBeGreaterThan(loopStart); + const runtimePublishLoop = script.slice(loopStart, loopEnd); + + expect(runtimePublishLoop).toContain( + 'npm publish --access public --provenance --tag "$DIST_TAG"', ); - expect(script).toMatch( - /if package_already_published "kandev"; then\s+record_already_published "kandev"/, + const mainPublish = script.slice(script.indexOf("Publishing kandev@$VERSION")); + expect(mainPublish).toContain('npm publish --access public --provenance --tag "$DIST_TAG"'); + expect(script).toContain("pkg.version = version;"); + expect(script).toContain('CLI_PACKAGE_BACKUP="$WORK_DIR/cli-package.json"'); + expect(script).toContain('cp "$CLI_PACKAGE_BACKUP" "$CLI_PACKAGE_JSON"'); + for (const packageName of [ + "@kdlbs/runtime-linux-x64", + "@kdlbs/runtime-linux-arm64", + "@kdlbs/runtime-darwin-x64", + "@kdlbs/runtime-darwin-arm64", + "@kdlbs/runtime-win32-x64", + ]) { + expect(packages).toContain(`"${packageName}"`); + } + expect(packages).toContain('NIGHTLY_PACKAGES=("kandev" "${RUNTIME_PACKAGES[@]}")'); + expect(script).toContain('source "$ROOT_DIR/scripts/release/npm-packages.sh"'); + }); + + it("keeps every runtime package consumer aligned with the publishing inventory", () => { + const inventory = runtimePackagesIn(readRepoFile("scripts/release/npm-packages.sh")); + expect(inventory.length).toBeGreaterThan(0); + + for (const path of [ + "apps/cli/src/runtime.ts", + "apps/cli/bin/native-shim.js", + "apps/cli/package.json", + ]) { + expect(runtimePackagesIn(readRepoFile(path)), path).toEqual(inventory); + } + expect(readRepoFile("scripts/release/package-npm-runtime.sh")).toContain( + 'source "$ROOT_DIR/scripts/release/npm-packages.sh"', ); + expect(readRepoFile("scripts/release/publish-npm.test.mjs")).toContain("loadRuntimeInventory"); + }); + + it("only treats an existing nightly version as idempotent when the nightly tag matches", () => { + const script = readRepoFile("scripts/release/publish-npm.sh"); + + expect(script).toContain('npm_view_version "${pkg}@${VERSION}"'); + expect(script).toContain('npm_view_version "${pkg}@${DIST_TAG}"'); + expect(script).toContain('if [[ "$DIST_TAG" == "nightly" ]]'); + expect(script).toContain('if [[ "$tagged_version" != "$VERSION" ]]'); + expect(script).toContain("refusing idempotent success"); expect(script).toContain("EPUBLISHCONFLICT"); expect(script).toContain("treated as idempotent success"); }); @@ -390,7 +457,9 @@ describe("release desktop artifacts", () => { expect(workflow).toContain("persist-credentials: false"); expect(workflow).toContain("Desktop validation summary"); expect(workflow).toContain("No release PR, tag, GitHub release, public container tags"); - expect(workflow).toContain("if: ${{ !inputs.dry_run && !inputs.desktop_validation_only }}"); + expect(workflow).toContain( + "if: ${{ github.event_name == 'workflow_dispatch' && !inputs.dry_run && !inputs.desktop_validation_only }}", + ); expect(workflow).toContain('if [ "$DESKTOP_VALIDATION_ONLY" = "true" ]; then'); expect(workflow).toContain("scripts/release/desktop-signing-ready.sh macos"); expect(workflow).toContain("scripts/release/desktop-signing-ready.sh windows"); diff --git a/apps/cli/src/service/self_update.test.ts b/apps/cli/src/service/self_update.test.ts index 7ad457363c..2f23fe275e 100644 --- a/apps/cli/src/service/self_update.test.ts +++ b/apps/cli/src/service/self_update.test.ts @@ -48,6 +48,17 @@ describe("planSelfUpdate", () => { ]); }); + it("installs an exact npm nightly prerelease target", () => { + const nightly = intent("npm"); + nightly.target_tag = "v0.82.1-nightly.shaabc123def456"; + nightly.target_version = "0.82.1-nightly.shaabc123def456"; + + expect(planSelfUpdate(nightly, { platform: "linux" })[0]).toEqual({ + command: "npm", + args: ["install", "-g", "--prefix", "/usr", "kandev@0.82.1-nightly.shaabc123def456"], + }); + }); + it("keeps npm updates inside the original global prefix", () => { const npm = intent("npm"); npm.install.cli_entry = "/tmp/kandev-test/npm-global/lib/node_modules/kandev/bin/cli.js"; @@ -103,6 +114,26 @@ describe("planSelfUpdate", () => { }); }); + it("reinstalls an exact npx nightly prerelease target", () => { + const nightly = intent("npx"); + nightly.target_tag = "v0.82.1-nightly.shaabc123def456"; + nightly.target_version = "0.82.1-nightly.shaabc123def456"; + + expect(planSelfUpdate(nightly, { platform: "linux" })[0]).toEqual({ + command: "npx", + args: [ + "-y", + "kandev@0.82.1-nightly.shaabc123def456", + "service", + "install", + "--home-dir", + "/home/alice/.kandev", + "--port", + "38429", + ], + }); + }); + it("plans launchd restart on macOS", () => { const mac = intent("homebrew"); mac.install.manager = "launchd"; diff --git a/apps/web/components/settings/system/update-channel-control.test.tsx b/apps/web/components/settings/system/update-channel-control.test.tsx new file mode 100644 index 0000000000..2144e0c7ba --- /dev/null +++ b/apps/web/components/settings/system/update-channel-control.test.tsx @@ -0,0 +1,105 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, expect, it, vi } from "vitest"; + +import type { UpdatesChannel, UpdatesResponse } from "@/lib/types/system"; +import { SettingsSaveProvider, useSettingsSaveContributor } from "../settings-save-provider"; +import { useUpdateChannelDraft } from "./update-channel-control"; + +type Deferred = { + promise: Promise; + resolve: () => void; +}; + +function deferred(): Deferred { + let resolve: () => void = () => undefined; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function BlockingContributor({ + pending, + onSaveStarted, +}: { + pending: Deferred; + onSaveStarted: () => void; +}) { + const [isDirty, setIsDirty] = useState(true); + useSettingsSaveContributor({ + id: "blocking-save", + order: 0, + revision: 1, + isDirty, + save: async () => { + onSaveStarted(); + await pending.promise; + setIsDirty(false); + }, + discard: () => setIsDirty(false), + }); + return null; +} + +function ChannelDraft({ + saveChannel, +}: { + saveChannel: (channel: UpdatesChannel) => Promise; +}) { + const { draft, isDirty, setDraft } = useUpdateChannelDraft( + { + current: "v1.0.0", + latest: "v1.0.1", + latest_url: "https://example.test/v1.0.1", + latest_checked_at: "2026-08-01T00:00:00Z", + update_available: true, + channel: "stable", + channel_editable: true, + channel_unsupported_reason: "", + install: { running_as_service: true, managed_service: true }, + apply_supported: true, + }, + saveChannel, + ); + return ( + <> + + + + {draft} + + + ); +} + +afterEach(cleanup); + +it("preserves an edit made before its queued contributor starts saving", async () => { + const pending = deferred(); + const onSaveStarted = vi.fn(); + const saveChannel = vi.fn().mockResolvedValue({ + channel: "nightly", + } as UpdatesResponse); + render( + + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Nightly" })); + fireEvent.click(await screen.findByRole("button", { name: "Save changes" })); + await waitFor(() => expect(onSaveStarted).toHaveBeenCalledOnce()); + expect(saveChannel).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Stable" })); + await act(async () => pending.resolve()); + await waitFor(() => expect(saveChannel).toHaveBeenCalledWith("nightly")); + + expect(screen.getByTestId("channel-draft").textContent).toBe("stable"); + expect(screen.getByTestId("channel-draft").getAttribute("data-dirty")).toBe("true"); +}); diff --git a/apps/web/components/settings/system/update-channel-control.tsx b/apps/web/components/settings/system/update-channel-control.tsx new file mode 100644 index 0000000000..c292a18611 --- /dev/null +++ b/apps/web/components/settings/system/update-channel-control.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useLayoutEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Label } from "@kandev/ui/label"; +import { RadioGroup, RadioGroupItem } from "@kandev/ui/radio-group"; +import type { UpdatesChannel, UpdatesResponse } from "@/lib/types/system"; +import { useSettingsSaveContributor } from "../settings-save-provider"; +import { updateChannelUnsupportedReasonKey } from "./update-channel-reasons"; + +export function useUpdateChannelDraft( + updates: UpdatesResponse | null | undefined, + saveChannel: (channel: UpdatesChannel) => Promise, +) { + const authoritative = updates?.channel ?? "stable"; + const editable = updates?.channel_editable === true; + const [saved, setSaved] = useState(authoritative); + const savedRef = useRef(saved); + const [draft, setDraftState] = useState(authoritative); + const draftRef = useRef(draft); + const [isSaving, setIsSaving] = useState(false); + // A newer edit can circle back to the old saved value, so value equality + // alone cannot decide whether an in-flight save response may rebase it. + const draftRevisionRef = useRef(0); + const pendingSaveRevisionRef = useRef(null); + draftRef.current = draft; + const isDirty = editable && draft !== saved; + + const setDraft = (channel: UpdatesChannel) => { + draftRevisionRef.current += 1; + draftRef.current = channel; + setDraftState(channel); + }; + + // The settings coordinator snapshots this contributor before it begins + // saving earlier contributors. Capture the edit token in that snapshot; the + // matching save callback closes over this render's draft value. + const contributorRevision = draftRevisionRef.current; + + useLayoutEffect(() => { + const previous = savedRef.current; + savedRef.current = authoritative; + setSaved(authoritative); + const pendingRevision = pendingSaveRevisionRef.current; + const hasNewerDraft = pendingRevision !== null && draftRevisionRef.current !== pendingRevision; + if (!editable || (draftRef.current === previous && !hasNewerDraft)) { + draftRef.current = authoritative; + setDraftState(authoritative); + } + }, [authoritative, editable]); + + useSettingsSaveContributor({ + id: "system-updates-channel", + order: 10, + revision: contributorRevision, + // Keep the navigation guard active for the full save. A user can edit the + // draft back to the previously saved value while the submitted channel is + // still in flight, but leaving then would abandon an unresolved mutation. + isDirty: isDirty || isSaving, + save: async (revision) => { + const submitted = draft; + const submittedRevision = revision as number; + pendingSaveRevisionRef.current = submittedRevision; + setIsSaving(true); + try { + const response = await saveChannel(submitted); + savedRef.current = response.channel; + setSaved(response.channel); + if (draftRevisionRef.current === submittedRevision) { + draftRef.current = response.channel; + setDraftState(response.channel); + } + } finally { + if (pendingSaveRevisionRef.current === submittedRevision) { + pendingSaveRevisionRef.current = null; + } + setIsSaving(false); + } + }, + discard: () => setDraft(savedRef.current), + }); + + return { + draft, + editable, + isDirty, + isSaving, + unsupportedReason: updates?.channel_unsupported_reason ?? "", + setDraft, + }; +} + +type UpdateChannelControlProps = ReturnType; + +export function UpdateChannelControl({ + draft, + editable, + isDirty, + unsupportedReason, + setDraft, +}: UpdateChannelControlProps) { + const { t } = useTranslation(); + const reasonId = "system-updates-channel-reason"; + const reason = unsupportedReason ? t(updateChannelUnsupportedReasonKey(unsupportedReason)) : ""; + return ( +
+
+
{t("settings:updateChannel")}
+

{t("settings:updateChannelDescription")}

+
+ { + if (editable && (value === "stable" || value === "nightly")) setDraft(value); + }} + className="gap-2" + data-settings-dirty={isDirty} + > + + + + {!editable && reason && ( +

+ {reason} +

+ )} +
+ ); +} + +function UpdateChannelOption({ + channel, + label, + description, + disabled, + reasonId, +}: { + channel: UpdatesChannel; + label: string; + description: string; + disabled: boolean; + reasonId?: string; +}) { + const inputId = `system-updates-channel-${channel}-input`; + const labelId = `system-updates-channel-${channel}-label`; + const descriptionId = `system-updates-channel-${channel}-description`; + const describedBy = reasonId ? `${descriptionId} ${reasonId}` : descriptionId; + return ( + + ); +} diff --git a/apps/web/components/settings/system/update-channel-reasons.test.tsx b/apps/web/components/settings/system/update-channel-reasons.test.tsx new file mode 100644 index 0000000000..247c5b7956 --- /dev/null +++ b/apps/web/components/settings/system/update-channel-reasons.test.tsx @@ -0,0 +1,36 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +import { activateLocale } from "@/lib/i18n"; +import { UpdateChannelControl } from "./update-channel-control"; + +describe("UpdateChannelControl reasons under the pseudo-locale", () => { + beforeAll(async () => activateLocale("pseudo")); + afterAll(async () => activateLocale("en")); + afterEach(cleanup); + + it.each([ + "Nightly updates require a Kandev-managed npm or npx user service.", + "Nightly updates require valid Kandev service metadata.", + "Nightly updates are only available for user services.", + "This service manager does not support nightly updates.", + "Nightly updates are only available for npm or npx installations.", + "Update channel settings are unavailable.", + "An unknown server capability reason.", + ])("localizes the server reason: %s", (unsupportedReason) => { + render( + , + ); + + const reason = screen.getByTestId("system-updates-channel-reason").textContent ?? ""; + expect(reason).not.toContain(unsupportedReason); + expect(reason).toMatch(/[À-ɏ]/); + }); +}); diff --git a/apps/web/components/settings/system/update-channel-reasons.ts b/apps/web/components/settings/system/update-channel-reasons.ts new file mode 100644 index 0000000000..8492f34f3f --- /dev/null +++ b/apps/web/components/settings/system/update-channel-reasons.ts @@ -0,0 +1,19 @@ +const FALLBACK_REASON_KEY = "settings:updateChannelUnavailable"; + +const REASON_KEYS: Readonly> = { + "Nightly updates require a Kandev-managed npm or npx user service.": + "settings:updateChannelRequiresManagedService", + "Nightly updates require valid Kandev service metadata.": + "settings:updateChannelRequiresServiceMetadata", + "Nightly updates are only available for user services.": + "settings:updateChannelRequiresUserService", + "This service manager does not support nightly updates.": + "settings:updateChannelUnsupportedServiceManager", + "Nightly updates are only available for npm or npx installations.": + "settings:updateChannelRequiresNpmInstallation", + "Update channel settings are unavailable.": "settings:updateChannelSettingsUnavailable", +}; + +export function updateChannelUnsupportedReasonKey(reason: string): string { + return REASON_KEYS[reason] ?? FALLBACK_REASON_KEY; +} diff --git a/apps/web/components/settings/system/updates-card.test.tsx b/apps/web/components/settings/system/updates-card.test.tsx index 33b176b799..308af97155 100644 --- a/apps/web/components/settings/system/updates-card.test.tsx +++ b/apps/web/components/settings/system/updates-card.test.tsx @@ -1,9 +1,12 @@ -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import type { ComponentProps } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { UpdatesResponse } from "@/lib/types/system"; import type { SelfUpdateController } from "@/hooks/domains/system/use-self-update"; import type { DesktopUpdaterController } from "@/hooks/domains/system/use-desktop-updater"; +import { requestNavigation } from "@/lib/routing/navigation-guard"; +import { SettingsSaveProvider } from "../settings-save-provider"; const mocks = vi.hoisted(() => ({ useUpdates: vi.fn(), @@ -32,6 +35,13 @@ vi.mock("@kandev/ui/spinner", () => ({ import { UpdatesCard } from "./updates-card"; const APPLY_TESTID = "system-updates-apply"; +const ARIA_CHECKED = "aria-checked"; +const CHECK_TESTID = "system-updates-check"; +const CHANNEL_TESTID = "system-updates-channel"; +const ERROR_TESTID = "system-updates-error"; +const LATEST_TESTID = "system-updates-latest"; +const SETTINGS_DIRTY_ATTRIBUTE = "data-settings-dirty"; +const SAVE_CHANGES_NAME = "Save changes"; function updates(overrides: Partial = {}): UpdatesResponse { return { @@ -40,6 +50,9 @@ function updates(overrides: Partial = {}): UpdatesResponse { latest_url: "https://example/v1.0.1", latest_checked_at: "2026-05-29T00:00:00.000Z", update_available: true, + channel: "stable", + channel_editable: true, + channel_unsupported_reason: "", install: { running_as_service: true, managed_service: true, @@ -52,6 +65,18 @@ function updates(overrides: Partial = {}): UpdatesResponse { }; } +function updatesCard(props: ComponentProps = {}) { + return ( + + + + ); +} + +function renderUpdatesCard(props: ComponentProps = {}) { + return render(updatesCard(props)); +} + function selfUpdate(overrides: Partial = {}): SelfUpdateController { return { phase: "idle", @@ -92,6 +117,21 @@ afterEach(() => { }); describe("UpdatesCard self-update", () => { + it("uses hook-owned checking state to hide Apply update", () => { + mocks.useUpdates.mockReturnValue({ + updates: updates(), + check: vi.fn(), + reload: vi.fn(), + saveChannel: vi.fn(), + isChecking: true, + error: null, + }); + + renderUpdatesCard(); + expect(screen.queryByTestId(APPLY_TESTID)).toBeNull(); + expect(screen.getByTestId(CHECK_TESTID).hasAttribute("disabled")).toBe(true); + }); + it("passes a stable document reload instead of the update-metadata refetch", () => { const reloadDocument = vi.fn(); const reloadMetadataFirst = vi.fn(); @@ -108,10 +148,10 @@ describe("UpdatesCard self-update", () => { reload: reloadMetadataSecond, }); - const { rerender } = render(); + const { rerender } = renderUpdatesCard({ reloadDocument }); const firstCompletion = mocks.useSelfUpdate.mock.calls.at(-1)?.[0]?.onComplete; - rerender(); + rerender(updatesCard({ reloadDocument })); const secondCompletion = mocks.useSelfUpdate.mock.calls.at(-1)?.[0]?.onComplete; expect(firstCompletion).toBe(reloadDocument); @@ -134,7 +174,7 @@ describe("UpdatesCard self-update", () => { reload: vi.fn(), }); - render(); + renderUpdatesCard(); expect(screen.queryByTestId(APPLY_TESTID)).toBeNull(); expect(screen.getByTestId("system-updates-manual").textContent).toContain( @@ -147,7 +187,7 @@ describe("UpdatesCard self-update", () => { mocks.useUpdates.mockReturnValue({ updates: updates(), check: vi.fn(), reload: vi.fn() }); mocks.useSelfUpdate.mockReturnValue(selfUpdate({ start })); - render(); + renderUpdatesCard(); fireEvent.click(screen.getByTestId(APPLY_TESTID)); fireEvent.click(await screen.findByTestId("system-updates-apply-confirm")); @@ -160,7 +200,7 @@ describe("UpdatesCard self-update", () => { selfUpdate({ phase: "restarting", targetVersion: "v1.0.1", isUpdating: true }), ); - render(); + renderUpdatesCard(); expect(screen.queryByTestId(APPLY_TESTID)).toBeNull(); const progress = screen.getByTestId("system-updates-progress"); @@ -174,7 +214,7 @@ describe("UpdatesCard self-update", () => { selfUpdate({ phase: "done", targetVersion: "v1.0.1", isUpdating: false }), ); - render(); + renderUpdatesCard(); expect(screen.queryByTestId(APPLY_TESTID)).toBeNull(); expect(screen.getByTestId("system-updates-progress").textContent).toContain( @@ -206,9 +246,9 @@ describe("UpdatesCard desktop package updates", () => { }), ); - render(); + renderUpdatesCard(); - expect(screen.getByTestId("system-updates-latest").textContent).toBe("1.1.0"); + expect(screen.getByTestId(LATEST_TESTID).textContent).toBe("1.1.0"); expect(screen.queryByTestId(APPLY_TESTID)).toBeNull(); expect(screen.getByTestId("system-updates-manual").textContent).toContain("package manager"); }); @@ -240,10 +280,10 @@ describe("UpdatesCard desktop updater", () => { }), ); - render(); + renderUpdatesCard(); expect(screen.getByTestId("system-updates-current").textContent).toBe("1.0.0"); - expect(screen.getByTestId("system-updates-latest").textContent).toBe("1.1.0"); + expect(screen.getByTestId(LATEST_TESTID).textContent).toBe("1.1.0"); expect(screen.getByTestId(APPLY_TESTID)).toBeTruthy(); expect(screen.queryByTestId("system-updates-manual")).toBeNull(); expect(screen.getByTestId("system-updates-actions").className).toContain("flex-col"); @@ -272,7 +312,7 @@ describe("UpdatesCard desktop updater", () => { }), ); - render(); + renderUpdatesCard(); fireEvent.click(screen.getByTestId(APPLY_TESTID)); expect(install).not.toHaveBeenCalled(); fireEvent.click(await screen.findByTestId("system-updates-apply-confirm")); @@ -303,12 +343,339 @@ describe("UpdatesCard desktop updater", () => { }), ); - render(); + renderUpdatesCard(); expect(screen.getByTestId("system-updates-progress").textContent).toContain("25 of 100 bytes"); - expect(screen.getByTestId("system-updates-error").textContent).toContain( - "Signature verification failed", - ); + expect(screen.getByTestId(ERROR_TESTID).textContent).toContain("Signature verification failed"); expect(screen.queryByTestId(APPLY_TESTID)).toBeNull(); }); }); + +describe("UpdatesCard channel setting", () => { + it("keeps Nightly local until the shared settings action saves it", async () => { + const saveChannel = vi.fn().mockResolvedValue( + updates({ + channel: "nightly", + latest: "1.0.1-nightly.shaabcdef123456", + latest_url: "https://www.npmjs.com/package/kandev/v/1.0.1-nightly.shaabcdef123456", + }), + ); + mocks.useUpdates.mockReturnValue({ + updates: updates(), + check: vi.fn(), + reload: vi.fn(), + saveChannel, + error: null, + }); + + renderUpdatesCard(); + const stable = screen.getByRole("radio", { name: /^Stable/ }); + const nightly = screen.getByRole("radio", { name: /^Nightly/ }); + + expect(stable.getAttribute(ARIA_CHECKED)).toBe("true"); + expect(nightly.getAttribute(ARIA_CHECKED)).toBe("false"); + fireEvent.click(nightly); + + expect(nightly.getAttribute(ARIA_CHECKED)).toBe("true"); + expect(saveChannel).not.toHaveBeenCalled(); + expect(screen.getByTestId(CHANNEL_TESTID).getAttribute(SETTINGS_DIRTY_ATTRIBUTE)).toBe("true"); + expect(screen.getByTestId(LATEST_TESTID).textContent).toBe("-"); + expect(screen.getByTestId("system-updates-checked-at").textContent).toBe("Last checked never"); + expect(screen.getByTestId("system-updates-channel-nightly").textContent).toContain( + "Install-wide", + ); + expect(screen.getByTestId("system-updates-channel-nightly").textContent).toContain( + "exact version shown", + ); + expect(screen.getByTestId(CHECK_TESTID).hasAttribute("disabled")).toBe(true); + expect(screen.queryByTestId(APPLY_TESTID)).toBeNull(); + expect(screen.queryByTestId("system-updates-release-link")).toBeNull(); + expect(screen.getByTestId("system-updates-channel-pending").textContent).toContain( + "Save channel changes", + ); + + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_NAME })); + + await waitFor(() => expect(saveChannel).toHaveBeenCalledWith("nightly")); + await waitFor(() => + expect(screen.getByTestId(CHANNEL_TESTID).getAttribute(SETTINGS_DIRTY_ATTRIBUTE)).toBe( + "false", + ), + ); + }); +}); + +describe("UpdatesCard channel save errors", () => { + it("clears a previous check error after a channel save succeeds", async () => { + let updatesError: string | null = null; + const check = vi.fn(async () => { + updatesError = "npm registry unavailable"; + throw new Error(updatesError); + }); + const saveChannel = vi.fn(async () => { + updatesError = null; + return updates({ channel: "nightly" }); + }); + mocks.useUpdates.mockImplementation(() => ({ + updates: updates(), + check, + reload: vi.fn(), + saveChannel, + isChecking: false, + error: updatesError, + })); + + const { rerender } = renderUpdatesCard(); + fireEvent.click(screen.getByTestId(CHECK_TESTID)); + await waitFor(() => expect(check).toHaveBeenCalledOnce()); + rerender(updatesCard()); + expect(screen.getByTestId(ERROR_TESTID).textContent).toContain("npm registry unavailable"); + + fireEvent.click(screen.getByRole("radio", { name: /^Nightly/ })); + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_NAME })); + + await waitFor(() => expect(saveChannel).toHaveBeenCalledWith("nightly")); + rerender(updatesCard()); + expect(screen.queryByTestId(ERROR_TESTID)).toBeNull(); + }); + + it("keeps a failed channel save dirty and retryable", async () => { + const saveChannel = vi + .fn() + .mockRejectedValueOnce(new Error("npm registry unavailable")) + .mockResolvedValueOnce(updates({ channel: "nightly" })); + mocks.useUpdates.mockReturnValue({ + updates: updates(), + check: vi.fn(), + reload: vi.fn(), + saveChannel, + error: null, + }); + + renderUpdatesCard(); + const nightly = screen.getByRole("radio", { name: /^Nightly/ }); + fireEvent.click(nightly); + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_NAME })); + + await waitFor(() => expect(screen.getByText("Couldn't save")).toBeTruthy()); + expect(nightly.getAttribute(ARIA_CHECKED)).toBe("true"); + expect(screen.getByTestId(CHANNEL_TESTID).getAttribute(SETTINGS_DIRTY_ATTRIBUTE)).toBe("true"); + + fireEvent.click(screen.getByRole("button", { name: "Retry save" })); + + await waitFor(() => expect(saveChannel).toHaveBeenCalledTimes(2)); + await waitFor(() => + expect(screen.getByTestId(CHANNEL_TESTID).getAttribute(SETTINGS_DIRTY_ATTRIBUTE)).toBe( + "false", + ), + ); + }); + + it("replaces a previous check error with the channel save failure", async () => { + let updatesError: string | null = null; + const check = vi.fn(async () => { + updatesError = "retry after 27 seconds"; + throw new Error(updatesError); + }); + const saveChannel = vi.fn(async () => { + updatesError = "channel save failed"; + throw new Error(updatesError); + }); + mocks.useUpdates.mockImplementation(() => ({ + updates: updates(), + check, + reload: vi.fn(), + saveChannel, + isChecking: false, + error: updatesError, + })); + + const { rerender } = renderUpdatesCard(); + fireEvent.click(screen.getByTestId(CHECK_TESTID)); + await waitFor(() => expect(check).toHaveBeenCalledOnce()); + rerender(updatesCard()); + expect(screen.getByTestId(ERROR_TESTID).textContent).toContain("27s"); + + fireEvent.click(screen.getByRole("radio", { name: /^Nightly/ })); + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_NAME })); + + await waitFor(() => expect(screen.getByText("Couldn't save")).toBeTruthy()); + expect(screen.getByTestId(ERROR_TESTID).textContent).toContain("channel save failed"); + expect(screen.getByTestId(ERROR_TESTID).textContent).not.toContain("27s"); + }); +}); + +describe("UpdatesCard concurrent channel edits", () => { + it("keeps update actions blocked when the draft reverts during a save", async () => { + let resolveSave!: (value: UpdatesResponse) => void; + const saveChannel = vi.fn( + () => + new Promise((resolve) => { + resolveSave = resolve; + }), + ); + mocks.useUpdates.mockReturnValue({ + updates: updates(), + check: vi.fn(), + reload: vi.fn(), + saveChannel, + error: null, + }); + + renderUpdatesCard(); + const stable = screen.getByRole("radio", { name: /^Stable/ }); + fireEvent.click(screen.getByRole("radio", { name: /^Nightly/ })); + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_NAME })); + await waitFor(() => expect(saveChannel).toHaveBeenCalledWith("nightly")); + + fireEvent.click(stable); + + expect(screen.getByTestId(CHANNEL_TESTID).getAttribute(SETTINGS_DIRTY_ATTRIBUTE)).toBe("false"); + expect(screen.getByTestId("system-updates-card").getAttribute(SETTINGS_DIRTY_ATTRIBUTE)).toBe( + "true", + ); + expect(screen.getByTestId(CHECK_TESTID).hasAttribute("disabled")).toBe(true); + expect(screen.queryByTestId(APPLY_TESTID)).toBeNull(); + expect(screen.getByTestId("system-updates-channel-pending").textContent).toContain("Saving"); + + const proceed = vi.fn(); + act(() => requestNavigation(proceed)); + expect(proceed).not.toHaveBeenCalled(); + expect(await screen.findByRole("alertdialog")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Discard and leave" }).hasAttribute("disabled")).toBe( + true, + ); + + await act(async () => { + resolveSave(updates({ channel: "nightly" })); + }); + await waitFor(() => + expect(screen.getByTestId(CHANNEL_TESTID).getAttribute(SETTINGS_DIRTY_ATTRIBUTE)).toBe( + "true", + ), + ); + expect(proceed).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Continue editing" })); + }); + + it("preserves a newer channel choice while an earlier save is pending", async () => { + let resolveSave!: (value: UpdatesResponse) => void; + const saveChannel = vi.fn( + () => + new Promise((resolve) => { + resolveSave = resolve; + }), + ); + let authoritative = updates(); + mocks.useUpdates.mockImplementation(() => ({ + updates: authoritative, + check: vi.fn(), + reload: vi.fn(), + saveChannel, + error: null, + })); + + const { rerender } = renderUpdatesCard(); + const stable = screen.getByRole("radio", { name: /^Stable/ }); + const nightly = screen.getByRole("radio", { name: /^Nightly/ }); + fireEvent.click(nightly); + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_NAME })); + await waitFor(() => expect(saveChannel).toHaveBeenCalledWith("nightly")); + + fireEvent.click(stable); + authoritative = updates({ channel: "nightly" }); + rerender(updatesCard()); + expect(stable.getAttribute(ARIA_CHECKED)).toBe("true"); + + await act(async () => { + resolveSave(authoritative); + }); + + expect(stable.getAttribute(ARIA_CHECKED)).toBe("true"); + expect(screen.getByTestId(CHANNEL_TESTID).getAttribute(SETTINGS_DIRTY_ATTRIBUTE)).toBe("true"); + }); +}); + +describe("UpdatesCard channel navigation", () => { + it("discards an unsaved Nightly choice through the shared navigation guard", async () => { + const saveChannel = vi.fn(); + const proceed = vi.fn(); + mocks.useUpdates.mockReturnValue({ + updates: updates(), + check: vi.fn(), + reload: vi.fn(), + saveChannel, + error: null, + }); + + renderUpdatesCard(); + const stable = screen.getByRole("radio", { name: /^Stable/ }); + const nightly = screen.getByRole("radio", { name: /^Nightly/ }); + fireEvent.click(nightly); + await screen.findByRole("button", { name: SAVE_CHANGES_NAME }); + + act(() => requestNavigation(proceed)); + fireEvent.click(await screen.findByRole("button", { name: "Discard and leave" })); + + await waitFor(() => expect(stable.getAttribute(ARIA_CHECKED)).toBe("true")); + expect(saveChannel).not.toHaveBeenCalled(); + expect(proceed).toHaveBeenCalledOnce(); + }); +}); + +describe("UpdatesCard channel availability", () => { + it("disables Nightly and renders the server capability reason", () => { + mocks.useUpdates.mockReturnValue({ + updates: updates({ + channel_editable: false, + channel_unsupported_reason: + "Nightly updates require a Kandev-managed npm or npx user service.", + }), + check: vi.fn(), + reload: vi.fn(), + saveChannel: vi.fn(), + error: null, + }); + + renderUpdatesCard(); + + const stable = screen.getByRole("radio", { name: /^Stable/ }); + const nightly = screen.getByRole("radio", { name: /^Nightly/ }); + const reasonId = screen.getByTestId("system-updates-channel-reason").id; + expect(stable.getAttribute(ARIA_CHECKED)).toBe("true"); + expect(stable.hasAttribute("disabled")).toBe(true); + expect(nightly.hasAttribute("disabled")).toBe(true); + expect(stable.getAttribute("aria-describedby")).toContain(reasonId); + expect(nightly.getAttribute("aria-describedby")).toContain(reasonId); + expect(screen.getByTestId("system-updates-channel-reason").textContent).toContain( + "Kandev-managed npm or npx user service", + ); + }); + + it("does not expose an npm channel selector in the Desktop updater", () => { + mocks.useDesktopUpdater.mockReturnValue( + desktopUpdater({ + available: true, + state: { + phase: "up-to-date", + currentVersion: "1.0.0", + latestVersion: "1.0.0", + releaseNotes: null, + releaseUrl: null, + checkedAtEpochMs: 42, + downloadedBytes: null, + totalBytes: null, + installSupported: true, + installUnsupportedReason: null, + error: null, + }, + }), + ); + + renderUpdatesCard(); + + expect(screen.queryByTestId(CHANNEL_TESTID)).toBeNull(); + expect(mocks.useUpdates).not.toHaveBeenCalled(); + expect(mocks.useSelfUpdate).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/settings/system/updates-card.tsx b/apps/web/components/settings/system/updates-card.tsx index c33fa7e14b..c7a9e9450c 100644 --- a/apps/web/components/settings/system/updates-card.tsx +++ b/apps/web/components/settings/system/updates-card.tsx @@ -1,6 +1,5 @@ "use client"; -import { useState } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { @@ -27,7 +26,9 @@ import { import { useUpdates } from "@/hooks/domains/system/use-updates"; import { formatDateTime } from "@/lib/i18n/formats"; import type { UpdatesResponse } from "@/lib/types/system"; +import { SettingsCard } from "../settings-card"; import { SelfUpdateProgress } from "./self-update-progress"; +import { UpdateChannelControl, useUpdateChannelDraft } from "./update-channel-control"; interface ApplyGate { canApply: boolean; @@ -50,10 +51,6 @@ function formatChecked(value: string | number | null | undefined, t: TFunction): return formatDateTime(d); } -function errorMessage(err: unknown, fallback: string): string { - return err instanceof Error ? err.message : fallback; -} - function retryAfterSeconds(message: string): number | null { const match = /retry.*?(\d+)/i.exec(message); return match ? Number(match[1]) : null; @@ -91,54 +88,61 @@ function serviceCardView( }; } -export function UpdatesCard({ reloadDocument = reloadCurrentDocument }: UpdatesCardProps = {}) { +function ChannelPendingNotice({ pending, saving }: { pending: boolean; saving: boolean }) { const { t } = useTranslation(); - const { updates, check } = useUpdates(); - const selfUpdate = useSelfUpdate({ latestVersion: updates?.latest, onComplete: reloadDocument }); - const desktopUpdater = useDesktopUpdater(); - const [checking, setChecking] = useState(false); - const [error, setError] = useState(null); - const [retryAfter, setRetryAfter] = useState(null); + if (!pending) return null; + return ( +

+ {saving ? t("settings:updateChannelSavingNotice") : t("settings:updateChannelUnsavedNotice")} +

+ ); +} +export function UpdatesCard({ reloadDocument = reloadCurrentDocument }: UpdatesCardProps = {}) { + const desktopUpdater = useDesktopUpdater(); if (desktopUpdater.available) { return ; } + return ; +} - const onCheck = async () => { - setChecking(true); - setError(null); - setRetryAfter(null); - try { - await check(); - } catch (err) { - const message = errorMessage(err, t("system:updatesCheckFailed")); - setError(message); - setRetryAfter(retryAfterSeconds(message)); - } finally { - setChecking(false); - } - }; - +function ServiceUpdatesCard({ reloadDocument }: { reloadDocument: () => void }) { + const { t } = useTranslation(); + const { updates, check, saveChannel, isChecking, error } = useUpdates(); + const selfUpdate = useSelfUpdate({ latestVersion: updates?.latest, onComplete: reloadDocument }); + const channel = useUpdateChannelDraft(updates, saveChannel); const view = serviceCardView(updates, selfUpdate); + const channelPending = channel.isDirty || channel.isSaving; return ( - + - + - - + + + + ignoreFailure(check())} onApply={selfUpdate.start} /> @@ -148,9 +152,9 @@ export function UpdatesCard({ reloadDocument = reloadCurrentDocument }: UpdatesC errorMessage={selfUpdate.errorMessage} onDismiss={selfUpdate.dismiss} /> - + - + ); } @@ -222,7 +226,7 @@ function DesktopCurrentStatus({ phase }: { phase: string | undefined }) { ); } -async function ignoreFailure(operation: Promise): Promise { +async function ignoreFailure(operation: Promise): Promise { await operation.catch(() => undefined); } @@ -268,17 +272,28 @@ function UpdatesHeader({ available }: { available: boolean }) { ); } -function VersionGrid({ current, latest }: { current: string; latest: string }) { +function VersionGrid({ + current, + latest, + latestLabel, +}: { + current: string; + latest: string; + latestLabel?: string; +}) { const { t } = useTranslation(); return ( -
+
@@ -288,9 +303,9 @@ function VersionGrid({ current, latest }: { current: string; latest: string }) { function VersionValue({ label, value, testId }: { label: string; value: string; testId: string }) { return ( -
+
{label}
-
+
{value}
@@ -308,6 +323,7 @@ function LastChecked({ checkedAt }: { checkedAt?: string | number | null }) { interface UpdateActionsProps { checking: boolean; + disabled?: boolean; showApply: boolean; latest: string; url?: string; @@ -322,7 +338,7 @@ function UpdateActions(props: UpdateActionsProps) { className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center" data-testid="system-updates-actions" > - + Promise; }) { const { t } = useTranslation(); @@ -346,7 +364,7 @@ function CheckNowButton({