1- import { existsSync , mkdirSync , readFileSync , renameSync , writeFileSync } from "node:fs" ;
1+ import { existsSync , mkdirSync , readFileSync , renameSync , rmSync , writeFileSync } from "node:fs" ;
22import { join } from "node:path" ;
33import { homedir } from "node:os" ;
44import { randomUUID } from "node:crypto" ;
@@ -12,30 +12,44 @@ const CONFIG_DIR = join(homedir(), ".hyperframes");
1212const 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
4054interface 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 */
78126function 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