Skip to content

Add an opt-in beta npm release channel (version-skew Phase 1) - #267

Merged
alexeyzimarev merged 15 commits into
mainfrom
docs/cli-server-version-skew-design
Jul 4, 2026
Merged

alexeyzimarev merged 15 commits into
mainfrom
docs/cli-server-version-skew-design

Conversation

@alexeyzimarev

@alexeyzimarev alexeyzimarev commented Jul 3, 2026 •

Copy link
Copy Markdown
Member

What & why

The kcap CLI and the Capacitor server share a consolidated version bump but ship through different channels: the server rolls out per tenant tier (internal → stable/micro) while the CLI publishes to npm's single latest dist-tag on every release. So an internal-first release drags stable/micro users onto a CLI newer than their server — a hard failure for daemon features, MCP tools, and agent-invoked commands (core recording stays backward-compatible).

This is Phase 1 of the version-skew design: an opt-in beta npm release channel so internal-first releases go to a beta dist-tag and latest stays matched to the stable/micro cohort. It's a noise reducer, not the correctness mechanism — that's Phase 2 (server-version guards), tracked separately.

Closes #266
Closes #268
AI-1177

What's in it

  • Prerelease-aware SemVer 2.0 comparator (PrereleaseSemver) — orders beta.1 < beta.2 < release, which the existing SemverCompare (strips prerelease) can't.
  • Release workflow — prerelease git tags publish every package with npm publish --tag beta, leaving latest untouched; scripts/npm-dist-tag.sh derives the tag; RELEASING.md documents the invariant (internal-first → prerelease; latest only once stable/micro can serve it).
  • update_channel on the v2 Profile (default latest).
  • Channel-aware kcap update --beta/--stable — persists the choice onto the resolved active profile, queries the matching dist-tag, per-channel cache, prerelease-aware comparison, and exposes install_tag in the --check JSON.
  • Launcher forwards --beta/--stable to its probe and installs the resolved dist-tag (instead of hardcoded @latest).
  • Docs — README (Getting started + kcap update) and help-update.txt.

Default behavior is unchanged: no opt-in ⇒ latest.

