fix(upgrade): one live source of truth for "latest", and a cache that cannot pin an install
The bug
Existing installs never moved past 0.0.45 while npm's latest dist-tag served 0.0.47, with autoupdate on.
Evidence from an affected machine:
npm view insta dist-tags -> { latest: "0.0.47", next: "0.0.23-rc.1" }
insta --version -> 0.0.45
~/.insta/update-check.json -> {"checkedAt":1787707409036,"latest":"0.0.45","lastAutoAt":1787613328905}
checkedAt = 2026-08-26T01:23:29Z
0.0.47 was published to npm at 2026-08-26T02:07:11Z — 44 minutes after that check. latest: "0.0.45" was correct when written and became a lie moments later, and with CHECK_TTL_MS = 24h nothing re-checked. maybeUpdate() short-circuits on Date.now() - cache.checkedAt > CHECK_TTL_MS, so the wrong answer was sticky for a full day. No nudge, no auto-upgrade, no re-check.
A second, quieter defect: the two paths used different resolvers.
| path | source |
|---|---|
| background check | https://registry.npmjs.org/insta/latest (npm dist-tag) |
insta upgrade, binary channel | install.sh → https://api.github.com/repos/InsForge/insta-cli/releases/latest |
insta upgrade, npm channel | npm i -g insta@latest |
Two independent answers to one question, and they have already drifted: v0.0.46 was merged (05d9d8b, "v0.0.46") but never tagged, so it exists on neither npm nor GitHub Releases. Nothing in the code notices when the two disagree.
Third: insta upgrade never touched update-check.json. After a successful upgrade to 0.0.47 the cache still read latest: "0.0.45" next to a 0.0.47 binary — a record that contradicts the thing sitting beside it.
What this delivers
resolveLatest()— one resolver, used by every path. Reads npm'slatestdist-tag (/-/package/insta/dist-tags, falling back to thelatestmanifest), the exact thingnpx insta@latestresolves. Sendscache-control: no-cacheso an intermediary cannot re-create the bug. Returnsnullrather than guessing when the registry is unreachable.- The binary channel installs that version.
INSTA_VERSION=v<latest>is passed toinstall.sh, so the binary and npm channels cannot land on different builds. If the pinned install fails (release assets can lag the npm tag), it retries unpinned rather than stranding the user. - The upgrade report quotes only what is on disk. After any install path — pinned, unpinned fallback, npm —
upgrade()reads the installed CLI's real--versionand prints the transition from that (✓ insta upgraded 0.0.45 → 0.0.47). The installer can exit 0 without changing anything (its "already current?" short-circuit against GitHub's/releases/latest), so its exit code proves nothing. In the drift case the honest line is printed:insta is still 0.0.45 — the newest release (0.0.47) is published on npm but its binary assets are not on GitHub Releases yet; try again later or install via npm (npm i -g insta@latest). If the version cannot be read, no transition is claimed. The cache is rewritten from the observed version, so start-up stops advertising a build this channel cannot actually install. nextis neverlatest.0.0.23-rc.1and any prerelease are rejected, in the resolver and indecideAction.- Real semver comparison. Numeric core (
0.0.9 < 0.0.10) plus prerelease precedence (1.0.0 > 1.0.0-rc.1,rc.2 < rc.10). The old comparator parsed"23-rc"withparseIntand ranked0.0.23-rc.1equal to0.0.23. cacheIsFresh()— a cache that cannot pin an install. An entry is refused if it is older than the TTL, malformed, stamped in the future, or claims alatestbelow the running build. TTL cut 24h → 3h, since releases ship every day or two.insta __update-checkacts on what it just learned instead of only priming the cache for some later invocation — otherwise a release landing after a check costs a full TTL plus one more run.insta upgradealways resolves live and rewrites the cache. It never readsupdate-check.json(the documented remedy for a stale cache must not itself be stale) and writes on the way out, including on the no-op path (✓ insta 0.0.47 is already the latest release — nothing to upgrade).
No command or flag surface changed, so skills/insta/cli-reference.md needs no edit.
Tests
test/upgrade-resolver.test.ts, 27 cases, DI fakes only (no network, no globals mocked; the installer and the post-install --version probe are both injected). Negative-controlled in two rounds.
Round 1 — 22 original cases against unmodified origin/main (655ef93): 20 failed, 2 passed.
× resolveLatest returns npm's latest dist-tag
× resolveLatest falls back to the latest manifest when dist-tags is unavailable
× resolveLatest returns null (never a guess) when the registry is unreachable
× the `next` prerelease tag is never selected as latest
× a prerelease cache entry never triggers an upgrade
✓ cmpSemver orders releases numerically, not lexicographically
× cmpSemver: a release outranks a prerelease of the same version
× cacheIsFresh: an entry older than the TTL is not usable
× cacheIsFresh: a cache claiming a `latest` older than the running build is never trusted
× a cached latest of 0.0.45 does not suppress the real 0.0.47 upgrade
× backgroundCheck nudges instead of upgrading when autoupdate is off
× insta upgrade ignores the cache and installs npm's latest on the binary channel
× insta upgrade installs the same resolved version on the npm channel
✓ insta upgrade is a no-op when already on the resolved latest
× a pinned binary install that fails falls back to the newest published release
× insta upgrade still upgrades when the registry is unreachable
× an installed version newer than the cached latest never triggers an upgrade
× a successful upgrade rewrites the cache instead of leaving it claiming the old latest
× even a no-op upgrade refreshes the cache (it still learned the true latest)
× insta upgrade reports what it upgraded from and to
× insta upgrade says plainly when there is nothing to do
× the re-check TTL is at most a few hours, not a day
Tests 20 failed | 2 passed (22)
Round 2 — the 5 observe-then-report cases against the first revision of this PR (8d25d79), which had the "reports a version it did not install" defect: 4 failed, 1 passed. The failing drift case captures the false line verbatim:
× drift: the fallback that installed nothing reports "still 0.0.45" with the npm hint, not "→ 0.0.47"
AssertionError: expected '…✓ insta upgraded 0.0.45 → 0.0.47 (run `insta --version` to confirm)'
not to match /upgraded 0\.0\.45 → 0\.0\.47/
✓ happy pinned path: the report quotes the observed transition (positive control)
× the report names the version the installer actually left, even when it differs from latest
× when the installed version cannot be read, no transition is claimed
× npm channel: an install that did not change the version says so instead of claiming latest
Tests 4 failed | 23 passed (27)
After the fix — 27 passed (27).
Caveats on the pre-fix passes:
cmpSemver orders releases numerically— the old comparator already handled0.0.45 < 0.0.47and0.0.9 < 0.0.10. Kept as a regression guard; not evidence of the bug.insta upgrade is a no-op when already on the resolved latest— passed vacuously pre-fix, because the oldupgrade()took no arguments and fell through to thesourcechannel under vitest. Only meaningful post-fix.happy pinned path— passes at 8d25d79 by design; it is the control that the observed transition matches the intended one when nothing drifted.
Gates
| gate | result |
|---|---|
npm run typecheck | clean |
npm test | 525 passed (525) |
Note: an earlier run of this PR showed 5 failures in test/setup-agent.test.ts / test/ensure-skills.test.ts. Those tests read the developer machine's ~/.insta/config.json and were failing because it pointed at staging; they fail identically with this diff removed and pass now that the local config is back on prod. Nothing in this PR touched them — the clean 525 is the machine's state, not a side effect of the fix.
Not in scope
- This is code drift and release-process drift. The code above makes the CLI robust against the process drift — npm's dist-tag is the authority, the binary install follows it, and the report tells the truth when the two are out of step — but the process gap is real and separate: v0.0.46 reached neither npm nor a git tag. Nothing enforces "bump merged ⇒ tag pushed ⇒ both artifacts published". Worth a CI guard; not attempted here.
- The installer's Next-steps banner recommends
insta deploy . --port 3000, which errors for any app without a Dockerfile. That text isinstall.sh:286; being handled separately. - Nothing published to npm; no tag pushed.