From e8f25edc144f69fa8d087fef49bce5fd50a3f0db Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 3 Jun 2026 12:03:01 -0700 Subject: [PATCH] fix(cli): auto-update in CI and always re-exec npx @latest Address PR review feedback on the RDE CLI self-update: - shouldAutoUpdate no longer skips CI, so the version that runs in CI matches what developers run locally. - Re-exec always runs npx @latest guarded by SKIP_AUTO_UPDATE_ENV instead of fetching/comparing versions. Removes the registry lookup, version comparison, and the now-orphaned cliUpdateCheck cache in dev-env-state. isVersionNewer is kept for the dashboard restart check. --- .../stack-cli/src/lib/dev-env-state.test.ts | 55 +---- packages/stack-cli/src/lib/dev-env-state.ts | 40 +--- .../stack-cli/src/lib/self-update.test.ts | 205 ++---------------- packages/stack-cli/src/lib/self-update.ts | 145 +++---------- 4 files changed, 58 insertions(+), 387 deletions(-) diff --git a/packages/stack-cli/src/lib/dev-env-state.test.ts b/packages/stack-cli/src/lib/dev-env-state.test.ts index a19735b53..0b32f4379 100644 --- a/packages/stack-cli/src/lib/dev-env-state.test.ts +++ b/packages/stack-cli/src/lib/dev-env-state.test.ts @@ -2,7 +2,7 @@ import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } import { tmpdir } from "os"; import { join } from "path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { devEnvStatePath, ensureLocalDashboardSecret, readCliUpdateCheckCache, readDevEnvState, recordLocalDashboardProcess, writeCliUpdateCheckCache, writeDevEnvState } from "./dev-env-state"; +import { devEnvStatePath, ensureLocalDashboardSecret, readDevEnvState, recordLocalDashboardProcess, writeDevEnvState } from "./dev-env-state"; let tempDir: string | undefined; @@ -89,55 +89,7 @@ describe("dev env state", () => { expect(readDevEnvState().localDashboardsByPort?.["26700"]?.version).toBe("2.8.110"); }); - it("round-trips the latest-version update-check cache", () => { - useTempStateFile(); - expect(readCliUpdateCheckCache()).toBeUndefined(); - writeCliUpdateCheckCache({ packageName: "@hexclave/cli", latestVersion: "2.0.0", checkedAtMillis: 123 }); - expect(readCliUpdateCheckCache()).toEqual({ packageName: "@hexclave/cli", latestVersion: "2.0.0", checkedAtMillis: 123 }); - }); - - it("drops a malformed cliUpdateCheck entry on read", () => { - useTempStateFile(); - const statePath = process.env.STACK_DEV_ENVS_PATH; - if (statePath == null) { - throw new Error("STACK_DEV_ENVS_PATH should be set by useTempStateFile()."); - } - // Wrong-typed fields (e.g. hand-edited or cross-version): latestVersion is a - // number and checkedAtMillis is a string — must be treated as "no cache" so - // version parsing never sees a non-string. - writeFileSync(statePath, JSON.stringify({ - version: 1, - cliUpdateCheck: { packageName: "@hexclave/cli", latestVersion: 2, checkedAtMillis: "soon" }, - projectsByConfigPath: {}, - }), { mode: 0o600 }); - expect(readCliUpdateCheckCache()).toBeUndefined(); - }); - - it("keeps a well-formed cliUpdateCheck entry on read", () => { - useTempStateFile(); - const statePath = process.env.STACK_DEV_ENVS_PATH; - if (statePath == null) { - throw new Error("STACK_DEV_ENVS_PATH should be set by useTempStateFile()."); - } - writeFileSync(statePath, JSON.stringify({ - version: 1, - cliUpdateCheck: { packageName: "@hexclave/cli", latestVersion: "2.0.0", checkedAtMillis: 123 }, - projectsByConfigPath: {}, - }), { mode: 0o600 }); - expect(readCliUpdateCheckCache()).toEqual({ packageName: "@hexclave/cli", latestVersion: "2.0.0", checkedAtMillis: 123 }); - }); - - it("does not clobber the dashboard record when writing the update-check cache", () => { - useTempStateFile(); - const secret = ensureLocalDashboardSecret(26700); - recordLocalDashboardProcess(26700, secret, 12345, "/tmp/stack-rde-dashboard.log", "2.8.110"); - writeCliUpdateCheckCache({ packageName: "@hexclave/cli", latestVersion: "2.0.0", checkedAtMillis: 123 }); - const state = readDevEnvState(); - expect(state.localDashboardsByPort?.["26700"]?.pid).toBe(12345); - expect(state.cliUpdateCheck?.latestVersion).toBe("2.0.0"); - }); - - it("does not clobber projectsByConfigPath or anonymousRefreshToken when writing the update-check cache", () => { + it("does not clobber projectsByConfigPath or anonymousRefreshToken across writes", () => { useTempStateFile(); writeDevEnvState({ version: 1, @@ -149,11 +101,10 @@ describe("dev env state", () => { }, }, }); - writeCliUpdateCheckCache({ packageName: "@hexclave/cli", latestVersion: "2.0.0", checkedAtMillis: 1 }); + ensureLocalDashboardSecret(26700); const state = readDevEnvState(); expect(state.anonymousRefreshToken).toBe("rt-123"); expect(state.projectsByConfigPath["/a/stack.config.ts"]?.projectId).toBe("p"); - expect(state.cliUpdateCheck?.latestVersion).toBe("2.0.0"); }); it("reads a recorded dashboard without a version field as version undefined", () => { diff --git a/packages/stack-cli/src/lib/dev-env-state.ts b/packages/stack-cli/src/lib/dev-env-state.ts index 9fa859174..48aee3423 100644 --- a/packages/stack-cli/src/lib/dev-env-state.ts +++ b/packages/stack-cli/src/lib/dev-env-state.ts @@ -14,19 +14,11 @@ type LocalDashboardState = { version?: string, }; -export type CliUpdateCheckCache = { - packageName: string, - latestVersion: string, - checkedAtMillis: number, -}; - export type DevEnvState = { version: 1, anonymousRefreshToken?: string, localDashboardsByPort?: Partial>, anonymousApiBaseUrl?: string, - // Memoized result of the latest-version registry lookup (see self-update.ts). - cliUpdateCheck?: CliUpdateCheckCache, projectsByConfigPath: Partial; - return ( - typeof candidate.packageName === "string" && - typeof candidate.latestVersion === "string" && - typeof candidate.checkedAtMillis === "number" && - Number.isFinite(candidate.checkedAtMillis) - ); -} - -// Validate an on-disk dashboard record, mirroring isCliUpdateCheckCache: a -// hand-edited or cross-version state file could carry wrong-typed fields. In -// particular a non-string `version` flows into shouldRestartDashboard -> +// Validate an on-disk dashboard record: a hand-edited or cross-version state +// file could carry wrong-typed fields. In particular a non-string `version` +// flows into shouldRestartDashboard -> // isVersionNewer -> parseVersionCore (version.trim()) inside // startDashboardIfNeeded, which is not behind the auto-update fail-open guard, // so it would throw and crash `hexclave dev`. Malformed entries are dropped on @@ -109,7 +87,6 @@ export function readDevEnvState(): DevEnvState { anonymousRefreshToken: typeof parsed.anonymousRefreshToken === "string" ? parsed.anonymousRefreshToken : undefined, anonymousApiBaseUrl: typeof parsed.anonymousApiBaseUrl === "string" ? parsed.anonymousApiBaseUrl : undefined, localDashboardsByPort: sanitizeLocalDashboardsByPort(parsed.localDashboardsByPort), - cliUpdateCheck: isCliUpdateCheckCache(parsed.cliUpdateCheck) ? parsed.cliUpdateCheck : undefined, projectsByConfigPath: parsed.projectsByConfigPath ?? {}, }; } @@ -162,14 +139,3 @@ export function recordLocalDashboardProcess(port: number, secret: string, pid: n }, }); } - -export function readCliUpdateCheckCache(): CliUpdateCheckCache | undefined { - return readDevEnvState().cliUpdateCheck; -} - -export function writeCliUpdateCheckCache(cache: CliUpdateCheckCache): void { - writeDevEnvState({ - ...readDevEnvState(), - cliUpdateCheck: cache, - }); -} diff --git a/packages/stack-cli/src/lib/self-update.test.ts b/packages/stack-cli/src/lib/self-update.test.ts index dc90f8cee..927af30eb 100644 --- a/packages/stack-cli/src/lib/self-update.test.ts +++ b/packages/stack-cli/src/lib/self-update.test.ts @@ -1,6 +1,3 @@ -import { mkdtempSync, rmSync, writeFileSync } from "fs"; -import { tmpdir } from "os"; -import { join } from "path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildNpxInvocation, @@ -9,7 +6,6 @@ import { isEnvFlagEnabled, isVersionNewer, maybeReexecToLatest, - resolveLatestVersion, shouldAutoUpdate, SKIP_AUTO_UPDATE_ENV, } from "./self-update.js"; @@ -45,13 +41,9 @@ describe("shouldAutoUpdate", () => { expect(shouldAutoUpdate({ [DISABLE_AUTO_UPDATE_ENV]: "1" })).toBe(false); }); - it("is disabled in CI", () => { - expect(shouldAutoUpdate({ CI: "true" })).toBe(false); - }); - - it("still auto-updates when CI is a falsy string (CI=false / CI=0)", () => { - expect(shouldAutoUpdate({ CI: "false" })).toBe(true); - expect(shouldAutoUpdate({ CI: "0" })).toBe(true); + it("still auto-updates in CI so it matches what developers run locally", () => { + expect(shouldAutoUpdate({ CI: "true" })).toBe(true); + expect(shouldAutoUpdate({ CI: "1" })).toBe(true); }); it("does not skip when an opt-out flag is a falsy string", () => { @@ -108,10 +100,9 @@ describe("isVersionNewer", () => { }); describe("buildNpxInvocation", () => { - it("pins the exact version and forwards the subcommand through the bin", () => { + it("pins @latest and forwards the subcommand through the bin", () => { const { command, args } = buildNpxInvocation({ packageName: "@hexclave/cli", - version: "2.8.110", binName: "stack", forwardArgs: ["dev", "--config-file", "./stack.config.ts", "--", "npm", "run", "dev:app"], }); @@ -120,7 +111,7 @@ describe("buildNpxInvocation", () => { "--yes", "--min-release-age=0", "-p", - "@hexclave/cli@2.8.110", + "@hexclave/cli@latest", "stack", "dev", "--config-file", @@ -135,7 +126,6 @@ describe("buildNpxInvocation", () => { it("overrides any global npm cooldown so a just-published version is fetched", () => { const { args } = buildNpxInvocation({ packageName: "@hexclave/cli", - version: "2.8.110", binName: "stack", forwardArgs: [], }); @@ -146,12 +136,11 @@ describe("buildNpxInvocation", () => { it("preserves args that start with dashes or contain spaces as individual argv elements", () => { const { args } = buildNpxInvocation({ packageName: "@hexclave/cli", - version: "2.8.110", binName: "stack", forwardArgs: ["dev", "--flag=a b", "--", "echo", "hello world"], }); expect(args).toEqual([ - "--yes", "--min-release-age=0", "-p", "@hexclave/cli@2.8.110", "stack", + "--yes", "--min-release-age=0", "-p", "@hexclave/cli@latest", "stack", "dev", "--flag=a b", "--", "echo", "hello world", ]); }); @@ -160,7 +149,7 @@ describe("buildNpxInvocation", () => { const spy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); try { const invocation = buildNpxInvocation({ - packageName: "@hexclave/cli", version: "1.0.0", binName: "stack", forwardArgs: [], + packageName: "@hexclave/cli", binName: "stack", forwardArgs: [], }); expect(invocation.command).toBe("npx.cmd"); expect(invocation.shell).toBe(true); @@ -173,7 +162,7 @@ describe("buildNpxInvocation", () => { const spy = vi.spyOn(process, "platform", "get").mockReturnValue("linux"); try { const invocation = buildNpxInvocation({ - packageName: "@hexclave/cli", version: "1.0.0", binName: "stack", forwardArgs: [], + packageName: "@hexclave/cli", binName: "stack", forwardArgs: [], }); expect(invocation.command).toBe("npx"); expect(invocation.shell).toBe(false); @@ -187,185 +176,35 @@ describe("decideReexec", () => { const pkg: OwnPackage = { name: "@hexclave/cli", version: "2.8.109", binName: "stack" }; it("does not re-exec when auto-update is disabled", () => { - expect(decideReexec({ env: { CI: "true" }, pkg, latest: "9.9.9", forwardArgs: [] })) + expect(decideReexec({ env: { [SKIP_AUTO_UPDATE_ENV]: "1" }, pkg, forwardArgs: [] })) .toEqual({ reexec: false, reason: "disabled" }); }); it("does not re-exec when own package is unresolvable", () => { - expect(decideReexec({ env: {}, pkg: null, latest: "9.9.9", forwardArgs: [] })) + expect(decideReexec({ env: {}, pkg: null, forwardArgs: [] })) .toEqual({ reexec: false, reason: "no-package" }); }); - it("does not re-exec when the registry returned nothing", () => { - expect(decideReexec({ env: {}, pkg, latest: null, forwardArgs: [] })) - .toEqual({ reexec: false, reason: "no-latest" }); - }); - - it("does not re-exec when latest is not strictly newer", () => { - expect(decideReexec({ env: {}, pkg, latest: "2.8.109", forwardArgs: ["dev"] })) - .toEqual({ reexec: false, reason: "not-newer" }); - expect(decideReexec({ env: {}, pkg, latest: "2.8.108", forwardArgs: ["dev"] })) - .toEqual({ reexec: false, reason: "not-newer" }); - }); - - it("re-execs with a pinned npx invocation when a newer version exists", () => { + it("re-execs through a pinned `npx @latest` invocation when eligible", () => { const decision = decideReexec({ env: {}, pkg, - latest: "2.8.110", forwardArgs: ["dev", "--config-file", "x"], }); expect(decision.reexec).toBe(true); if (decision.reexec) { expect(decision.invocation.args).toEqual([ - "--yes", "--min-release-age=0", "-p", "@hexclave/cli@2.8.110", "stack", "dev", "--config-file", "x", + "--yes", "--min-release-age=0", "-p", "@hexclave/cli@latest", "stack", "dev", "--config-file", "x", ]); } }); }); -describe("resolveLatestVersion", () => { - // The latest-version cache is persisted via dev-env-state, which honors - // STACK_DEV_ENVS_PATH — point it at a temp file so each test is isolated. - let tempDir: string; - - beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), "self-update-")); - process.env.STACK_DEV_ENVS_PATH = join(tempDir, "dev-envs.json"); - }); - - afterEach(() => { - delete process.env.STACK_DEV_ENVS_PATH; - rmSync(tempDir, { recursive: true, force: true }); - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - }); - - it("fetches and caches the latest version", async () => { - const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ version: "2.0.0" }) }); - vi.stubGlobal("fetch", fetchMock); - - const first = await resolveLatestVersion("@hexclave/cli", { timeoutMs: 1000, ttlMs: 10_000, now: 1_000 }); - expect(first).toBe("2.0.0"); - expect(fetchMock).toHaveBeenCalledTimes(1); - - // Within TTL: served from cache, no second network call. - const second = await resolveLatestVersion("@hexclave/cli", { timeoutMs: 1000, ttlMs: 10_000, now: 5_000 }); - expect(second).toBe("2.0.0"); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it("re-fetches once the cache is stale", async () => { - const fetchMock = vi.fn() - .mockResolvedValueOnce({ ok: true, json: async () => ({ version: "2.0.0" }) }) - .mockResolvedValueOnce({ ok: true, json: async () => ({ version: "2.1.0" }) }); - vi.stubGlobal("fetch", fetchMock); - - await resolveLatestVersion("@hexclave/cli", { timeoutMs: 1000, ttlMs: 10_000, now: 1_000 }); - const fresh = await resolveLatestVersion("@hexclave/cli", { timeoutMs: 1000, ttlMs: 10_000, now: 20_000 }); - expect(fresh).toBe("2.1.0"); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it("ignores a cache entry for a different package", async () => { - const fetchMock = vi.fn() - .mockResolvedValueOnce({ ok: true, json: async () => ({ version: "2.0.0" }) }) - .mockResolvedValueOnce({ ok: true, json: async () => ({ version: "9.9.9" }) }); - vi.stubGlobal("fetch", fetchMock); - - await resolveLatestVersion("@stackframe/stack-cli", { timeoutMs: 1000, ttlMs: 10_000, now: 1_000 }); - const other = await resolveLatestVersion("@hexclave/cli", { timeoutMs: 1000, ttlMs: 10_000, now: 2_000 }); - expect(other).toBe("9.9.9"); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it("returns null and does not cache when the registry is unreachable", async () => { - const fetchMock = vi.fn().mockRejectedValue(new Error("network down")); - vi.stubGlobal("fetch", fetchMock); - - const result = await resolveLatestVersion("@hexclave/cli", { timeoutMs: 1000, ttlMs: 10_000, now: 1_000 }); - expect(result).toBeNull(); - - // A subsequent successful fetch should still happen (nothing was cached). - fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ version: "2.0.0" }) }); - const retry = await resolveLatestVersion("@hexclave/cli", { timeoutMs: 1000, ttlMs: 10_000, now: 2_000 }); - expect(retry).toBe("2.0.0"); - }); - - it("returns null on a non-OK registry response", async () => { - const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 404, json: async () => ({}) }); - vi.stubGlobal("fetch", fetchMock); - - const result = await resolveLatestVersion("@hexclave/cli", { timeoutMs: 1000, ttlMs: 10_000, now: 1_000 }); - expect(result).toBeNull(); - }); - - it("re-fetches when the cache age exactly equals the TTL (boundary is exclusive)", async () => { - const fetchMock = vi.fn() - .mockResolvedValueOnce({ ok: true, json: async () => ({ version: "2.0.0" }) }) - .mockResolvedValueOnce({ ok: true, json: async () => ({ version: "2.1.0" }) }); - vi.stubGlobal("fetch", fetchMock); - - await resolveLatestVersion("@hexclave/cli", { timeoutMs: 1000, ttlMs: 10_000, now: 1_000 }); - // now - checkedAt === ttlMs exactly → not "< ttl" → re-fetch. - const atBoundary = await resolveLatestVersion("@hexclave/cli", { timeoutMs: 1000, ttlMs: 10_000, now: 11_000 }); - expect(atBoundary).toBe("2.1.0"); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it("returns null on an OK response whose body is missing `version`", async () => { - const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ name: "@hexclave/cli" }) }); - vi.stubGlobal("fetch", fetchMock); - expect(await resolveLatestVersion("@hexclave/cli", { timeoutMs: 1000, ttlMs: 10_000, now: 1_000 })).toBeNull(); - }); - - it("returns null (and does not cache) when `version` is not a string", async () => { - const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ version: 123 }) }); - vi.stubGlobal("fetch", fetchMock); - expect(await resolveLatestVersion("@hexclave/cli", { timeoutMs: 1000, ttlMs: 10_000, now: 1_000 })).toBeNull(); - - // Nothing cached → a later good fetch still succeeds. - fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ version: "2.0.0" }) }); - expect(await resolveLatestVersion("@hexclave/cli", { timeoutMs: 1000, ttlMs: 10_000, now: 2_000 })).toBe("2.0.0"); - }); - - it("requests the percent-encoded scoped `/latest` URL on the default registry", async () => { - const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ version: "2.0.0" }) }); - vi.stubGlobal("fetch", fetchMock); - await resolveLatestVersion("@hexclave/cli", { timeoutMs: 1000, ttlMs: 10_000, now: 1_000 }); - expect(fetchMock).toHaveBeenCalledWith( - "https://registry.npmjs.org/@hexclave%2fcli/latest", - expect.objectContaining({ headers: { Accept: "application/json" } }), - ); - }); - - it("uses npm_config_registry (with trailing slashes stripped) for the lookup URL", async () => { - const prev = process.env.npm_config_registry; - process.env.npm_config_registry = "https://npm.internal.example.com///"; - try { - const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ version: "2.0.0" }) }); - vi.stubGlobal("fetch", fetchMock); - await resolveLatestVersion("@hexclave/cli", { timeoutMs: 1000, ttlMs: 10_000, now: 1_000 }); - expect(fetchMock).toHaveBeenCalledWith( - "https://npm.internal.example.com/@hexclave%2fcli/latest", - expect.anything(), - ); - } finally { - if (prev == null) delete process.env.npm_config_registry; - else process.env.npm_config_registry = prev; - } - }); -}); - describe("maybeReexecToLatest", () => { - let tempDir: string; - const optOutKeys = ["CI", SKIP_AUTO_UPDATE_ENV, DISABLE_AUTO_UPDATE_ENV]; + const optOutKeys = [SKIP_AUTO_UPDATE_ENV, DISABLE_AUTO_UPDATE_ENV]; const savedEnv: Record = {}; beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), "reexec-")); - process.env.STACK_DEV_ENVS_PATH = join(tempDir, "dev-envs.json"); - // Auto-update must be eligible for the throwing path to be reached. for (const key of optOutKeys) { savedEnv[key] = process.env[key]; delete process.env[key]; @@ -373,26 +212,18 @@ describe("maybeReexecToLatest", () => { }); afterEach(() => { - delete process.env.STACK_DEV_ENVS_PATH; for (const key of optOutKeys) { if (savedEnv[key] == null) delete process.env[key]; else process.env[key] = savedEnv[key]; } - rmSync(tempDir, { recursive: true, force: true }); vi.restoreAllMocks(); - vi.unstubAllGlobals(); }); - it("fails open (does not throw) when reading dev-env state throws", async () => { - // A corrupt state file makes readDevEnvState throw while resolving the - // cached latest version. The contract is to fall through to the installed - // CLI, not crash `stack dev`. - writeFileSync(process.env.STACK_DEV_ENVS_PATH as string, "{ not json", { mode: 0o600 }); - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - + it("returns without re-exec (never spawning npx) when auto-update is opted out", async () => { + // With the opt-out set, the disabled short-circuit fires before any spawn, + // so the installed CLI keeps running. Resolving here without throwing or + // hanging proves we did not re-exec into `npx @latest`. + process.env[DISABLE_AUTO_UPDATE_ENV] = "1"; await expect(maybeReexecToLatest({ forwardArgs: ["dev"] })).resolves.toBeUndefined(); - // It bails on the state-read error before reaching the network. - expect(fetchMock).not.toHaveBeenCalled(); }); }); diff --git a/packages/stack-cli/src/lib/self-update.ts b/packages/stack-cli/src/lib/self-update.ts index 9f5ea1446..46bd7b368 100644 --- a/packages/stack-cli/src/lib/self-update.ts +++ b/packages/stack-cli/src/lib/self-update.ts @@ -1,6 +1,5 @@ import { spawn } from "child_process"; import { forwardSignals } from "./child-process.js"; -import { readCliUpdateCheckCache, writeCliUpdateCheckCache } from "./dev-env-state.js"; import { getOwnPackage, type OwnPackage } from "./own-package.js"; // Set on the process we re-exec via npx so the child doesn't try to update @@ -9,9 +8,6 @@ export const SKIP_AUTO_UPDATE_ENV = "STACK_CLI_SKIP_AUTO_UPDATE"; // User-facing opt-out. Set to a truthy value to never auto-update. export const DISABLE_AUTO_UPDATE_ENV = "STACK_CLI_NO_AUTO_UPDATE"; -const DEFAULT_UPDATE_CHECK_TIMEOUT_MS = 1_500; -const DEFAULT_UPDATE_CHECK_TTL_MS = 10 * 60 * 1_000; -const DEFAULT_REGISTRY = "https://registry.npmjs.org"; const LOG_PREFIX = "[Hexclave] "; function logUpdate(message: string): void { @@ -25,13 +21,13 @@ export function isEnvFlagEnabled(value: string | undefined): boolean { return normalized !== "" && normalized !== "0" && normalized !== "false"; } -// Auto-update is skipped when we're the re-exec'd child, when the user opted -// out, or in CI (where re-running an arbitrary newer version would be -// non-deterministic). +// Auto-update is skipped only when we're the re-exec'd child or when the user +// explicitly opted out. We intentionally still auto-update in CI: pinning a +// different version there than developers run locally is exactly the kind of +// drift that hides "works on my machine" bugs. export function shouldAutoUpdate(env: NodeJS.ProcessEnv): boolean { if (isEnvFlagEnabled(env[SKIP_AUTO_UPDATE_ENV])) return false; if (isEnvFlagEnabled(env[DISABLE_AUTO_UPDATE_ENV])) return false; - if (isEnvFlagEnabled(env.CI)) return false; return true; } @@ -54,9 +50,11 @@ function parseVersionCore(version: string): ParsedVersion | null { } // Returns true only when `candidate` is strictly newer than `current`. Unknown -// or unparseable versions return false so we never re-exec into a version we -// can't reason about (and never downgrade). Prerelease identifiers beyond the +// or unparseable versions return false so we never act on a version we can't +// reason about (and never downgrade). Prerelease identifiers beyond the // "release beats same-core prerelease" rule are intentionally not ordered. +// Used by the dashboard restart check in dev.ts (the re-exec itself just always +// runs `@latest`). export function isVersionNewer(candidate: string, current: string): boolean { const a = parseVersionCore(candidate); const b = parseVersionCore(current); @@ -70,75 +68,6 @@ export function isVersionNewer(candidate: string, current: string): boolean { return !a.hasPrerelease && b.hasPrerelease; } -function npmRegistry(): string { - const fromEnv = process.env.npm_config_registry ?? process.env.NPM_CONFIG_REGISTRY; - const base = fromEnv != null && fromEnv.trim().length > 0 ? fromEnv.trim() : DEFAULT_REGISTRY; - return base.replace(/\/+$/, ""); -} - -function encodePackageName(name: string): string { - // Scoped packages contain a single `/` that must be percent-encoded for the - // registry path; the leading `@` is left as-is. - return name.replace("/", "%2f"); -} - -function positiveIntEnv(name: string, fallback: number): number { - const raw = process.env[name]; - if (raw == null) return fallback; - const parsed = Number(raw); - if (!Number.isInteger(parsed) || parsed <= 0) return fallback; - return parsed; -} - -export function updateCheckTimeoutMs(): number { - return positiveIntEnv("STACK_CLI_UPDATE_CHECK_TIMEOUT_MS", DEFAULT_UPDATE_CHECK_TIMEOUT_MS); -} - -export function updateCheckTtlMs(): number { - return positiveIntEnv("STACK_CLI_UPDATE_CHECK_TTL_MS", DEFAULT_UPDATE_CHECK_TTL_MS); -} - -async function fetchLatestVersion(packageName: string, timeoutMs: number): Promise { - // Manual AbortController + clearTimeout (rather than AbortSignal.timeout) so - // the timer is always cleared on the fast path and we stay compatible with - // older Node runtimes that lack AbortSignal.timeout. - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - const url = `${npmRegistry()}/${encodePackageName(packageName)}/latest`; - const res = await fetch(url, { - signal: controller.signal, - headers: { Accept: "application/json" }, - }); - if (!res.ok) return null; - const body = await res.json() as { version?: unknown }; - return typeof body.version === "string" ? body.version : null; - } catch { - return null; - } finally { - clearTimeout(timeout); - } -} - -// Resolves the latest published version, memoizing the result in the dev-env -// state file for `ttlMs` so back-to-back `hexclave dev` runs don't hammer the -// registry. Returns null when the registry can't be reached (offline, -// timeout) so callers fall back to the installed CLI. -export async function resolveLatestVersion( - packageName: string, - opts: { timeoutMs: number, ttlMs: number, now?: number }, -): Promise { - const now = opts.now ?? Date.now(); - const cache = readCliUpdateCheckCache(); - if (cache != null && cache.packageName === packageName && now - cache.checkedAtMillis < opts.ttlMs) { - return cache.latestVersion; - } - const latest = await fetchLatestVersion(packageName, opts.timeoutMs); - if (latest == null) return null; - writeCliUpdateCheckCache({ packageName, latestVersion: latest, checkedAtMillis: now }); - return latest; -} - export type NpxInvocation = { command: string, args: string[], @@ -151,7 +80,6 @@ export type NpxInvocation = { export function buildNpxInvocation(opts: { packageName: string, - version: string, binName: string, forwardArgs: string[], }): NpxInvocation { @@ -163,13 +91,17 @@ export function buildNpxInvocation(opts: { args: [ "--yes", // Override any global npm "cooldown" for this call only — we always want - // the just-published latest, and npx of a pinned version newer than the - // cooldown window otherwise fails with ETARGET (which would kill - // `hexclave dev`). npm's config is `min-release-age` (days, npm >=11.10.0); - // older npm silently ignores the unknown flag. + // the just-published latest, and npx of a version newer than the cooldown + // window otherwise fails with ETARGET (which would kill `hexclave dev`). + // npm's config is `min-release-age` (days, npm >=11.10.0); older npm + // silently ignores the unknown flag. "--min-release-age=0", "-p", - `${opts.packageName}@${opts.version}`, + // Always pin `@latest`: npm resolves the newest published version, so we + // don't need to fetch-and-compare versions ourselves. The re-exec'd child + // carries SKIP_AUTO_UPDATE_ENV, so it runs that downloaded CLI directly + // instead of recursing. + `${opts.packageName}@latest`, opts.binName, ...opts.forwardArgs, ], @@ -177,27 +109,24 @@ export function buildNpxInvocation(opts: { } export type ReexecDecision = - | { reexec: false, reason: "disabled" | "no-package" | "no-latest" | "not-newer" } + | { reexec: false, reason: "disabled" | "no-package" } | { reexec: true, invocation: NpxInvocation }; -// Pure decision: given the environment, our own package, the resolved latest -// version, and the args to forward, decide whether (and how) to re-exec. Kept -// free of I/O so the branching can be unit-tested directly. +// Pure decision: given the environment, our own package, and the args to +// forward, decide whether (and how) to re-exec through `npx @latest`. Kept +// free of I/O so the branching can be unit-tested directly. We re-exec unless +// auto-update is off or we can't resolve our own package name. export function decideReexec(opts: { env: NodeJS.ProcessEnv, pkg: OwnPackage | null, - latest: string | null, forwardArgs: string[], }): ReexecDecision { if (!shouldAutoUpdate(opts.env)) return { reexec: false, reason: "disabled" }; if (opts.pkg == null) return { reexec: false, reason: "no-package" }; - if (opts.latest == null) return { reexec: false, reason: "no-latest" }; - if (!isVersionNewer(opts.latest, opts.pkg.version)) return { reexec: false, reason: "not-newer" }; return { reexec: true, invocation: buildNpxInvocation({ packageName: opts.pkg.name, - version: opts.latest, binName: opts.pkg.binName, forwardArgs: opts.forwardArgs, }), @@ -241,24 +170,19 @@ function runReexec(invocation: NpxInvocation): Promise { }); } -// If a newer version of this CLI is published, re-runs the requested command -// through `npx @` so the user gets the latest dashboard without -// reinstalling, then exits with the child's code. Best-effort: any failure -// (offline, no npx, opted out) silently falls through to the installed CLI. +// Re-runs the requested command through `npx @latest` so the user always +// gets the latest CLI + dashboard without reinstalling, then exits with the +// child's code. The re-exec'd child carries SKIP_AUTO_UPDATE_ENV so it runs the +// freshly downloaded CLI directly instead of recursing. Best-effort: if npx +// can't be spawned (or auto-update is off / opted out) we silently fall through +// to the installed CLI. export async function maybeReexecToLatest(opts: { forwardArgs: string[] }): Promise { try { - // Fast-path: don't even hit the registry when auto-update is off. - if (!shouldAutoUpdate(process.env)) return; - - const pkg = getOwnPackage(); - if (pkg == null) return; - - const latest = await resolveLatestVersion(pkg.name, { - timeoutMs: updateCheckTimeoutMs(), - ttlMs: updateCheckTtlMs(), + const decision = decideReexec({ + env: process.env, + pkg: getOwnPackage(), + forwardArgs: opts.forwardArgs, }); - - const decision = decideReexec({ env: process.env, pkg, latest, forwardArgs: opts.forwardArgs }); if (!decision.reexec) return; const result = await runReexec(decision.invocation); @@ -267,8 +191,7 @@ export async function maybeReexecToLatest(opts: { forwardArgs: string[] }): Prom } logUpdate(`Could not run npx (${result.error}); continuing with the installed CLI.`); } catch { - // Fail open: a corrupt/unreadable state file, a disk-full cache write, or - // any other unexpected error must not block the installed CLI from running. - // A genuine state-file problem resurfaces later in the normal dev flow. + // Fail open: any unexpected error must not block the installed CLI from + // running. } }