feat(cli): add `npx e2e upgrade`

Merged
KA
8d ago
+1718-79

Adds e2e upgrade, with e2e update as an alias. It moves a project's e2e to the latest release, a dist-tag, or one version, and every @e2e-dev/* package the project lists along with it.

$ npx e2e upgrade            # latest
$ npx e2e upgrade 0.21.0     # one version
$ npx e2e upgrade nightly    # a dist-tag
$ npx e2e update --dry-run   # the plan, nothing written
flowchart LR
  A[nearest package.json<br/>that lists e2e] --> B[registry metadata<br/>e2e + @e2e-dev/*]
  B --> C[e2e target<br/>tag or version]
  C --> D[each sibling: same tag if its<br/>peer ranges accept, else newest that does]
  D --> E[write specs into<br/>package.json]
  E --> F[one manager install]
  F -- fails or Ctrl-C --> G[put back package.json<br/>and changed lockfiles]
  • Siblings follow e2e. A deprecated release is never picked, and a prerelease only when e2e gets one. An integration resolves after the engine it peers on, so @e2e-dev/kernel follows @e2e-dev/web down on a downgrade. Peer ranges are read by the new packages/e2e/src/internal/semver.ts, which covers the shapes the packages publish (>=0.15.0 <1, an exact prerelease pin) and node-semver's prerelease rule.
  • One install. The new specs go into package.json, each in its section, then one <manager> install resolves them together. An add command per package meets the old e2e spec in npm's peer check (ERESOLVE) when packages sit in different sections. A named version or a prerelease is pinned exactly; a dist-tag keeps the old spec's ^ or ~. pnpm gets --no-frozen-lockfile and Yarn YARN_ENABLE_IMMUTABLE_INSTALLS=false, so CI works.
  • A failed or interrupted install puts back package.json and every lockfile it changed, byte for byte (bun.lockb is binary). SIGINT and SIGTERM are held off e2e while the package manager runs, so Ctrl-C stops the install and the restore still runs.
  • Edge cases: a package listed in two sections is rewritten in both; the whole sibling plan's peer ranges are checked before anything is written; a Windows path spec (..\\web) counts as linked.
  • Linked packages are left alone (workspace:, file:, catalog:, a path, git). A linked e2e exits 2.
  • Package manager detection (detectWorkspacePackageManager in packages/e2e/src/internal/package-manager.ts): the package's own packageManager field or lockfile (or pnpm at a pnpm workspace root), else those of a workspace root above whose globs include it (pnpm-workspace.yaml in block or flow form, or workspaces). A lockfile above a project no workspace lists does not count; examples/with-vite otherwise resolved to this repository's pnpm.
  • The update notice from #1238 now suggests e2e upgrade for the package that lists e2e, through the same detection, instead of e2e@latest alone, which left the engine behind. It stays quiet after e2e upgrade itself. The skill says npx e2e@latest upgrade, since a skill from npx skills add can be newer than the project's e2e. olderThan orders versions through the new semver module.
  • Telemetry: one e2e_upgrade_completed event per run: the result, the target kind or a dist-tag e2e publishes, the e2e versions before and after, the package manager, the sibling names inside the @e2e-dev scope, and package counts (docs/telemetry.mdx). The security pages list the registry requests.
  • e2e --help lists commands without their aliases; upgrade|update widened the column and wrapped every summary. The summary says (alias: update).

Exit codes: 0 installed, already up to date, or --dry-run; 2 for a range, an unknown version or tag, no project, a linked e2e, no fitting sibling release, or a failed install; 3 when the registry does not answer; 130 on Ctrl-C or SIGTERM during the install.

Verified

Ran it locally: yes. The built CLI against the live npm registry, in scratch projects and examples/with-vite.

npm, packages split across sections with an exact pin beside ^, the case where per-section add commands hit ERESOLVE:

$ e2e upgrade
Upgrading with npm:
  e2e              0.15.0 → 0.21.2
  @e2e-dev/web     0.12.0 → 0.15.2
  @e2e-dev/github  0.3.3 → 0.4.2
package.json: "e2e": "0.21.2", "@e2e-dev/web": "~0.15.2", "@e2e-dev/github": "^0.4.2"
$ npm install
...
Upgraded: e2e 0.21.2, @e2e-dev/web 0.15.2, @e2e-dev/github 0.4.2
Release notes: https://github.com/tester-army/e2e/releases/tag/e2e%400.21.2

package.json kept its order and indentation; npm ls showed the new versions. Other runs:

CaseResult
upgrade 0.15.0 --dry-run on 0.21.2e2e 0.21.2 → 0.15.0 (downgrade), @e2e-dev/web → 0.12.0, exact pins
upgrade nightly --dry-runevery package to its 0.x.y-nightly-20261019081103 build
pnpm workspace member, unindented packages: list, run under npm_config_user_agent=npmpnpm install --no-frozen-lockfile, no stray package-lock.json in the member
Yarn 4 (Plug'n'Play) with CI=truelockfile updated to e2e@npm:^0.21.2; a second run printed Already up to date
npm project whose postinstall exits 1package.json and package-lock.json are back as before, both byte-identical, npm ci passes
fake npm that sleeps, SIGINT sent to the process groupexit 130, package.json byte-identical
an unresolvable dependency beside e2eexit 2, package.json back
upgrade ^0.20, upgrade 9.9.9, upgrade beta2, apps/testbed (workspace:*), no projectexit 2, nothing written
yarn 1 with .agents/skills/e2eupgraded, then refresh it with yarn e2e init

Telemetry, from a copy of the build outside the checkout with E2E_TELEMETRY_DEBUG=1:

[telemetry] {"event":"e2e_upgrade_completed", ... "result":"dry-run","target":"version","from_version":"0.21.2","to_version":"0.20.0","prerelease":false,"manager":"npm","siblings":["kernel","web"],"packages_moved":0,"packages_linked":0, ...}
[telemetry] {"event":"e2e_upgrade_completed", ... "result":"unknown-target","target":"other","from_version":"0.21.2","to_version":null, ...}

Two fresh-context review rounds ran before this PR, and cubic's 22 findings after it (21 fixed, 1 disproved in its thread); the pre-PR findings (the ERESOLVE above, exact pins turned into ^, the notice after an upgrade, ^ eaten by cmd.exe, YAML list forms, Ctrl-C, lockfiles after a failed script, Yarn Berry in CI, Plug'n'Play) are fixed and covered by tests that fail without the fix.

Not verified: bun (not installed here), Windows, and the update notice on a real terminal; its unit tests cover the command it prints.

Found while verifying, not fixed here: @e2e-dev/web 0.11.2 declares e2e >=0.15.0 <1 but imports rejectUnknownOptions, which e2e first exports in 0.16.0, so a fresh install of examples/with-vite on main fails with CONFIG_LOAD_FAILED. e2e upgrade trusts published peer ranges; deprecating the affected @e2e-dev/web releases on npm would keep it from picking them.