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 <pkg>@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.
This commit is contained in:
Bilal Godil 2026-06-03 12:03:01 -07:00
parent 7f9fc77bd5
commit e8f25edc14
4 changed files with 58 additions and 387 deletions

View File

@ -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", () => {

View File

@ -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<Record<string, LocalDashboardState>>,
anonymousApiBaseUrl?: string,
// Memoized result of the latest-version registry lookup (see self-update.ts).
cliUpdateCheck?: CliUpdateCheckCache,
projectsByConfigPath: Partial<Record<string, {
projectId: string,
teamId: string,
@ -42,23 +34,9 @@ export function devEnvStatePath(): string {
return stackDevEnvStatePath();
}
// Validate the on-disk cache shape: a hand-edited or cross-version state file
// could carry a wrong-typed entry, and a non-string latestVersion would later
// throw in version parsing. Treat anything malformed as "no cache".
function isCliUpdateCheckCache(value: unknown): value is CliUpdateCheckCache {
if (value == null || typeof value !== "object") return false;
const candidate = value as Record<string, unknown>;
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,
});
}

View File

@ -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<string, string | undefined> = {};
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();
});
});

View File

@ -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<string | null> {
// 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<string | null> {
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 <pkg>@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<ReexecResult> {
});
}
// If a newer version of this CLI is published, re-runs the requested command
// through `npx <pkg>@<latest>` 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 <pkg>@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<void> {
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.
}
}