Skip to content

Commit fa56454

Browse files
authored
Merge pull request #2904 from heygen-com/07-30-fix_cli_move_install-state_into_the_config_dir_so_deleting_it_is_a_full_reset
fix(cli): move install-state into the config dir so deleting it is a full reset
2 parents 5a6e4b1 + dae1b63 commit fa56454

2 files changed

Lines changed: 119 additions & 25 deletions

File tree

packages/cli/src/telemetry/config.test.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { homedir } from "node:os";
3+
import { join } from "node:path";
24

35
// In-memory fake filesystem so these tests exercise the REAL config.ts
46
// module (parsing, caching, readConfigFresh's cache-bypass) without ever
@@ -28,6 +30,10 @@ vi.mock("node:fs", () => ({
2830
fsState.files.set(to, content);
2931
fsState.files.delete(from);
3032
}),
33+
// Legacy install-state cleanup after the migration to CONFIG_DIR.
34+
rmSync: vi.fn((path: string) => {
35+
fsState.files.delete(path);
36+
}),
3137
}));
3238

3339
describe("config.ts — readConfig / readConfigFresh / writeConfig (real module, faked fs)", () => {
@@ -130,13 +136,18 @@ describe("config.ts — readConfig / readConfigFresh / writeConfig (real module,
130136
});
131137
});
132138

