mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
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:
parent
7f9fc77bd5
commit
e8f25edc14
@ -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", () => {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user