mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 01:56:04 +00:00
458 lines
19 KiB
TypeScript
458 lines
19 KiB
TypeScript
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { homedir } from "node:os";
|
|
import { randomUUID } from "node:crypto";
|
|
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Config directory: ~/.hyperframes/
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const CONFIG_DIR = join(homedir(), ".hyperframes");
|
|
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Install-state file: ~/.hyperframes/install-state.json
|
|
//
|
|
// A separate FILE, but deliberately the same DIRECTORY as config.json, so
|
|
// `rm -rf ~/.hyperframes` really is a full reset. It previously lived in
|
|
// ~/.local/state/hyperframes/ specifically to survive that delete; review
|
|
// rejected that ("if someone is deleting their hyperframes config it should
|
|
// wipe all hyperframes state — I'm not sure we should try and persist state
|
|
// elsewhere to get around this"), and the measurement agreed: the churn this
|
|
// actually defends against is not users running `rm -rf`.
|
|
//
|
|
// The threat it does defend against is config.json itself. That file is hot
|
|
// and wide — ~20 fields rewritten on every command and every render — and
|
|
// readConfig recovers from ANY parse/permission/IO failure by minting a fresh
|
|
// identity. Splitting these two facts into their own file decouples them from
|
|
// that churn: no shared schema to migrate on upgrade, and one write at first
|
|
// mint instead of one per command.
|
|
//
|
|
// It carries exactly two facts, and no identity — no anonymousId, no
|
|
// counters, nothing linking the old install to the new one:
|
|
//
|
|
// 1. `markerAt` — "a hyperframes install existed on this machine". Now that
|
|
// it shares CONFIG_DIR, `predecessorFound` measures the churn we care
|
|
// about (config.json lost, install-state survived => corruption/re-mint)
|
|
// rather than deliberate directory deletion, which takes both.
|
|
// 2. `deParallelRouterTrialFired` — the DE parallel-router circuit
|
|
// breaker's tripped state, so a config re-mint does not re-enrol an
|
|
// install into an experimental path that already FAILED on this machine.
|
|
//
|
|
// Removal path: delete ~/.hyperframes (or just this file). `hyperframes
|
|
// telemetry status` prints its exact location.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const STATE_FILE = join(CONFIG_DIR, "install-state.json");
|
|
|
|
// Pre-move location. Read once, migrated, then deleted — an install that
|
|
// wrote state under the old scheme keeps its tripped breaker instead of
|
|
// silently re-enrolling, and no file is left behind outside CONFIG_DIR.
|
|
const LEGACY_STATE_FILE = join(homedir(), ".local", "state", "hyperframes", "install-state.json");
|
|
|
|
interface InstallState {
|
|
/** ISO timestamp of when the marker was first written. */
|
|
markerAt: string;
|
|
/** Rolled-over circuit-breaker state — see HyperframesConfig's field. */
|
|
deParallelRouterTrialFired?: boolean;
|
|
}
|
|
|
|
/** Parse one state file; any parse/shape failure reads as absent. */
|
|
function parseInstallState(file: string): InstallState | null {
|
|
try {
|
|
if (!existsSync(file)) return null;
|
|
const parsed = JSON.parse(readFileSync(file, "utf-8")) as Partial<InstallState>;
|
|
if (typeof parsed.markerAt !== "string") return null;
|
|
return {
|
|
markerAt: parsed.markerAt,
|
|
deParallelRouterTrialFired: parsed.deParallelRouterTrialFired === true ? true : undefined,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read the install-state file, adopting the pre-move copy if this machine
|
|
* still has one.
|
|
*
|
|
* Migration is one-way and best-effort: the current location always wins (a
|
|
* stale legacy file must never resurrect a breaker the user has since
|
|
* cleared), and failing to delete the legacy copy is not an error — it is
|
|
* re-read harmlessly next time.
|
|
*/
|
|
function readInstallState(): InstallState | null {
|
|
const current = parseInstallState(STATE_FILE);
|
|
if (current !== null) {
|
|
removeLegacyStateFile();
|
|
return current;
|
|
}
|
|
const legacy = parseInstallState(LEGACY_STATE_FILE);
|
|
if (legacy === null) return null;
|
|
try {
|
|
writeInstallState(legacy);
|
|
removeLegacyStateFile();
|
|
} catch {
|
|
// Keep the legacy copy; the value is still returned below either way.
|
|
}
|
|
return legacy;
|
|
}
|
|
|
|
function removeLegacyStateFile(): void {
|
|
try {
|
|
if (existsSync(LEGACY_STATE_FILE)) rmSync(LEGACY_STATE_FILE, { force: true });
|
|
} catch {
|
|
// Best-effort cleanup — never break the CLI over a leftover file.
|
|
}
|
|
}
|
|
|
|
// Sync bookkeeping, so the existsSync+read doesn't run on every writeConfig:
|
|
// `stateMarkerSynced` = the marker is known present; `stateFiredSynced` = the
|
|
// state file is known to already carry fired=true.
|
|
let stateMarkerSynced = false;
|
|
let stateFiredSynced = false;
|
|
|
|
/** Test-only: reset the sync memo (module state leaks across vitest cases). */
|
|
export function __resetInstallStateSyncForTests(): void {
|
|
stateMarkerSynced = false;
|
|
stateFiredSynced = false;
|
|
}
|
|
|
|
/**
|
|
* Atomic for the same reason writeConfig is: a torn read must never exist,
|
|
* since a corrupted state file silently reads as absent.
|
|
*/
|
|
function writeInstallState(next: InstallState): void {
|
|
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
const tmpFile = `${STATE_FILE}.${process.pid}.tmp`;
|
|
writeFileSync(tmpFile, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
|
|
renameSync(tmpFile, STATE_FILE);
|
|
}
|
|
|
|
/**
|
|
* Bring the install-state file up to date with this config write: ensure the
|
|
* marker exists, and mirror a tripped breaker. Called from `writeConfig` so
|
|
* no breaker write site can forget it. Never throws — same contract as the
|
|
* rest of this file, telemetry must not break the CLI.
|
|
*/
|
|
/** What the state file should say after this config write; null = already correct. */
|
|
function nextInstallState(state: InstallState | null, wantFired: boolean): InstallState | null {
|
|
const hadFired = state?.deParallelRouterTrialFired === true;
|
|
if (state !== null && (hadFired || !wantFired)) return null;
|
|
// Every path reaching here has hadFired === false (state is either null, or
|
|
// the guard above already returned when hadFired was true) — the field is
|
|
// simply wantFired, not a merge of the two (review nit, two independent
|
|
// reviewers).
|
|
return {
|
|
markerAt: state?.markerAt ?? new Date().toISOString(),
|
|
deParallelRouterTrialFired: wantFired || undefined,
|
|
};
|
|
}
|
|
|
|
function syncInstallState(config: HyperframesConfig): void {
|
|
const wantFired = config.deParallelRouterTrialFired === true;
|
|
if (stateMarkerSynced && (stateFiredSynced || !wantFired)) return;
|
|
try {
|
|
const state = readInstallState();
|
|
const next = nextInstallState(state, wantFired);
|
|
if (next !== null) writeInstallState(next);
|
|
stateMarkerSynced = true;
|
|
stateFiredSynced = wantFired || state?.deParallelRouterTrialFired === true;
|
|
} catch {
|
|
// Leave the memo unset so a later write retries.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a brand-new config for an install with no (readable) config file,
|
|
* consulting the install-state file for what a previous install on this
|
|
* machine left behind.
|
|
*/
|
|
function mintConfig(): HyperframesConfig {
|
|
const state = readInstallState();
|
|
return {
|
|
...DEFAULT_CONFIG,
|
|
anonymousId: randomUUID(),
|
|
predecessorFound: state !== null,
|
|
// The rollover itself: a breaker tripped by a previous install on this
|
|
// machine stays tripped for the new one.
|
|
deParallelRouterTrialFired: state?.deParallelRouterTrialFired === true ? true : undefined,
|
|
};
|
|
}
|
|
|
|
export interface HyperframesConfig {
|
|
/** Whether anonymous telemetry is enabled (default: true in production) */
|
|
telemetryEnabled: boolean;
|
|
/** Stable anonymous identifier — no PII, just a random UUID */
|
|
anonymousId: string;
|
|
/** Whether the first-run telemetry notice has been shown */
|
|
telemetryNoticeShown: boolean;
|
|
/** Total CLI command invocations (for engagement prompts) */
|
|
commandCount: number;
|
|
/** Total successful renders (for feedback prompt gating) */
|
|
renderSuccessCount: number;
|
|
/** The renderSuccessCount at which feedback was last shown */
|
|
lastFeedbackPromptAt: number;
|
|
/** ISO timestamp of the last npm registry version check */
|
|
lastUpdateCheck?: string;
|
|
/** Latest version found on npm */
|
|
latestVersion?: string;
|
|
/** Throttle for the non-TTY stale-project-pin notice (ms epoch). */
|
|
lastStalePinNoticeAt?: number;
|
|
/**
|
|
* Auto-update marker. Set when a background install is spawned so a
|
|
* subsequent run can skip re-triggering it. Cleared once
|
|
* `completedUpdate` captures the outcome.
|
|
*/
|
|
pendingUpdate?: {
|
|
/** Version being installed. */
|
|
version: string;
|
|
/** Install command being run, for debug logging. */
|
|
command: string;
|
|
/** ISO timestamp of when the background install was launched. */
|
|
startedAt: string;
|
|
};
|
|
/**
|
|
* Outcome of the last completed auto-update, written by the detached
|
|
* installer. Surfaced once in the next invocation and then cleared.
|
|
*/
|
|
completedUpdate?: {
|
|
version: string;
|
|
/** Whether the install succeeded. */
|
|
ok: boolean;
|
|
/** ISO timestamp of when the installer finished. */
|
|
finishedAt: string;
|
|
/** Non-empty when `ok === false` — the installer's stderr tail. */
|
|
error?: string;
|
|
/** True after the result has been surfaced once to the user. */
|
|
reported?: boolean;
|
|
};
|
|
/** ISO timestamp of the last `skills check` freshness check (24h cache). */
|
|
lastSkillsCheck?: string;
|
|
/** Whether installed skills were stale at the last check. */
|
|
skillsUpdateAvailable?: boolean;
|
|
/** How many installed skills were outdated at the last check. */
|
|
skillsOutdatedCount?: number;
|
|
/** How many skills were missing (not installed) at the last check. */
|
|
skillsMissingCount?: number;
|
|
/** How many installed skills were flagged removed-upstream at the last check. */
|
|
skillsRemovedCount?: number;
|
|
/**
|
|
* True once the DE parallel-router experiment ("HF_DE_PARALLEL_ROUTER")
|
|
* has actually FAILED (its self-verify/generic-failure safety net fired —
|
|
* "reverted", not merely "routed") on a render from this install. The CLI
|
|
* enables the experiment for free on EVERY eligible render from a fresh
|
|
* install — not just once — to maximize real-traffic router telemetry
|
|
* (mostly successful "routed" outcomes) without requiring anyone to
|
|
* manually opt in via env var; only a real failure turns it off, and only
|
|
* for this install going forward. See `renderLocal`'s
|
|
* `maybeEnableDeParallelRouterTrial`/`maybeConsumeDeParallelRouterTrial`.
|
|
*/
|
|
deParallelRouterTrialFired?: boolean;
|
|
/**
|
|
* Count of engaged (routed or reverted) trial renders so far — the
|
|
* backstop that caps exposure even absent an actual failure. See
|
|
* `DE_PARALLEL_ROUTER_TRIAL_MAX_RENDERS` in `render.ts`.
|
|
*/
|
|
deParallelRouterTrialRenderCount?: number;
|
|
/**
|
|
* Whether a previous install's state marker existed on this machine when
|
|
* this config was minted. Attached to telemetry so the fraction of fresh
|
|
* installs that are RECOVERABLE churn (config wiped, machine persisted) is
|
|
* measurable directly. `undefined` on configs minted before this field
|
|
* existed — a different fact from `false` (minted fresh, no predecessor).
|
|
*/
|
|
predecessorFound?: boolean;
|
|
/**
|
|
* Ring of the last few local renders (newest last). `hyperframes feedback`
|
|
* attaches these ids — which are the `render_job_id` /
|
|
* `observability_render_job_id` on this install's PostHog events — to the
|
|
* feedback it submits, so a wild bug report can be joined to the exact
|
|
* telemetry rows of the renders it describes.
|
|
*/
|
|
recentRenders?: RecentRenderRecord[];
|
|
}
|
|
|
|
/** One entry in {@link HyperframesConfig.recentRenders}. */
|
|
export interface RecentRenderRecord {
|
|
/** The render job id (`RenderJob.id` — the telemetry `render_job_id`). */
|
|
id: string;
|
|
/** ISO timestamp of when the render finished. */
|
|
at: string;
|
|
/** Whether the render completed successfully. */
|
|
ok: boolean;
|
|
}
|
|
|
|
/** Ring size for {@link HyperframesConfig.recentRenders}. */
|
|
const MAX_RECENT_RENDERS = 5;
|
|
|
|
/**
|
|
* Append a finished render to the recent-renders ring (newest last, capped).
|
|
* Fresh read-modify-write like the trial counters — narrows, but does not
|
|
* eliminate, lost updates against a concurrent CLI process.
|
|
*/
|
|
export function recordRecentRender(id: string, ok: boolean): void {
|
|
const config = readConfigFresh();
|
|
const ring = [...(config.recentRenders ?? []), { id, at: new Date().toISOString(), ok }];
|
|
config.recentRenders = ring.slice(-MAX_RECENT_RENDERS);
|
|
writeConfig(config);
|
|
}
|
|
|
|
const DEFAULT_CONFIG: HyperframesConfig = {
|
|
telemetryEnabled: true,
|
|
anonymousId: "",
|
|
telemetryNoticeShown: false,
|
|
commandCount: 0,
|
|
renderSuccessCount: 0,
|
|
lastFeedbackPromptAt: 0,
|
|
};
|
|
|
|
let cachedConfig: HyperframesConfig | null = null;
|
|
|
|
/**
|
|
* Read the config file, creating it with defaults if it doesn't exist.
|
|
* Returns a mutable copy — call `writeConfig()` to persist changes.
|
|
*/
|
|
export function readConfig(): HyperframesConfig {
|
|
if (cachedConfig) return { ...cachedConfig };
|
|
|
|
if (!existsSync(CONFIG_FILE)) {
|
|
const config = mintConfig();
|
|
writeConfig(config);
|
|
return config;
|
|
}
|
|
|
|
try {
|
|
const raw = readFileSync(CONFIG_FILE, "utf-8");
|
|
const parsed = JSON.parse(raw) as Partial<HyperframesConfig>;
|
|
|
|
const config: HyperframesConfig = {
|
|
telemetryEnabled: parsed.telemetryEnabled ?? DEFAULT_CONFIG.telemetryEnabled,
|
|
anonymousId: parsed.anonymousId || randomUUID(),
|
|
telemetryNoticeShown: parsed.telemetryNoticeShown ?? DEFAULT_CONFIG.telemetryNoticeShown,
|
|
commandCount: parsed.commandCount ?? DEFAULT_CONFIG.commandCount,
|
|
renderSuccessCount: parsed.renderSuccessCount ?? DEFAULT_CONFIG.renderSuccessCount,
|
|
lastFeedbackPromptAt: parsed.lastFeedbackPromptAt ?? DEFAULT_CONFIG.lastFeedbackPromptAt,
|
|
lastUpdateCheck: parsed.lastUpdateCheck,
|
|
latestVersion: parsed.latestVersion,
|
|
lastStalePinNoticeAt: parsed.lastStalePinNoticeAt,
|
|
pendingUpdate: parsed.pendingUpdate,
|
|
completedUpdate: parsed.completedUpdate,
|
|
lastSkillsCheck: parsed.lastSkillsCheck,
|
|
skillsUpdateAvailable: parsed.skillsUpdateAvailable,
|
|
skillsOutdatedCount: parsed.skillsOutdatedCount,
|
|
skillsMissingCount: parsed.skillsMissingCount,
|
|
skillsRemovedCount: parsed.skillsRemovedCount,
|
|
// Explicit `=== true`/typeof-number checks rather than a truthy/nullish
|
|
// read — a hand-edited or corrupted config could plausibly carry a
|
|
// non-boolean/non-number JSON value (e.g. the STRING "false", which is
|
|
// truthy in JS) for these two fields specifically, since they're read
|
|
// with a bare truthy check at the call site (review finding).
|
|
deParallelRouterTrialFired: parsed.deParallelRouterTrialFired === true ? true : undefined,
|
|
deParallelRouterTrialRenderCount:
|
|
typeof parsed.deParallelRouterTrialRenderCount === "number"
|
|
? parsed.deParallelRouterTrialRenderCount
|
|
: undefined,
|
|
predecessorFound:
|
|
typeof parsed.predecessorFound === "boolean" ? parsed.predecessorFound : undefined,
|
|
recentRenders: Array.isArray(parsed.recentRenders)
|
|
? parsed.recentRenders
|
|
.filter(
|
|
(r): r is RecentRenderRecord =>
|
|
typeof r === "object" &&
|
|
r !== null &&
|
|
typeof (r as RecentRenderRecord).id === "string" &&
|
|
typeof (r as RecentRenderRecord).at === "string" &&
|
|
typeof (r as RecentRenderRecord).ok === "boolean",
|
|
)
|
|
.slice(-MAX_RECENT_RENDERS)
|
|
: undefined,
|
|
};
|
|
|
|
cachedConfig = config;
|
|
return { ...config };
|
|
} catch {
|
|
// A missing file is handled above. Any failure here means an existing
|
|
// preference could not be read safely (corrupt JSON, permissions, I/O).
|
|
// Recover through the same mint path as a missing file — so a tripped
|
|
// breaker survives config corruption too — but fail closed for the
|
|
// privacy control: recovery must never silently turn telemetry back on.
|
|
const config = { ...mintConfig(), telemetryEnabled: false };
|
|
writeConfig(config);
|
|
return config;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Re-read the config from disk, bypassing the in-process cache. Use
|
|
* immediately before a targeted single-field read-modify-write (e.g. the DE
|
|
* parallel-router trial's render count/fired flag) to narrow — though not
|
|
* eliminate, there is no cross-process file locking here — the window for a
|
|
* lost update against a concurrently-running CLI process that wrote other
|
|
* fields in the meantime.
|
|
*/
|
|
export function readConfigFresh(): HyperframesConfig {
|
|
cachedConfig = null;
|
|
return readConfig();
|
|
}
|
|
|
|
/**
|
|
* Persist config to disk. Updates the in-memory cache on success.
|
|
*
|
|
* Atomic: writes to a pid-suffixed temp file and renames it over the config —
|
|
* `rename(2)` within one directory is atomic on POSIX, so a concurrent
|
|
* reader can never observe a partially-written file. That matters beyond
|
|
* hygiene: `readConfig`'s corrupted-file catch RESETS the config to defaults
|
|
* (new anonymousId, telemetry re-enabled, all optional fields wiped), so a
|
|
* torn read of a non-atomic write would silently destroy the user's config
|
|
* (review finding).
|
|
*
|
|
* Returns whether the write actually landed — errors are still swallowed
|
|
* (telemetry must never break the CLI), but callers that need persistence
|
|
* certainty (e.g. the DE parallel-router trial's off-switch) can react
|
|
* instead of re-implementing read-back verification.
|
|
*/
|
|
export function writeConfig(config: HyperframesConfig): boolean {
|
|
return writeConfigWithResult(config).ok;
|
|
}
|
|
|
|
export type ConfigWriteResult = { ok: true } | { ok: false; error: string };
|
|
|
|
/**
|
|
* Persist config and retain the failure reason for user-facing commands that
|
|
* must distinguish a durable preference write from a best-effort update.
|
|
*/
|
|
export function writeConfigWithResult(config: HyperframesConfig): ConfigWriteResult {
|
|
try {
|
|
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
const tmpFile = `${CONFIG_FILE}.${process.pid}.tmp`;
|
|
writeFileSync(tmpFile, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
|
|
renameSync(tmpFile, CONFIG_FILE);
|
|
cachedConfig = { ...config };
|
|
// Mirror into the install-state file (marker + tripped breaker) so no
|
|
// breaker write site has to remember to do it.
|
|
syncInstallState(config);
|
|
return { ok: true };
|
|
} catch (error) {
|
|
// Non-fatal — telemetry should never break the CLI
|
|
return { ok: false, error: normalizeErrorMessage(error) };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Increment the command counter and persist.
|
|
*/
|
|
export function incrementCommandCount(): number {
|
|
const config = readConfig();
|
|
config.commandCount++;
|
|
writeConfig(config);
|
|
return config.commandCount;
|
|
}
|
|
|
|
/** Expose the config directory path for the telemetry command output */
|
|
export const CONFIG_PATH = CONFIG_FILE;
|
|
|
|
/** Expose the install-state path for the telemetry command output. */
|
|
export const STATE_PATH = STATE_FILE;
|