133-
describe("install-state rollover (breaker survives a config wipe)", () => {
139+
describe("install-state rollover (breaker survives a config re-mint)", () => {
134140
let readConfig: typeof import("./config.js").readConfig;
135141
let readConfigFresh: typeof import("./config.js").readConfigFresh;
136142
let writeConfig: typeof import("./config.js").writeConfig;
137143
let CONFIG_PATH: typeof import("./config.js").CONFIG_PATH;
138144
let STATE_PATH: typeof import("./config.js").STATE_PATH;
139145

146+
// Derived here rather than exported from config.ts: the pre-move path is
147+
// frozen history, so pinning the literal is the point — an export would
148+
// just let a rename pass silently, and it has no non-test consumer.
149+
const LEGACY_STATE_PATH = join(homedir(), ".local", "state", "hyperframes", "install-state.json");
150+
140151
beforeEach(async () => {
141152
fsState.files.clear();
142153
vi.resetModules();
@@ -245,4 +256,39 @@ describe("install-state rollover (breaker survives a config wipe)", () => {
245256
fsState.files.set(CONFIG_PATH, JSON.stringify(legacy));
246257
expect(readConfigFresh().predecessorFound).toBeUndefined();
247258
});
259+
260+
// The move: state used to live in ~/.local/state/hyperframes/ so it would
261+
// survive `rm -rf ~/.hyperframes`. Review rejected persisting state outside
262+
// the config dir to defeat the user's reset, so it now shares CONFIG_DIR.
263+
it("keeps install-state inside the config dir, so deleting that dir is a full reset", () => {
264+
readConfig();
265+
expect(STATE_PATH.startsWith(CONFIG_PATH.replace(/config\.json$/, ""))).toBe(true);
266+
expect(STATE_PATH).not.toContain(".local");
267+
});
268+
269+
it("adopts a pre-move state file so an upgrading install keeps its tripped breaker", () => {
270+
fsState.files.set(
271+
LEGACY_STATE_PATH,
272+
JSON.stringify({ markerAt: "2026-07-28T00:00:00.000Z", deParallelRouterTrialFired: true }),
273+
);
274+
// Config absent => mintConfig consults install state; without the
275+
// migration this install silently re-enrols in the failed trial.
276+
const config = readConfig();
277+
expect(config.deParallelRouterTrialFired).toBe(true);
278+
expect(config.predecessorFound).toBe(true);
279+
expect(fsState.files.has(STATE_PATH), "state migrated into CONFIG_DIR").toBe(true);
280+
expect(fsState.files.has(LEGACY_STATE_PATH), "legacy copy removed").toBe(false);
281+
});
282+
283+
it("lets the current location win over a stale legacy file, and deletes the legacy copy", () => {
284+
// A user who cleared the breaker must not have it resurrected by a
285+
// leftover file from the old scheme.
286+
fsState.files.set(
287+
LEGACY_STATE_PATH,
288+
JSON.stringify({ markerAt: "2026-07-28T00:00:00.000Z", deParallelRouterTrialFired: true }),
289+
);
290+
fsState.files.set(STATE_PATH, JSON.stringify({ markerAt: "2026-07-30T00:00:00.000Z" }));
291+
expect(readConfig().deParallelRouterTrialFired).toBeUndefined();
292+
expect(fsState.files.has(LEGACY_STATE_PATH)).toBe(false);
293+
});
248294
});

packages/cli/src/telemetry/config.ts

Lines changed: 72 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
1+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
22
import { join } from "node:path";
33
import { homedir } from "node:os";
44
import { randomUUID } from "node:crypto";
@@ -12,30 +12,44 @@ const CONFIG_DIR = join(homedir(), ".hyperframes");
1212
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
1313

1414
// ---------------------------------------------------------------------------
15-
// Install-state file: ~/.local/state/hyperframes/install-state.json
15+
// Install-state file: ~/.hyperframes/install-state.json
1616
//
17-
// A second, deliberately separate location from CONFIG_DIR, so it survives
18-
// the most common identity reset — deleting or reinstalling ~/.hyperframes.
19-
// It exists to carry exactly two facts across that reset, and nothing else:
17+
// A separate FILE, but deliberately the same DIRECTORY as config.json, so
18+
// `rm -rf ~/.hyperframes` really is a full reset. It previously lived in
19+
// ~/.local/state/hyperframes/ specifically to survive that delete; review
20+
// rejected that ("if someone is deleting their hyperframes config it should
21+
// wipe all hyperframes state — I'm not sure we should try and persist state
22+
// elsewhere to get around this"), and the measurement agreed: the churn this
23+
// actually defends against is not users running `rm -rf`.
2024
//
21-
// 1. `markerAt` — "a hyperframes install existed on this machine". Written
22-
// unconditionally, so the fraction of fresh installs that find it is a
23-
// direct measurement of recoverable id churn (config wiped, machine
24-
// persisted) vs unrecoverable (fresh machine/container/new user).
25+
// The threat it does defend against is config.json itself. That file is hot
26+
// and wide — ~20 fields rewritten on every command and every render — and
27+
// readConfig recovers from ANY parse/permission/IO failure by minting a fresh
28+
// identity. Splitting these two facts into their own file decouples them from
29+
// that churn: no shared schema to migrate on upgrade, and one write at first
30+
// mint instead of one per command.
31+
//
32+
// It carries exactly two facts, and no identity — no anonymousId, no
33+
// counters, nothing linking the old install to the new one:
34+
//
35+
// 1. `markerAt` — "a hyperframes install existed on this machine". Now that
36+
// it shares CONFIG_DIR, `predecessorFound` measures the churn we care
37+
// about (config.json lost, install-state survived => corruption/re-mint)
38+
// rather than deliberate directory deletion, which takes both.
2539
// 2. `deParallelRouterTrialFired` — the DE parallel-router circuit
26-
// breaker's tripped state. Without this, a config wipe re-enrols the
27-
// install into an experimental path that already FAILED on this exact
28-
// machine; the breaker's whole point is that a real failure turns the
29-
// trial off for good.
40+
// breaker's tripped state, so a config re-mint does not re-enrol an
41+
// install into an experimental path that already FAILED on this machine.
3042
//
31-
// It intentionally holds NO identity: no anonymousId, no counters, nothing
32-
// that could link the old install to the new one. A user who wipes their
33-
// config gets a fresh id unconditionally — this file only stops the wipe
34-
// from also discarding a safety fact about the machine.
43+
// Removal path: delete ~/.hyperframes (or just this file). `hyperframes
44+
// telemetry status` prints its exact location.
3545
// ---------------------------------------------------------------------------
3646

37-
const STATE_DIR = join(homedir(), ".local", "state", "hyperframes");
38-
const STATE_FILE = join(STATE_DIR, "install-state.json");
47+
const STATE_FILE = join(CONFIG_DIR, "install-state.json");
48+
49+
// Pre-move location. Read once, migrated, then deleted — an install that
50+
// wrote state under the old scheme keeps its tripped breaker instead of
51+
// silently re-enrolling, and no file is left behind outside CONFIG_DIR.
52+
const LEGACY_STATE_FILE = join(homedir(), ".local", "state", "hyperframes", "install-state.json");
3953

4054
interface InstallState {
4155
/** ISO timestamp of when the marker was first written. */
@@ -44,11 +58,11 @@ interface InstallState {
4458
deParallelRouterTrialFired?: boolean;
4559
}
4660

47-
/** Read the install-state file; any parse/shape failure reads as absent. */
48-
function readInstallState(): InstallState | null {
61+
/** Parse one state file; any parse/shape failure reads as absent. */
62+
function parseInstallState(file: string): InstallState | null {
4963
try {
50-
if (!existsSync(STATE_FILE)) return null;
51-
const parsed = JSON.parse(readFileSync(STATE_FILE, "utf-8")) as Partial<InstallState>;
64+
if (!existsSync(file)) return null;
65+
const parsed = JSON.parse(readFileSync(file, "utf-8")) as Partial<InstallState>;
5266
if (typeof parsed.markerAt !== "string") return null;
5367
return {
5468
markerAt: parsed.markerAt,
@@ -59,6 +73,40 @@ function readInstallState(): InstallState | null {
5973
}
6074
}
6175

76+
/**
77+
* Read the install-state file, adopting the pre-move copy if this machine
78+
* still has one.
79+
*
80+
* Migration is one-way and best-effort: the current location always wins (a
81+
* stale legacy file must never resurrect a breaker the user has since
82+
* cleared), and failing to delete the legacy copy is not an error — it is
83+
* re-read harmlessly next time.
84+
*/
85+
function readInstallState(): InstallState | null {
86+
const current = parseInstallState(STATE_FILE);
87+
if (current !== null) {
88+
removeLegacyStateFile();
89+
return current;
90+
}
91+
const legacy = parseInstallState(LEGACY_STATE_FILE);
92+
if (legacy === null) return null;
93+
try {
94+
writeInstallState(legacy);
95+
removeLegacyStateFile();
96+
} catch {
97+
// Keep the legacy copy; the value is still returned below either way.
98+
}
99+
return legacy;
100+
}
101+
102+
function removeLegacyStateFile(): void {
103+
try {
104+
if (existsSync(LEGACY_STATE_FILE)) rmSync(LEGACY_STATE_FILE, { force: true });
105+
} catch {
106+
// Best-effort cleanup — never break the CLI over a leftover file.
107+
}
108+
}
109+
62110
// Sync bookkeeping, so the existsSync+read doesn't run on every writeConfig:
63111
// `stateMarkerSynced` = the marker is known present; `stateFiredSynced` = the
64112
// state file is known to already carry fired=true.
@@ -76,7 +124,7 @@ export function __resetInstallStateSyncForTests(): void {
76124
* since a corrupted state file silently reads as absent.
77125
*/
78126
function writeInstallState(next: InstallState): void {
79-
mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
127+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
80128
const tmpFile = `${STATE_FILE}.${process.pid}.tmp`;
81129
writeFileSync(tmpFile, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
82130
renameSync(tmpFile, STATE_FILE);

0 commit comments

Comments
 (0)