Also in this PR: retire the live v1 config read path (Closes #268)

Working on the channel setting surfaced that kcap update's update_check gate (and ImportCommand) read the dead legacy flat config via AppConfig.Load(), which returns all-default values for v2-profile users. Rather than patch one caller, this retires the whole live v1 read path:

  • Deleted AppConfig.Load(); moved UpdateCommand and ImportCommand onto the v2 profile (GetActiveProfileAsync()).
  • Demoted CapacitorConfig → LegacyV1Config, now a migration-only DTO used solely by ConfigMigration; removed the unused v1 indented JSON context.
  • Fixed an STJ source-gen quirk where a v1 config lacking update_check migrated to false instead of the v1 default true.
  • Kept the v1→v2 migration as the straggler safety net (removing it would silently reset a v1 config on disk).

Notable during review

  • update_channel was moved from the legacy v1 CapacitorConfig to the v2 Profile — the legacy record is dead for real (v2-profile) users and persisting it would corrupt the config.
  • Two cross-task bugs caught by the whole-branch review and fixed: the launcher not forwarding the channel flag (feature was a no-op for npm installs), and persistence targeting the wrong profile for multi-profile / KCAP_PROFILE / repo-bound users.

Testing

  • 2171 unit tests pass; new coverage: PrereleaseSemver precedence, update_channel round-trip, ResolveChannel, v1→v2 ConfigMigration (incl. the update_check absent/false/true trio), a WireMock integration test for the channel dist-tag query, and the launcher resolveInstallSpec/probeArgs (node).
  • AOT publish clean (no IL3050/IL2026).

Follow-ups (not in this PR)

  • Polish: a couple of SemVer edge cases (unreachable from our X.Y.Z-beta.N scheme), a --check+--beta doc note, and a binary↔launcher install_tag contract test.
  • Phases 2–4 of the design (the actual server-version safety mechanism).

🤖 Generated with Claude Code

alexeyzimarev and others added 12 commits July 3, 2026 15:32
Design for gracefully handling a CLI newer than its server (npm `latest`
outruns per-tier server rollout). Recommends per-endpoint version-compare
guards fed by a shared last-seen-version cache, with a beta dist-tag channel
and a deferred capability-endpoint path. Refined through a spec-review flow
(6 rounds to sign-off).

Reviewed-via: kcap review flow (spec-review), signed off clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…file

Task 3 surfaced that the plan targeted the legacy v1 CapacitorConfig; the live
config is v2 profile-based (AppConfig.Load/CapacitorConfig is dead for real
users, and persisting a flat CapacitorConfig would corrupt the v2 config).
Move update_channel to Profile (next to update_check), read via
GetActiveProfileAsync, persist via SaveProfileConfig.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…npm tarball; fix stale cache comment

Persist --beta/--stable to the profile GetActiveProfileAsync() actually
read (ResolvedProfile?.ProfileName ?? active_profile), not blindly to
active_profile, so the switch sticks for the profile the user is on
(KCAP_PROFILE, .kcap.json, git-remote match, or `kcap use` binding).
Excludes bin/kcap.test.js from the npm tarball via a nested bin/.npmignore
(a root-level npmignore doesn't work against directory globs in "files").
Corrects a stale comment referencing the old non-channel cache filename.
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Add opt-in beta npm release channel for kcap CLI updates (Phase 1)

✨ Enhancement ⚙️ Configuration changes 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Publish prerelease git tags to npm beta dist-tag while keeping latest stable.
• Add per-profile update_channel and kcap update --beta/--stable selection + persistence.
• Make launcher and update checks channel-aware with prerelease SemVer comparison and tests.
Diagram

graph TD
  R["GitHub tag (vX.Y.Z[-beta.N])"] --> S["npm-dist-tag.sh"] --> P["npm publish --tag <tag>"] --> N["npm registry"]
  U["User"] --> L["Node launcher"] --> C["UpdateCommand"] --> N --> J["--check JSON (install_tag)"] --> L --> I["npm install -g @kurrent/kcap@<tag>"]
  C --> F[("Profile config")] --> C
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Publish always to `beta`, promote via `npm dist-tag add`
  • ➕ Avoids republishing artifacts when moving from internal-only to GA
  • ➕ Makes promotion an explicit, auditable step independent of build pipeline
  • ➖ Adds an extra release step that can be forgotten or misapplied
  • ➖ Requires additional permissions/guardrails for dist-tag mutation
2. Separate package name for prereleases (e.g. @kurrent/kcap-beta)
  • ➕ Completely isolates cohorts; no dist-tag semantics to understand
  • ➕ Avoids accidental @latest installs picking up prerelease
  • ➖ Splits install/update docs and increases maintenance overhead
  • ➖ Doesn’t integrate as cleanly with existing kcap update/launcher expectations

Recommendation: Keep the PR’s approach: dist-tags (beta vs latest) are the least disruptive way to split cohorts while preserving a single package name and defaulting safely. The added guardrails (release.yml deriving the tag, per-profile persistence, and launcher forwarding/installing install_tag) close the main foot-guns that would otherwise make the feature a no-op or mis-persisted for multi-profile users.

Files changed (19) +1627 / -86

Enhancement (3) +245 / -71
kcap.jsMake launcher channel-aware and forward beta/stable flags +84/-56

Make launcher channel-aware and forward beta/stable flags

• Adds 'resolveInstallSpec()' to install '@kurrent/kcap@<install_tag>' from the native probe instead of hardcoding '@latest'. Adds 'probeArgs()' to forward only '--beta/--stable' to the 'kcap update --check' probe, and guards execution with 'require.main === module' so the file can be required by tests.

npm/kcap/bin/kcap.js

PrereleaseSemver.csAdd SemVer 2.0 prerelease-aware comparator +92/-0

Add SemVer 2.0 prerelease-aware comparator

• Introduces 'PrereleaseSemver' to compare SemVer precedence including prerelease identifiers and numeric ordering, while ignoring build metadata. Treats null/unknown/unparseable values as lowest and ensures 'IsNewer' returns false when either side can’t be parsed.

src/Capacitor.Cli.Core/PrereleaseSemver.cs

UpdateCommand.csResolve/persist update channel and query matching npm dist-tag +69/-15

Resolve/persist update channel and query matching npm dist-tag

• Adds 'ResolveChannel()' and a per-channel cache ('update-check-<channel>.json') so beta/latest checks don’t clobber each other. Makes registry base URL overridable for integration tests, switches version comparison to 'PrereleaseSemver', persists explicit channel switches onto the resolved active profile, and extends '--check' JSON with 'channel' and 'install_tag' for the launcher.

src/Capacitor.Cli/Commands/UpdateCommand.cs

Tests (6) +193 / -0
kcap.test.jsAdd unit tests for launcher install tag + probe args +16/-0

Add unit tests for launcher install tag + probe args

• Adds Node assertions verifying 'resolveInstallSpec()' defaults and tag handling, plus 'probeArgs()' forwarding behavior for '--beta/--stable' only. Intended to run as a simple 'node' test script.

npm/kcap/bin/kcap.test.js

npm-dist-tag.test.shAdd bash test for npm dist-tag derivation +16/-0

Add bash test for npm dist-tag derivation

• Adds a small bash test script exercising stable, prerelease, and build-metadata cases to validate 'npm-dist-tag.sh' behavior.

scripts/npm-dist-tag.test.sh

UpdateChannelQueryTests.csAdd WireMock integration test for channel dist-tag queries +57/-0

Add WireMock integration test for channel dist-tag queries

• Adds an end-to-end test that overrides 'UpdateCommand.RegistryBaseUrl' to a WireMock server and asserts that 'latest' and 'beta' channels hit the correct endpoints and return the expected versions. Serializes tests to avoid races on the shared static registry seam.

test/Capacitor.Cli.Tests.Integration/UpdateChannelQueryTests.cs

PrereleaseSemverTests.csAdd unit tests for prerelease SemVer ordering +48/-0

Add unit tests for prerelease SemVer ordering

• Validates stable ordering, prerelease vs release precedence, numeric identifier ordering, build-metadata ignoring, and safe behavior for unknown/unparseable inputs.

test/Capacitor.Cli.Tests.Unit/PrereleaseSemverTests.cs

UpdateChannelConfigTests.csTest profile update_channel default and JSON round-trip +32/-0

Test profile update_channel default and JSON round-trip

• Asserts 'Profile.UpdateChannel' defaults to 'latest' on construction and that 'update_channel' round-trips through the profile config source-generated STJ context.

test/Capacitor.Cli.Tests.Unit/UpdateChannelConfigTests.cs

UpdateChannelResolveTests.csTest UpdateCommand channel resolution precedence +24/-0

Test UpdateCommand channel resolution precedence

• Adds unit tests ensuring '--beta/--stable' override config, config is honored when set, and the default falls back to 'latest' when unset.

test/Capacitor.Cli.Tests.Unit/UpdateChannelResolveTests.cs

Documentation (6) +1172 / -13
README.mdDocument beta update channel and tag-based installs +16/-1

Document beta update channel and tag-based installs

• Adds guidance for internal-tenant testers to opt into prereleases using 'kcap update --beta'. Updates 'kcap update' docs to describe installing '@kurrent/kcap@<tag>' and explains per-profile persistence and how to switch back with '--stable'.

README.md

RELEASING.mdAdd release invariant for beta vs latest publishing +18/-0

Add release invariant for beta vs latest publishing

• Introduces a concise releasing guide documenting the mapping from 'vX.Y.Z-beta.N' → npm 'beta' and 'vX.Y.Z' → npm 'latest'. Explicitly states the invariant to avoid CLI/server skew by only cutting GA tags once stable/micro cohorts can serve the matching server version.

RELEASING.md

2026-07-03-beta-release-channel.mdAdd Phase 1 implementation plan for beta dist-tag channel +770/-0

Add Phase 1 implementation plan for beta dist-tag channel

• Adds a detailed task plan covering prerelease SemVer comparison, release workflow dist-tag derivation, profile 'update_channel', channel-aware update behavior, launcher integration, and documentation updates. Serves as execution guidance for agentic development and review traceability.

docs/superpowers/plans/2026-07-03-beta-release-channel.md

2026-07-03-cli-server-version-skew-design.mdAdd version-skew design spec (Phase 1–4) +347/-0

Add version-skew design spec (Phase 1–4)

• Adds the design document explaining the CLI/server version-skew problem, phased approach, and why the beta channel is a noise reducer rather than the correctness mechanism. Documents assumptions, risks, and follow-on phases (cache + proactive guards).

docs/superpowers/specs/2026-07-03-cli-server-version-skew-design.md

help-update.txtUpdate kcap update help for beta/stable channel switching +18/-9

Update kcap update help for beta/stable channel switching

• Updates usage and option descriptions to include '--beta' and '--stable'. Explains per-profile persistence and clarifies that beta is intended for internal-tenant-first server rollouts.

src/Capacitor.Cli.Core/Resources/help-update.txt

Program.csClarify uninstall race avoidance for per-channel update cache +3/-3

Clarify uninstall race avoidance for per-channel update cache

• Updates the comment describing why the background update hint is skipped for 'uninstall', reflecting the new per-channel cache filename pattern.

src/Capacitor.Cli/Program.cs

Other (4) +17 / -2
release.ymlPublish npm artifacts with derived dist-tag (beta vs latest) +6/-2

Publish npm artifacts with derived dist-tag (beta vs latest)

• Adds a step to derive the npm dist-tag from the git tag’s version via 'scripts/npm-dist-tag.sh'. Updates both per-platform package publishing and wrapper publishing to use 'npm publish --tag <derived>' so prereleases land on 'beta' and releases on 'latest'.

.github/workflows/release.yml

.npmignoreExclude Node launcher tests from published package +1/-0

Exclude Node launcher tests from published package

• Adds a '.npmignore' rule to prevent '*.test.js' files in the launcher bin directory from being shipped in the npm package.

npm/kcap/bin/.npmignore

npm-dist-tag.shAdd script to map version → npm dist-tag +7/-0

Add script to map version → npm dist-tag

• Implements 'npm-dist-tag.sh' which outputs 'beta' for SemVer prereleases (hyphen in core, ignoring build metadata) and 'latest' otherwise. Used by the GitHub Actions release workflow.

scripts/npm-dist-tag.sh

ProfileConfig.csAdd per-profile update_channel field (default latest) +3/-0

Add per-profile update_channel field (default latest)

• Extends the v2 'Profile' record with 'update_channel' to persist an opt-in release channel per profile. Keeps default construction as 'latest' while acknowledging source-generated STJ may deserialize absent properties as null (handled at call sites).

src/Capacitor.Cli.Core/Config/ProfileConfig.cs

@qodo-code-review

qodo-code-review Bot commented Jul 3, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 📜 Skill insights (0)

Context used

Grey Divider


Action required

1. Channel path traversal ✓ Resolved 🐞 Bug ⛨ Security
Description
ResolveChannel can return Profile.UpdateChannel verbatim, and that string is used to build the
update-check cache path and the registry URL. A malformed update_channel (e.g. containing ../ or
an absolute path) can escape the config directory and be overwritten by the update-check cache (or
crash update checks due to invalid filenames).
Code

src/Capacitor.Cli/Commands/UpdateCommand.cs[R136-145]

+    /// <summary>
+    /// Per-channel cache path so a <c>beta</c> check doesn't clobber the
+    /// cached <c>latest</c> result (and vice versa).
+    /// </summary>
+    static string CachePathFor(string channel) =>
+        PathHelpers.ConfigPath($"update-check-{channel}.json");
+
+    internal static async Task<(string? latest, string? current)> CheckForUpdateAsync(bool forceCheck, string channel) {
+        var current   = GetCurrentVersion();
+        var cachePath = CachePathFor(channel);
Evidence
The code returns a potentially untrusted configuredChannel directly, then interpolates it into a
cache filename passed to PathHelpers.ConfigPath, which is a simple Path.Combine with no
sanitization; the resulting path is then written to disk via a temp file + move.

src/Capacitor.Cli/Commands/UpdateCommand.cs[22-26]
src/Capacitor.Cli/Commands/UpdateCommand.cs[28-32]
src/Capacitor.Cli/Commands/UpdateCommand.cs[136-145]
src/Capacitor.Cli/Commands/UpdateCommand.cs[172-193]
src/Capacitor.Cli.Core/PathHelpers.cs[13-16]
src/Capacitor.Cli.Core/Config/ProfileConfig.cs[74-82]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`UpdateCommand` uses the resolved `channel` to construct both:
1) a cache file path (`update-check-{channel}.json`), and
2) the registry URL path segment (`.../@kurrent/kcap/{channel}`).

Because `ResolveChannel` returns `configuredChannel` as-is, a corrupted/malicious `update_channel` value can:
- escape `~/.config/kcap` (Path.Combine accepts `..` segments and rooted paths), and
- cause arbitrary user-writable file clobbering when the cache is written.

## Issue Context
- `Profile.UpdateChannel` is deserialized from disk and is not validated.
- `PathHelpers.ConfigPath(name)` is a raw `Path.Combine(ConfigDir, name)`.

## Fix Focus Areas
- src/Capacitor.Cli/Commands/UpdateCommand.cs[15-26]
- src/Capacitor.Cli/Commands/UpdateCommand.cs[136-145]
- src/Capacitor.Cli/Commands/UpdateCommand.cs[172-193]
- src/Capacitor.Cli.Core/PathHelpers.cs[13-16]

### Suggested implementation approach
- Enforce an allowlist at the resolution boundary (e.g. only `"latest"` and `"beta"` for Phase 1):
 - normalize to lowercase;
 - if not recognized, fall back to `"latest"` (optionally emit a warning once).
- Use the validated value everywhere (cache path, URL path, `install_tag`).
- (Optional hardening) If you intend to support arbitrary dist-tags later, add a strict regex/character allowlist and reject `Path.IsPathRooted`, path separators, and `..` before using it in `ConfigPath` or URLs.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Hardcoded latest install hint 🐞 Bug ≡ Correctness
Description
When UpdateCommand.HandleAsync runs without the npm launcher (platform binary invoked directly),
it still prints a direct install command with @latest even if the resolved channel is beta.
Users following the CLI output will install the wrong channel and lose their beta opt-in behavior.
Code

↗ src/Capacitor.Cli/Commands/UpdateCommand.cs

        if (config?.UpdateCheck == false) return;
Evidence
The code computes channel from args/profile, but the printed direct-install command is still a
constant @latest, which diverges from the newly introduced channel-aware behavior.

src/Capacitor.Cli/Commands/UpdateCommand.cs[28-32]
src/Capacitor.Cli/Commands/UpdateCommand.cs[100-107]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`HandleAsync` resolves `channel` early, but the fallback instructions for non-launcher invocation still hardcode `npm install -g @kurrent/kcap@latest`.

## Issue Context
This message is shown specifically when the native binary is invoked directly (not via the Node launcher). With the new beta channel, the correct dist-tag depends on the resolved `channel`.

## Fix Focus Areas
- src/Capacitor.Cli/Commands/UpdateCommand.cs[28-32]
- src/Capacitor.Cli/Commands/UpdateCommand.cs[100-107]

### Suggested implementation approach
- Replace the hardcoded `@latest` with `@{channel}` (or use the `install_tag` string you already emit in `--check`).
- Consider also adjusting the preceding text so it’s clear that the tag is the active channel’s dist-tag.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

alexeyzimarev and others added 3 commits July 4, 2026 09:48
…eck/import settings from the v2 profile

Deletes AppConfig.Load(); moves UpdateCommand + ImportCommand onto the v2 profile;
demotes CapacitorConfig -> LegacyV1Config (migration-only DTO); removes the unused
v1 indented JSON context; fixes update_check defaulting to false on v1->v2 migration.

Closes #268
… migrated

Fires only on a genuine v1->v2 migration (result.ShouldPersist), best-effort and
at most once per process. Lets us watch whether any v1 configs still exist in the
wild before eventually removing the ConfigMigration path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses Qodo review on #267: Profile.UpdateChannel is read unvalidated from
disk and flows into the update-check cache path and the registry URL. Validate
the resolved channel against a {latest, beta} allowlist (normalize + fall back
to latest), so a corrupted/hand-edited value containing '..' or a rooted path
can never escape the config dir or break the check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alexeyzimarev
alexeyzimarev merged commit 341d61b into main Jul 4, 2026
5 checks passed
@alexeyzimarev
alexeyzimarev deleted the docs/cli-server-version-skew-design branch July 4, 2026 08:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant