Files
hyperframes/packages/cli/src/utils/updateCheck.test.ts
T
James RussoandClaude Opus 5.5 0bdc128cb5 fix(cli): never prompt or auto-install when nobody is at the terminal (#6617)
* fix(cli): never prompt or auto-install when nobody is at the terminal

publish and upgrade called the confirmation prompt with no terminal check.
With stdin from /dev/null, publish exited 13; with an open pipe it hung.
Unattended and without --yes, both now exit 2 before doing any work, and
stderr names --yes (upgrade also names --check).

All prompts and spinners now go through ui/prompts.ts, the only module allowed
to import @clack/prompts (a lint rule and a contract test enforce it). Its
confirm, select, text and password refuse to prompt in an unattended run, and
its spinner prints only the final line, on stderr. init, feedback, catalog and
cloud delete decide whether to ask with isUnattended() instead of a TTY check
of their own.

The background auto-install no longer starts in an unattended run, and the
update notice ("Run: npm install -g ...") is not shown while an install for
that version is pending. CI now counts as set for any value but "false" or "0".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(cli): neutralize agent detection in the catalog offer tests through isAgentDriven

Attendance now reads isAgentDriven(), so that is what the catalog tests stub to
stay independent of the agent running the suite.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-28 07:56:20 +00:00

485 lines
16 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { isSafeVersion, printDeprecationNotice, withMeta } from "./updateCheck.js";
const dns = vi.hoisted(() => ({ answers: true }));
vi.mock("./hostAnswers.js", () => ({ hostAnswers: async () => dns.answers }));
describe("isSafeVersion", () => {
it("accepts strict semver, incl. prerelease/build metadata", () => {
expect(isSafeVersion("1.2.3")).toBe(true);
expect(isSafeVersion("0.7.28")).toBe(true);
expect(isSafeVersion("1.2.3-beta.1")).toBe(true);
expect(isSafeVersion("1.2.3+build.5")).toBe(true);
});
it("rejects anything that could carry shell metacharacters or isn't semver", () => {
expect(isSafeVersion("")).toBe(false);
expect(isSafeVersion("latest")).toBe(false);
expect(isSafeVersion("1.2")).toBe(false);
expect(isSafeVersion("1.2.3; rm -rf /")).toBe(false);
expect(isSafeVersion("1.2.3 && curl evil")).toBe(false);
expect(isSafeVersion("$(whoami)")).toBe(false);
});
});
/**
* Drive printUpdateNotice under controlled mocks. isDevMode() is true under
* vitest (the module path ends in .ts), which would suppress the notice, so we
* mock ./env.js. detectInstaller and readConfig are mocked to pick the branch.
*/
async function noticeWith(opts: {
installerCommand: string | null;
latestVersion?: string;
isTTY?: boolean;
env?: Record<string, string | undefined>;
unattended?: boolean;
pendingVersion?: string;
}): Promise<string> {
vi.resetModules();
vi.doMock("./env.js", () => ({ isDevMode: () => false }));
vi.doMock("./attendedTerminal.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./attendedTerminal.js")>()),
isUnattended: () => opts.unattended ?? false,
}));
vi.doMock("./installerDetection.js", () => ({
detectInstaller: () => ({
kind: opts.installerCommand ? "npm" : "skip",
installCommand: () => opts.installerCommand,
reason: "test",
}),
}));
vi.doMock("../telemetry/config.js", () => ({
readConfig: () => ({
latestVersion: opts.latestVersion ?? "9.9.9",
pendingUpdate: opts.pendingVersion && {
version: opts.pendingVersion,
command: "npm install -g hyperframes",
startedAt: new Date().toISOString(),
},
}),
writeConfig: () => {},
}));
const origEnv = { ...process.env };
for (const [k, v] of Object.entries(opts.env ?? {})) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
// Default to a non-CI interactive terminal unless the test overrides env.
for (const name of ["CI", "HYPERFRAMES_NO_UPDATE_CHECK"]) {
if (!(name in (opts.env ?? {}))) delete process.env[name];
}
const origTTY = process.stderr.isTTY;
Object.defineProperty(process.stderr, "isTTY", {
value: opts.isTTY ?? true,
configurable: true,
});
const writes: string[] = [];
const origWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = ((chunk: unknown) => {
writes.push(String(chunk));
return true;
}) as typeof process.stderr.write;
try {
const mod = await import("./updateCheck.js");
mod.printUpdateNotice();
} finally {
process.stderr.write = origWrite;
Object.defineProperty(process.stderr, "isTTY", { value: origTTY, configurable: true });
process.env = origEnv;
}
return writes.join("");
}
describe("printUpdateNotice — install-method-aware command", () => {
afterEach(() => {
vi.doUnmock("./env.js");
vi.doUnmock("./attendedTerminal.js");
vi.doUnmock("./installerDetection.js");
vi.doUnmock("../telemetry/config.js");
vi.resetModules();
});
it("shows the detected manager's command for an owned global install", async () => {
const out = await noticeWith({ installerCommand: "brew upgrade hyperframes" });
expect(out).toContain("Update available");
expect(out).toContain("brew upgrade hyperframes");
expect(out).not.toContain("npx hyperframes@latest");
});
it("falls back to npx hyperframes@latest when the install method is skip/unknown", async () => {
const out = await noticeWith({ installerCommand: null });
expect(out).toContain("npx hyperframes@latest");
});
it("is suppressed on a non-TTY stderr", async () => {
const out = await noticeWith({ installerCommand: "brew upgrade hyperframes", isTTY: false });
expect(out).toBe("");
});
it("is suppressed when the run is unattended", async () => {
const out = await noticeWith({ installerCommand: "npm i -g hyperframes", unattended: true });
expect(out).toBe("");
});
it("does not tell the user to install a version a background install is already installing", async () => {
const out = await noticeWith({
installerCommand: "npm i -g hyperframes",
pendingVersion: "9.9.9",
});
expect(out).toBe("");
});
it("is suppressed in CI", async () => {
const out = await noticeWith({
installerCommand: "brew upgrade hyperframes",
env: { CI: "true" },
});
expect(out).toBe("");
});
it("is suppressed by the HYPERFRAMES_NO_UPDATE_CHECK opt-out", async () => {
const out = await noticeWith({
installerCommand: "brew upgrade hyperframes",
env: { HYPERFRAMES_NO_UPDATE_CHECK: "1" },
});
expect(out).toBe("");
});
it("does not advertise an update from an invalid cached version", async () => {
expect(await noticeWith({ installerCommand: null, latestVersion: "not-a-version" })).toBe("");
});
});
/**
* The registry-boundary guard: a poisoned or non-string data.version must
* never be cached, because it flows into the auto-updater's install command.
* This closes the injection class for every downstream consumer at one point.
*/
async function checkWith(
registryVersion: unknown,
force = true,
): Promise<{
latest: string;
wroteVersion: string | undefined;
fetched: boolean;
}> {
vi.resetModules();
vi.doMock("./env.js", () => ({ isDevMode: () => false }));
const writes: Array<Record<string, unknown>> = [];
vi.doMock("../telemetry/config.js", () => ({
readConfig: () => ({}),
readConfigFresh: () => ({}),
writeConfig: (c: Record<string, unknown>) => writes.push({ ...c }),
}));
const origFetch = globalThis.fetch;
let fetched = false;
globalThis.fetch = (async () => {
fetched = true;
return { ok: true, json: async () => ({ version: registryVersion }) };
}) as unknown as typeof fetch;
try {
const mod = await import("./updateCheck.js");
const result = await mod.checkForUpdate(force);
const lastWrite = writes.at(-1);
return {
latest: result.latest,
wroteVersion: lastWrite ? (lastWrite["latestVersion"] as string | undefined) : undefined,
fetched,
};
} finally {
globalThis.fetch = origFetch;
}
}
async function checkAcrossConcurrentConfigWrite(): Promise<Record<string, unknown>> {
vi.resetModules();
const initial = {
telemetryEnabled: true,
anonymousId: "test-install",
telemetryNoticeShown: true,
commandCount: 0,
renderSuccessCount: 0,
lastFeedbackPromptAt: 0,
};
let persisted: Record<string, unknown> = { ...initial };
vi.doMock("../telemetry/config.js", () => ({
readConfig: () => ({ ...initial }),
readConfigFresh: () => ({ ...persisted }),
writeConfig: (config: Record<string, unknown>) => {
persisted = { ...config };
return true;
},
}));
const origFetch = globalThis.fetch;
let releaseResponse: (() => void) | undefined;
const responseReady = new Promise<void>((resolve) => {
releaseResponse = resolve;
});
globalThis.fetch = (async () => {
await responseReady;
return {
ok: true,
json: async () => ({ version: "9.9.9" }),
};
}) as unknown as typeof fetch;
try {
const mod = await import("./updateCheck.js");
const check = mod.checkForUpdate(true);
persisted = { ...persisted, telemetryEnabled: false };
releaseResponse?.();
await check;
return persisted;
} finally {
globalThis.fetch = origFetch;
}
}
/**
* U5: validate/inspect/layout are deprecated in favor of `check`. withMeta's
* optional `{ deprecated: true }` is the single place that adds `_meta.deprecated`
* to a --json envelope; every other command (check, lint, ...) calls withMeta
* with no second argument and must never see the key at all — not even `false`.
*/
describe("withMeta — deprecated flag", () => {
it("omits _meta.deprecated entirely when no options are passed (check/lint et al.)", () => {
const wrapped = withMeta({ ok: true });
expect("deprecated" in wrapped._meta).toBe(false);
});
it("omits _meta.deprecated when options.deprecated is false", () => {
const wrapped = withMeta({ ok: true }, { deprecated: false });
expect("deprecated" in wrapped._meta).toBe(false);
});
it("sets _meta.deprecated === true when requested (validate/inspect/layout)", () => {
const wrapped = withMeta({ ok: true }, { deprecated: true });
expect(wrapped._meta.deprecated).toBe(true);
});
it("preserves the rest of the _meta envelope alongside the deprecated flag", () => {
const wrapped = withMeta({ ok: true }, { deprecated: true });
expect(wrapped._meta.version).toEqual(expect.any(String));
expect(typeof wrapped._meta.updateAvailable).toBe("boolean");
});
});
/**
* The stderr-only deprecation notice: printed once per invocation, never on
* stdout, so --json output stays pure JSON while humans still see the notice.
*/
describe("printDeprecationNotice", () => {
it("writes exactly one line to stderr, never stdout", () => {
const stderrWrites: string[] = [];
const stdoutWrites: string[] = [];
const origErrWrite = process.stderr.write.bind(process.stderr);
const origOutWrite = process.stdout.write.bind(process.stdout);
process.stderr.write = ((chunk: unknown) => {
stderrWrites.push(String(chunk));
return true;
}) as typeof process.stderr.write;
process.stdout.write = ((chunk: unknown) => {
stdoutWrites.push(String(chunk));
return true;
}) as typeof process.stdout.write;
try {
printDeprecationNotice("validate");
} finally {
process.stderr.write = origErrWrite;
process.stdout.write = origOutWrite;
}
expect(stdoutWrites).toEqual([]);
expect(stderrWrites).toHaveLength(1);
expect(stderrWrites[0]).toContain("hyperframes validate");
expect(stderrWrites[0]).toContain("hyperframes check");
});
});
describe("checkForUpdate — registry boundary guard", () => {
afterEach(() => {
vi.doUnmock("../telemetry/config.js");
vi.doUnmock("./env.js");
vi.resetModules();
});
function clearOptOuts(): void {
vi.stubEnv("CI", "");
vi.stubEnv("HYPERFRAMES_NO_UPDATE_CHECK", "");
}
it("still asks the registry from a run without a terminal", async () => {
clearOptOuts();
const origTTY = process.stderr.isTTY;
Object.defineProperty(process.stderr, "isTTY", { value: false, configurable: true });
try {
expect((await checkWith("99.0.0", false)).fetched).toBe(true);
} finally {
Object.defineProperty(process.stderr, "isTTY", { value: origTTY, configurable: true });
vi.unstubAllEnvs();
}
});
it("skips the background registry request when DNS does not answer", async () => {
clearOptOuts();
dns.answers = false;
try {
expect((await checkWith("99.0.0", false)).fetched).toBe(false);
expect((await checkWith("99.0.0")).fetched).toBe(true);
} finally {
dns.answers = true;
vi.unstubAllEnvs();
}
});
it.each(["CI", "HYPERFRAMES_NO_UPDATE_CHECK"])(
"skips the background registry request when %s=1",
async (name) => {
clearOptOuts();
vi.stubEnv(name, "1");
try {
expect((await checkWith("99.0.0", false)).fetched).toBe(false);
expect((await checkWith("99.0.0")).fetched).toBe(true);
} finally {
vi.unstubAllEnvs();
}
},
);
it("caches and returns a valid semver from the registry", async () => {
const { latest, wroteVersion } = await checkWith("9.9.9");
expect(latest).toBe("9.9.9");
expect(wroteVersion).toBe("9.9.9");
});
it("rejects a version carrying shell metacharacters (no cache, no surface)", async () => {
const { latest, wroteVersion } = await checkWith("1.2.3; rm -rf /");
expect(latest).not.toContain(";");
expect(latest).not.toBe("1.2.3; rm -rf /");
expect(wroteVersion).toBeUndefined(); // never written to config
});
it("rejects a non-string data.version", async () => {
const { latest, wroteVersion } = await checkWith({ evil: true });
expect(typeof latest).toBe("string");
expect(wroteVersion).toBeUndefined();
});
it("leaves no timer behind when the registry request fails fast", async () => {
vi.resetModules();
vi.doMock("../telemetry/config.js", () => ({
readConfig: () => ({}),
readConfigFresh: () => ({}),
writeConfig: () => {},
}));
const origFetch = globalThis.fetch;
globalThis.fetch = (async () => {
throw new TypeError("fetch failed");
}) as unknown as typeof fetch;
vi.useFakeTimers();
try {
const mod = await import("./updateCheck.js");
await mod.checkForUpdate(true);
expect(vi.getTimerCount()).toBe(0);
} finally {
vi.useRealTimers();
globalThis.fetch = origFetch;
}
});
it("merges update metadata into a fresh snapshot without re-enabling telemetry", async () => {
const persisted = await checkAcrossConcurrentConfigWrite();
expect(persisted).toMatchObject({
telemetryEnabled: false,
latestVersion: "9.9.9",
});
});
});
describe("updateCheckDue / cachedUpdateCheck — what the parent reads without fetching", () => {
afterEach(() => {
vi.doUnmock("../telemetry/config.js");
vi.doUnmock("./env.js");
vi.resetModules();
vi.unstubAllEnvs();
});
async function load(config: Record<string, unknown>) {
vi.resetModules();
vi.stubEnv("CI", "");
vi.stubEnv("HYPERFRAMES_NO_UPDATE_CHECK", "");
vi.doMock("./env.js", () => ({ isDevMode: () => false }));
vi.doMock("../telemetry/config.js", () => ({
readConfig: () => ({ ...config }),
readConfigFresh: () => ({ ...config }),
writeConfig: () => {},
}));
return import("./updateCheck.js");
}
const hoursAgo = (h: number) => new Date(Date.now() - h * 3_600_000).toISOString();
it.each([
["no cache", {}, true],
["a cache from an hour ago", { lastUpdateCheck: hoursAgo(1), latestVersion: "9.9.9" }, false],
["a cache from 25 hours ago", { lastUpdateCheck: hoursAgo(25), latestVersion: "9.9.9" }, true],
["an unreadable timestamp", { lastUpdateCheck: "garbage", latestVersion: "9.9.9" }, true],
[
"an unsafe cached version",
{ lastUpdateCheck: hoursAgo(1), latestVersion: "1.2.3; rm" },
true,
],
])("with %s, a check is due: %s", async (_, config, due) => {
const mod = await load(config);
expect(mod.updateCheckDue()).toBe(due);
});
it("is never due under CI", async () => {
const mod = await load({});
vi.stubEnv("CI", "1");
expect(mod.updateCheckDue()).toBe(false);
});
it("reports a cached newer version, and never surfaces an unsafe one", async () => {
expect((await load({ latestVersion: "999.0.0" })).cachedUpdateCheck()).toMatchObject({
latest: "999.0.0",
updateAvailable: true,
});
const poisoned = (await load({ latestVersion: "999.0.0; rm -rf /" })).cachedUpdateCheck();
expect(poisoned.updateAvailable).toBe(false);
expect(poisoned.latest).not.toContain(";");
});
it.each(["not-a-version", "999.0.0; invalid", 999, ["999.0.0"], {}, null, undefined])(
"omits invalid cached version %j from JSON metadata",
async (latestVersion) => {
const mod = await load({ latestVersion });
const wrapped = mod.withMeta({ ok: true });
expect(wrapped._meta).toEqual({
version: mod.cachedUpdateCheck().current,
latestVersion: undefined,
updateAvailable: false,
});
expect(JSON.parse(JSON.stringify(wrapped))).toEqual({
ok: true,
_meta: { version: wrapped._meta.version, updateAvailable: false },
});
},
);
it.each(["0.0.1", "999.0.0", "999.0.0-beta.1+build.5"])(
"preserves safe cached version %s and the update-check result in metadata",
async (latestVersion) => {
const mod = await load({ latestVersion });
expect(mod.getUpdateMeta()).toEqual({
version: mod.cachedUpdateCheck().current,
latestVersion,
updateAvailable: mod.cachedUpdateCheck().updateAvailable,
});
},
);
});