Skip to content

air upgrade should keep extensions in lockstep with air-cli #131

Description

@tadasant

Problem

air upgrade only runs npm install -g @pulsemcp/air-cli@latest — it does not touch the extensions installed under air-cli's managed extension directory (e.g. ~/.air/node_modules/). Because the air monorepo does lockstep version bumps across all 8 packages, this leaves users with a CLI on the latest version and extensions multiple major versions behind, which produces hard-to-diagnose runtime errors.

Reproduction

Starting state — long-stale install, then air upgrade:

$ npm ls -g
@pulsemcp/air-cli              0.4.1   ← upgraded by `air upgrade`
@pulsemcp/air-adapter-claude   0.4.1   ← present globally but not the one loaded
@pulsemcp/air-provider-github  0.4.1
@pulsemcp/air-secrets-env      0.4.1

$ ls ~/.air/node_modules/@pulsemcp/
@pulsemcp/air-adapter-claude   0.0.25  ← actual loaded extensions
@pulsemcp/air-provider-github  0.0.25
@pulsemcp/air-secrets-env      0.0.25
@pulsemcp/air-secrets-file     0.0.25
@pulsemcp/air-core             0.0.25

$ cat ~/.air/package.json
{
  "dependencies": {
    "@pulsemcp/air-adapter-claude": "^0.0.25",
    "@pulsemcp/air-provider-github": "^0.0.25",
    ...
  }
}

air start claude then fails with:

Error: Provider for "github://" cannot resolve directory paths — it lacks resolveCatalogDir().
Upgrade the provider extension or replace the URI with a vendored relative path.

Even though the new global air-provider-github@0.4.1 implements resolveCatalogDir(), the extension loader uses createRequire(join(airJsonDir, "__placeholder.js")) where airJsonDir defaults to ~/.air/, so the v0.0.25 copy in ~/.air/node_modules/ is what's resolved.

Two underlying gaps

  1. air upgrade doesn't update extensions. It needs to additionally rewrite ~/.air/package.json (or whichever <airJsonDir>/package.json the user's config resolves from) and reinstall — pinning each extension to a version compatible with the new CLI. Since the monorepo is lockstep, pinning to the CLI version is the simplest correct behavior.

  2. air install's isPackageInstalled() only checks directory existence, never version compatibility. From packages/sdk/src/install.ts:

    function isPackageInstalled(specifier, prefix) {
      const name = stripVersion(specifier);
      return existsSync(join(prefix, "node_modules", name));
    }

    So even if the user runs air install after the CLI bump, it sees ~/.air/node_modules/@pulsemcp/air-provider-github exists and reports Already installed, never bumping. Pinned ^0.0.25 constraints in the package.json compound this — npm install alone in that dir won't upgrade either.

Proposed fix

air upgrade should:

  1. Upgrade air-cli (today's behavior).
  2. Read the resolved air.json's extensions array.
  3. For each extension, rewrite the corresponding constraint in <airJsonDir>/package.json to a version range compatible with the upgraded CLI (e.g., ^<cli-major>.<cli-minor>.0, since the monorepo bumps in lockstep).
  4. Run npm install --prefix <airJsonDir> (or equivalent), letting npm pull the new versions.

air install's isPackageInstalled should also be tightened so a user can force a re-check / reinstall when CLI compatibility shifts — at minimum, parsing <prefix>/node_modules/<name>/package.json and comparing against the desired constraint.

Workaround

For users hitting this now:

npm install --prefix ~/.air \
  @pulsemcp/air-adapter-claude@latest \
  @pulsemcp/air-provider-github@latest \
  @pulsemcp/air-secrets-env@latest \
  @pulsemcp/air-secrets-file@latest

(Adjust the package list to match your ~/.air/air.json extensions array.)

Related

This is likely subsumed by the broader air update/air upgrade unification work — filing separately so the install-location and version-aware install semantics are tracked explicitly regardless of how the command shape lands.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    convergentIssue-work-gate direction: closes a gap the system is already supposed to have closed

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions