mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(cli): --yes in an unattended run no longer overrides a no saved by another process (#4547)
* fix(cli): --yes in an unattended run no longer overrides a no saved by another process * fix(cli): every settings writer takes the lock, and --yes needs a real terminal to reverse a no * fix(cli): the thin-results download offer also needs a real terminal * fix(cli): one owner-checked settings lock, and an unreadable settings file never counts as a yes * fix(cli): settings lock removals go through a reaper, and a non-boolean answer counts as a no * fix(cli): a settings lock that cannot be released no longer fails a write that landed * fix(cli): a leftover settings lock is never taken over, and doctor names it * fix(cli): a background update that cannot be recorded is not launched again every run * fix(cli): every on-device install re-reads the saved answer first, and an unsaved yes installs nothing * fix(cli): the thin-results offer stops when a yes cannot be saved * fix(cli): only the consent-checked step installs the on-device runtime, and a leftover lock warns once * fix(cli): the leftover-lock warning follows the attended-terminal rule
This commit is contained in:
@@ -40,7 +40,8 @@ The download is never started without a yes:
|
||||
been asked yet.
|
||||
- Agents, CI and `--json` runs are never prompted. They are told to ask the person they work for;
|
||||
`--on-device --yes` is that person's yes, and records it.
|
||||
- After a no, `--on-device` alone is skipped with a message; `--on-device --yes` changes the answer.
|
||||
- After a no, `--on-device` alone is skipped with a message. Only `--on-device --yes` in an interactive
|
||||
terminal (stdin and stdout both terminals, no `--json`, not CI) changes the answer; elsewhere a no stands.
|
||||
|
||||
Once the model is on your machine, every `catalog --query` uses meaning search. If it cannot run, the
|
||||
CLI says why and falls back to word match.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { failCommand, failUsage } from "../../utils/commandResult.js";
|
||||
import { envFlagEnabled, isAttendedTerminal } from "../../utils/attendedTerminal.js";
|
||||
/**
|
||||
* `hyperframes auth login` — sign in to HeyGen.
|
||||
*
|
||||
@@ -117,13 +118,8 @@ function isRemoteOrHeadless(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function envFlagEnabled(name: string): boolean {
|
||||
const value = process.env[name]?.trim().toLowerCase();
|
||||
return Boolean(value && value !== "0" && value !== "false" && value !== "no");
|
||||
}
|
||||
|
||||
function assertAttendedDeviceFlow(): void {
|
||||
if (envFlagEnabled("CI") || process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
|
||||
if (!isAttendedTerminal()) {
|
||||
console.error(
|
||||
c.error(
|
||||
"`--device` requires an attended terminal and is disabled in CI. Use an API key or workload credential for automation.",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { RegistryItem } from "@hyperframes/core";
|
||||
@@ -84,8 +85,14 @@ const state = vi.hoisted(() => ({
|
||||
modelStatus: "ready" as "ready" | "not-asked" | "declined" | "unavailable",
|
||||
confirmAnswer: true as boolean,
|
||||
consentRecorded: [] as boolean[],
|
||||
consentSavedMeanwhile: undefined as boolean | undefined,
|
||||
consentWriteFails: false,
|
||||
downloads: 0,
|
||||
runtimeInstalls: 0,
|
||||
runtimeAvailable: true,
|
||||
noSavedDuringRuntime: false,
|
||||
vectorsOnDisk: true,
|
||||
runtimeOnDisk: true,
|
||||
}));
|
||||
|
||||
vi.mock("../registry/resolver.js", () => ({
|
||||
@@ -131,16 +138,34 @@ vi.mock("../registry/localModel.js", () => ({
|
||||
return true;
|
||||
},
|
||||
recordLocalModelConsent: (enabled: boolean) => {
|
||||
if (state.consentWriteFails) return state.consentRecorded.at(-1);
|
||||
state.consentRecorded.push(enabled);
|
||||
return enabled;
|
||||
},
|
||||
assumeLocalModelConsent: () => {
|
||||
if (state.consentWriteFails) return undefined;
|
||||
const onDisk = state.consentSavedMeanwhile ?? state.consentRecorded.at(-1);
|
||||
if (onDisk !== undefined) return onDisk;
|
||||
state.consentRecorded.push(true);
|
||||
return true;
|
||||
},
|
||||
// "unavailable" and "ready" both mean a yes is saved; only a later save changes that.
|
||||
savedLocalModelConsent: () =>
|
||||
state.consentSavedMeanwhile ??
|
||||
state.consentRecorded.at(-1) ??
|
||||
(state.modelStatus === "ready" || state.modelStatus === "unavailable" ? true : undefined),
|
||||
downloadOfferMessage: () => "offer",
|
||||
nonInteractiveConsentMessage: () => "consent",
|
||||
}));
|
||||
|
||||
vi.mock("../registry/localEmbedder.js", () => ({
|
||||
hasLocalRuntime: () => state.runtimeOnDisk,
|
||||
// Left unmocked this would run a real npm install under vitest.
|
||||
ensureLocalRuntime: async () =>
|
||||
state.runtimeAvailable ? { ok: true } : { ok: false, reason: "installing it failed" },
|
||||
ensureLocalRuntime: async () => {
|
||||
state.runtimeInstalls += 1;
|
||||
if (state.noSavedDuringRuntime) state.consentSavedMeanwhile = false;
|
||||
return state.runtimeAvailable ? { ok: true } : { ok: false, reason: "installing it failed" };
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../registry/localSemantic.js", () => ({
|
||||
@@ -151,7 +176,7 @@ vi.mock("../registry/localSemantic.js", () => ({
|
||||
},
|
||||
localVectorNames: () => state.indexed,
|
||||
cachedLocalVectorRevision: () => state.cachedVectorRevision,
|
||||
hasLocalVectors: () => true,
|
||||
hasLocalVectors: () => state.vectorsOnDisk,
|
||||
fetchLocalVectors: async (_registry: string, options: { expectedRevision?: string } = {}) => {
|
||||
state.vectorFetches += 1;
|
||||
if (state.vectorFetchSucceeds && options.expectedRevision !== undefined) {
|
||||
@@ -252,8 +277,14 @@ beforeEach(() => {
|
||||
state.rankingError = null;
|
||||
state.confirmAnswer = true;
|
||||
state.consentRecorded = [];
|
||||
state.consentSavedMeanwhile = undefined;
|
||||
state.consentWriteFails = false;
|
||||
state.downloads = 0;
|
||||
state.runtimeInstalls = 0;
|
||||
state.runtimeAvailable = true;
|
||||
state.noSavedDuringRuntime = false;
|
||||
state.vectorsOnDisk = true;
|
||||
state.runtimeOnDisk = true;
|
||||
state.registry = [block("count-up"), block("fade-through"), component("whip-pan")];
|
||||
state.indexed = ["count-up", "fade-through", "whip-pan"];
|
||||
state.ranking = [
|
||||
@@ -608,14 +639,27 @@ describe("the on-device download offer", () => {
|
||||
// The offer only exists for someone who can answer it. Off a terminal the
|
||||
// caller must add --yes explicitly, so a test that forgets the terminal
|
||||
// never reaches the prompt and passes for the wrong reason.
|
||||
const asATerminal = async (run: () => Promise<string>): Promise<string> => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
|
||||
const asATerminal = async <T>(
|
||||
run: () => Promise<T>,
|
||||
{ stdin = true, ci }: { stdin?: boolean; ci?: string } = {},
|
||||
): Promise<T> => {
|
||||
const streams = [process.stdin, process.stdout];
|
||||
const descriptors = streams.map((stream) => Object.getOwnPropertyDescriptor(stream, "isTTY"));
|
||||
const savedCi = process.env["CI"];
|
||||
Object.defineProperty(process.stdin, "isTTY", { value: stdin, configurable: true });
|
||||
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
|
||||
if (ci === undefined) delete process.env["CI"];
|
||||
else process.env["CI"] = ci;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
if (descriptor) Object.defineProperty(process.stdout, "isTTY", descriptor);
|
||||
else delete (process.stdout as unknown as { isTTY?: boolean }).isTTY;
|
||||
streams.forEach((stream, i) => {
|
||||
const descriptor = descriptors[i];
|
||||
if (descriptor) Object.defineProperty(stream, "isTTY", descriptor);
|
||||
else delete (stream as unknown as { isTTY?: boolean }).isTTY;
|
||||
});
|
||||
if (savedCi === undefined) delete process.env["CI"];
|
||||
else process.env["CI"] = savedCi;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -659,6 +703,58 @@ describe("the on-device download offer", () => {
|
||||
expect(state.consentRecorded).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it("says a no that could not be saved was not saved", async () => {
|
||||
state.modelStatus = "not-asked";
|
||||
state.confirmAnswer = false;
|
||||
state.consentWriteFails = true;
|
||||
|
||||
const { err } = await asATerminal(() => runForExit({ query: "count up", "on-device": true }));
|
||||
|
||||
expect([state.downloads, state.consentRecorded]).toEqual([0, []]);
|
||||
expect(err).toContain("declined, but could not save the answer in settings");
|
||||
});
|
||||
|
||||
it("says so when a no given to the thin-results offer could not be saved", async () => {
|
||||
state.modelStatus = "not-asked";
|
||||
state.confirmAnswer = false;
|
||||
state.consentWriteFails = true;
|
||||
|
||||
const { err } = await asATerminal(() => runForExit({ query: "count up" }));
|
||||
|
||||
expect(err).toContain("Could not save the answer in settings; `hyperframes doctor` says why.");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["piped stdin", { stdin: false }],
|
||||
["CI", { ci: "true" }],
|
||||
])("treats a terminal with %s as unwatched, so its --yes keeps a saved no", async (_, env) => {
|
||||
state.consentRecorded = [false];
|
||||
|
||||
const { err } = await asATerminal(
|
||||
() => runForExit({ query: "count up", "on-device": true, yes: true }),
|
||||
env,
|
||||
);
|
||||
|
||||
expect([state.runtimeInstalls, state.downloads, state.consentRecorded]).toEqual([
|
||||
0,
|
||||
0,
|
||||
[false],
|
||||
]);
|
||||
expect(err).toContain("previously declined");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["piped stdin", { stdin: false }],
|
||||
["CI", { ci: "true" }],
|
||||
])("does not offer the download after thin results in a terminal with %s", async (_, env) => {
|
||||
state.modelStatus = "not-asked";
|
||||
state.confirmAnswer = true;
|
||||
|
||||
await asATerminal(() => runForExit({ query: "count up" }), env);
|
||||
|
||||
expect([state.downloads, state.consentRecorded]).toEqual([0, []]);
|
||||
});
|
||||
|
||||
it("does not treat non-interactive output as download consent", async () => {
|
||||
state.modelStatus = "not-asked";
|
||||
|
||||
@@ -667,6 +763,105 @@ describe("the on-device download offer", () => {
|
||||
expect(state.downloads).toBe(0);
|
||||
expect(state.consentRecorded).toEqual([]);
|
||||
});
|
||||
|
||||
it("lets --yes in a run nobody watches answer a question never asked", async () => {
|
||||
state.modelStatus = "not-asked";
|
||||
|
||||
await runEnvelope({ query: "count up", "on-device": true, yes: true });
|
||||
|
||||
expect([state.downloads, state.consentRecorded]).toEqual([1, [true]]);
|
||||
});
|
||||
|
||||
it("keeps a recorded no against --yes in a run nobody watches, installing nothing", async () => {
|
||||
state.consentRecorded = [false];
|
||||
|
||||
const { warnings } = await runEnvelope({ query: "count up", "on-device": true, yes: true });
|
||||
|
||||
expect([state.runtimeInstalls, state.downloads, state.consentRecorded]).toEqual([
|
||||
0,
|
||||
0,
|
||||
[false],
|
||||
]);
|
||||
expect(warnings?.join(" ")).toContain("previously declined");
|
||||
});
|
||||
|
||||
it("installs nothing, and does not claim a no, when the answer cannot be saved", async () => {
|
||||
state.modelStatus = "not-asked";
|
||||
state.consentWriteFails = true;
|
||||
|
||||
const { warnings } = await runEnvelope({ query: "count up", "on-device": true, yes: true });
|
||||
|
||||
expect([state.runtimeInstalls, state.downloads]).toEqual([0, 0]);
|
||||
expect(warnings?.join(" ")).toContain("could not save the answer");
|
||||
expect(warnings?.join(" ")).not.toContain("previously declined");
|
||||
});
|
||||
|
||||
it("keeps a no saved by someone else while the run was starting", async () => {
|
||||
state.modelStatus = "not-asked";
|
||||
state.consentSavedMeanwhile = false;
|
||||
|
||||
const { warnings } = await runEnvelope({ query: "count up", "on-device": true, yes: true });
|
||||
|
||||
expect([state.runtimeInstalls, state.downloads, state.consentRecorded]).toEqual([0, 0, []]);
|
||||
expect(warnings?.join(" ")).toContain("previously declined");
|
||||
});
|
||||
|
||||
it("installs nothing on the routine update when a no was saved after the yes", async () => {
|
||||
state.modelStatus = "unavailable";
|
||||
state.consentSavedMeanwhile = false;
|
||||
|
||||
const { warnings } = await runEnvelope({ query: "count up" });
|
||||
|
||||
expect([state.runtimeInstalls, state.downloads]).toEqual([0, 0]);
|
||||
expect(warnings?.join(" ")).toContain("previously declined");
|
||||
});
|
||||
|
||||
it("skips the model download when a no is saved while the runtime installs", async () => {
|
||||
state.modelStatus = "unavailable";
|
||||
state.noSavedDuringRuntime = true;
|
||||
|
||||
await runEnvelope({ query: "count up", "on-device": true });
|
||||
|
||||
expect([state.runtimeInstalls, state.downloads]).toEqual([1, 0]);
|
||||
});
|
||||
|
||||
it("fetches nothing after a thin-results yes that cannot be saved", async () => {
|
||||
state.modelStatus = "not-asked";
|
||||
state.confirmAnswer = true;
|
||||
state.consentWriteFails = true;
|
||||
state.vectorsOnDisk = false;
|
||||
|
||||
await asATerminal(() => runForExit({ query: "count up" }));
|
||||
|
||||
expect([state.vectorFetches, state.downloads]).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[true, 1],
|
||||
[false, 0],
|
||||
])(
|
||||
"installs a runtime missing beside a ready model only through the consent check (saved yes: %s)",
|
||||
async (savedYes, installs) => {
|
||||
state.runtimeOnDisk = false;
|
||||
if (!savedYes) state.consentSavedMeanwhile = false;
|
||||
|
||||
await runEnvelope({ query: "count up" });
|
||||
|
||||
expect(state.runtimeInstalls).toBe(installs);
|
||||
},
|
||||
);
|
||||
|
||||
it("installs nothing when a person's yes cannot be saved", async () => {
|
||||
state.consentRecorded = [false];
|
||||
state.consentWriteFails = true;
|
||||
|
||||
const { err } = await asATerminal(() =>
|
||||
runForExit({ query: "count up", "on-device": true, yes: true }),
|
||||
);
|
||||
|
||||
expect([state.runtimeInstalls, state.downloads]).toEqual([0, 0]);
|
||||
expect(err).toContain("could not save the answer in settings");
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalogRow", () => {
|
||||
|
||||
@@ -17,9 +17,12 @@ import { fetchRegistryManifest } from "../registry/remote.js";
|
||||
import { loadProjectConfig, DEFAULT_PROJECT_CONFIG } from "../utils/projectConfig.js";
|
||||
import { resolve } from "node:path";
|
||||
import { finishCommand } from "../utils/commandResult.js";
|
||||
import { isAttendedTerminal } from "../utils/attendedTerminal.js";
|
||||
import { runAdd } from "./add.js";
|
||||
import { hasNoSearchableTokens, searchByWords } from "../registry/localSearch.js";
|
||||
import {
|
||||
assumeLocalModelConsent,
|
||||
savedLocalModelConsent,
|
||||
downloadOfferMessage,
|
||||
ensureLocalModel,
|
||||
type LocalModelStatus,
|
||||
@@ -27,7 +30,7 @@ import {
|
||||
nonInteractiveConsentMessage,
|
||||
recordLocalModelConsent,
|
||||
} from "../registry/localModel.js";
|
||||
import { ensureLocalRuntime } from "../registry/localEmbedder.js";
|
||||
import { ensureLocalRuntime, hasLocalRuntime } from "../registry/localEmbedder.js";
|
||||
import {
|
||||
cachedLocalVectorRevision,
|
||||
fetchLocalVectors,
|
||||
@@ -62,10 +65,12 @@ async function prepareOnDeviceTier(opts: {
|
||||
};
|
||||
|
||||
const status = opts.status;
|
||||
if (!opts.assumedYes && status.status === "declined") {
|
||||
warn(
|
||||
"on-device search skipped: the model download was previously declined. Re-run with --yes to consent.",
|
||||
);
|
||||
// A person at a terminal may reverse their own no with --yes; an unwatched run only answers a question never asked.
|
||||
const unwatchedYes = opts.assumedYes && !opts.canPrompt;
|
||||
const declined =
|
||||
"on-device search skipped: the model download was previously declined. To consent, re-run with --yes in an interactive terminal, without --json and outside CI.";
|
||||
if (status.status === "declined" && !opts.assumedYes) {
|
||||
warn(declined);
|
||||
return warnings;
|
||||
}
|
||||
|
||||
@@ -80,8 +85,11 @@ async function prepareOnDeviceTier(opts: {
|
||||
initialValue: true,
|
||||
});
|
||||
if (clack.isCancel(answer) || answer !== true) {
|
||||
recordLocalModelConsent(false);
|
||||
warn("on-device search skipped: the download was declined.");
|
||||
warn(
|
||||
recordLocalModelConsent(false) === false
|
||||
? "on-device search skipped: the download was declined."
|
||||
: "on-device search skipped: the download was declined, but could not save the answer in settings; `hyperframes doctor` says why.",
|
||||
);
|
||||
// Return, or the decline is the only thing that does not happen: the
|
||||
// runtime check below is skipped precisely because consent is now false,
|
||||
// control reaches recordLocalModelConsent(true), and the answer is
|
||||
@@ -90,16 +98,36 @@ async function prepareOnDeviceTier(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
// Settled from disk before anything installs, on every path, so a no saved by another process stops it.
|
||||
const asked = status.status === "declined" || status.status === "not-asked";
|
||||
const agreed = !asked
|
||||
? savedLocalModelConsent()
|
||||
: unwatchedYes
|
||||
? assumeLocalModelConsent()
|
||||
: recordLocalModelConsent(true);
|
||||
if (agreed !== true) {
|
||||
warn(
|
||||
agreed === false && (unwatchedYes || !asked)
|
||||
? declined
|
||||
: asked
|
||||
? "on-device search skipped: could not save the answer in settings; `hyperframes doctor` says why."
|
||||
: nonInteractiveConsentMessage(),
|
||||
);
|
||||
return warnings;
|
||||
}
|
||||
|
||||
// Before the model download: fetching 32 MB and then finding the runtime missing wastes it.
|
||||
const runtime = await ensureLocalRuntime();
|
||||
if (!runtime.ok) {
|
||||
warn(`on-device search skipped: ${runtime.reason}`);
|
||||
return warnings;
|
||||
}
|
||||
|
||||
if (status.status === "declined" || status.status === "not-asked") {
|
||||
recordLocalModelConsent(true);
|
||||
// The runtime install can take a while; a no saved during it stops the model download.
|
||||
if (savedLocalModelConsent() !== true) {
|
||||
warn(declined);
|
||||
return warnings;
|
||||
}
|
||||
|
||||
const model = await ensureLocalModel();
|
||||
const revisionStale =
|
||||
opts.artifactRevision !== undefined && cachedLocalVectorRevision() !== opts.artifactRevision;
|
||||
@@ -215,8 +243,8 @@ export default defineCommand({
|
||||
const routineUpdate =
|
||||
searchContext?.status.status === "unavailable" ||
|
||||
(searchContext?.status.status === "ready" &&
|
||||
artifactRevision !== undefined &&
|
||||
cachedLocalVectorRevision() !== artifactRevision);
|
||||
(!hasLocalRuntime() ||
|
||||
(artifactRevision !== undefined && cachedLocalVectorRevision() !== artifactRevision)));
|
||||
const shouldPrepare = args["on-device"] === true || routineUpdate;
|
||||
let warnings: string[] = [];
|
||||
let effectiveStatus = searchContext?.status;
|
||||
@@ -224,7 +252,7 @@ export default defineCommand({
|
||||
warnings = await prepareOnDeviceTier({
|
||||
assumedYes: args.yes === true,
|
||||
artifactRevision,
|
||||
canPrompt: process.stdout.isTTY === true && !json,
|
||||
canPrompt: isAttendedTerminal() && !json,
|
||||
registry: config.registry,
|
||||
registryNames,
|
||||
status: searchContext.status,
|
||||
@@ -676,7 +704,7 @@ type LocalMode = "local-model" | "words";
|
||||
* person to prompt instead, or when that person has already answered.
|
||||
*/
|
||||
function localModelHint(json: boolean, status: LocalModelStatus | undefined): string | null {
|
||||
if (!json && process.stdout.isTTY) return null;
|
||||
if (!json && isAttendedTerminal()) return null;
|
||||
if (status?.status !== "not-asked") return null;
|
||||
return nonInteractiveConsentMessage();
|
||||
}
|
||||
@@ -694,7 +722,7 @@ async function offerLocalModel(
|
||||
registryBaseUrl: string,
|
||||
artifactRevision?: string,
|
||||
): Promise<void> {
|
||||
if (json || !process.stdout.isTTY) return;
|
||||
if (json || !isAttendedTerminal()) return;
|
||||
if (localModelStatus().status !== "not-asked") return;
|
||||
// Deliberately not gated on the number of results. That gate was set when the
|
||||
// catalog was small; against 411 moves a word match nearly always returns
|
||||
@@ -706,7 +734,10 @@ async function offerLocalModel(
|
||||
initialValue: true,
|
||||
});
|
||||
if (clack.isCancel(answer)) return;
|
||||
recordLocalModelConsent(answer === true);
|
||||
if (recordLocalModelConsent(answer === true) !== (answer === true)) {
|
||||
console.error(" Could not save the answer in settings; `hyperframes doctor` says why.");
|
||||
return;
|
||||
}
|
||||
if (answer !== true) return;
|
||||
|
||||
// The vectors come from the registry rather than the package, so consent is
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -311,3 +311,25 @@ describe("checkOptionalPackage", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkSettingsLock", () => {
|
||||
it("names a lock left by a process that stopped, and leaves it for the person to remove", async () => {
|
||||
const { checkSettingsLock } = await import("./doctor.js");
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-doctor-lock-"));
|
||||
try {
|
||||
const lock = join(dir, "config.json.lock");
|
||||
expect(checkSettingsLock(lock)).toEqual({ ok: true, detail: "Not locked" });
|
||||
|
||||
writeFileSync(lock, "stopped");
|
||||
const past = new Date(Date.now() - 60_000);
|
||||
utimesSync(lock, past, past);
|
||||
const result = checkSettingsLock(lock);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.hint).toContain(`delete ${lock}`);
|
||||
expect(existsSync(lock)).toBe(true);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
// fallow-ignore-file complexity
|
||||
import { defineCommand } from "citty";
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import { existsSync } from "node:fs";
|
||||
import { platform } from "node:os";
|
||||
import { dirname } from "node:path";
|
||||
import { resolveExtractCacheDir } from "@hyperframes/engine";
|
||||
import type { Example } from "./_examples.js";
|
||||
import { CONFIG_PATH } from "../telemetry/config.js";
|
||||
import { withFileLock } from "../media-use/lib/config-lock.mjs";
|
||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||
import { c } from "../ui/colors.js";
|
||||
import { parseToolVersion, runEnvironmentChecks } from "../browser/preflight.js";
|
||||
import { KOKORO_MODULES, KOKORO_PIP, MUSICGEN_MODULES, MUSICGEN_PIP } from "../audio/providers.js";
|
||||
@@ -236,6 +240,17 @@ export function checkArchiveExtractor(
|
||||
};
|
||||
}
|
||||
|
||||
/** A lock left by a hyperframes process that stopped mid-write blocks settings writes until a person removes it. */
|
||||
export function checkSettingsLock(lockPath = `${CONFIG_PATH}.lock`): CheckResult {
|
||||
if (!existsSync(lockPath)) return { ok: true, detail: "Not locked" };
|
||||
try {
|
||||
withFileLock(lockPath, fs, () => undefined);
|
||||
return { ok: true, detail: "Not locked" };
|
||||
} catch (error) {
|
||||
return { ok: false, detail: "Locked", hint: normalizeErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function checkEnvironment(): CheckResult {
|
||||
const sys = getSystemMeta();
|
||||
const parts: string[] = [];
|
||||
@@ -361,6 +376,7 @@ export default defineCommand({
|
||||
{ name: "Disk", run: checkDisk },
|
||||
{ name: "Frames cache", run: () => checkFramesCache() },
|
||||
{ name: "Archive extractor", run: checkArchiveExtractor },
|
||||
{ name: "Settings lock", run: () => checkSettingsLock() },
|
||||
];
|
||||
|
||||
// /dev/shm is only relevant on Linux (especially Docker)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import type * as fs from "node:fs";
|
||||
|
||||
export function withFileLock<T>(lockPath: string, fsModule: typeof fs, task: () => T): T;
|
||||
@@ -0,0 +1,45 @@
|
||||
// The one lock on ~/.hyperframes/config.json, for the CLI, its auto-update step and media-use. The auto-update step
|
||||
// embeds this function's source, so it must use only its arguments and globals.
|
||||
export function withFileLock(lockPath, fs, task) {
|
||||
const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const started = Date.now();
|
||||
for (;;) {
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(lockPath, "wx");
|
||||
} catch (error) {
|
||||
if (error.code !== "EEXIST") throw error;
|
||||
// Never taken over: a lock older than any hold was left by a process that stopped, and a person removes it.
|
||||
let leftover = false;
|
||||
try {
|
||||
leftover = Date.now() - fs.statSync(lockPath).mtimeMs > 5000;
|
||||
} catch {}
|
||||
if (leftover || Date.now() - started > 10000)
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
`Settings are locked by another hyperframes process. If none is running, delete ${lockPath}`,
|
||||
),
|
||||
{ code: "HF_SETTINGS_LOCKED" },
|
||||
);
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fs.writeSync(fd, token);
|
||||
} catch (error) {
|
||||
fs.rmSync(lockPath, { force: true });
|
||||
throw error;
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
break;
|
||||
}
|
||||
try {
|
||||
return task();
|
||||
} finally {
|
||||
// No code removes another's lock, so release needs no check (only a person deleting a live one defeats it).
|
||||
try {
|
||||
fs.rmSync(lockPath);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { spawn } from "node:child_process";
|
||||
import { test } from "node:test";
|
||||
import * as fs from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { withFileLock } from "./config-lock.mjs";
|
||||
|
||||
function lockIn() {
|
||||
const dir = fs.mkdtempSync(join(tmpdir(), "hf-config-lock-"));
|
||||
return {
|
||||
lock: join(dir, "config.json.lock"),
|
||||
cleanup: () => fs.rmSync(dir, { recursive: true }),
|
||||
};
|
||||
}
|
||||
|
||||
test("returns the task's result even when the lock cannot be released", () => {
|
||||
const { lock, cleanup } = lockIn();
|
||||
try {
|
||||
const refusing = {
|
||||
...fs,
|
||||
rmSync(path, options) {
|
||||
if (path === lock) throw Object.assign(new Error("EPERM"), { code: "EPERM" });
|
||||
return fs.rmSync(path, options);
|
||||
},
|
||||
};
|
||||
assert.equal(
|
||||
withFileLock(lock, refusing, () => "saved"),
|
||||
"saved",
|
||||
);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("refuses a lock left by a process that stopped, names it, and never removes it", () => {
|
||||
const { lock, cleanup } = lockIn();
|
||||
try {
|
||||
fs.writeFileSync(lock, "dead");
|
||||
const past = new Date(Date.now() - 60_000);
|
||||
fs.utimesSync(lock, past, past);
|
||||
const started = Date.now();
|
||||
let ran = false;
|
||||
|
||||
assert.throws(
|
||||
() => withFileLock(lock, fs, () => (ran = true)),
|
||||
(error) => error.code === "HF_SETTINGS_LOCKED" && error.message.includes(lock),
|
||||
);
|
||||
assert.ok(Date.now() - started < 1000);
|
||||
assert.equal(ran, false);
|
||||
assert.equal(fs.readFileSync(lock, "utf8"), "dead");
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("waits for a process that holds the lock, then takes it", async () => {
|
||||
const { lock, cleanup } = lockIn();
|
||||
try {
|
||||
const script = `
|
||||
const fs = require("fs");
|
||||
const lock = process.argv[1];
|
||||
fs.writeFileSync(lock, "other", { flag: "wx" });
|
||||
process.stdout.write("locked\\n");
|
||||
setTimeout(() => {
|
||||
fs.writeFileSync(lock + ".released", String(Date.now()));
|
||||
fs.rmSync(lock);
|
||||
}, 300);`;
|
||||
const other = spawn(process.execPath, ["-e", script, lock]);
|
||||
await new Promise((ready) => other.stdout.once("data", ready));
|
||||
|
||||
const ranAt = withFileLock(lock, fs, () => Date.now());
|
||||
|
||||
assert.ok(ranAt >= Number(fs.readFileSync(`${lock}.released`, "utf8")));
|
||||
await new Promise((exited) => other.once("exit", exited));
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
});
|
||||
@@ -2,9 +2,11 @@
|
||||
// never carry intent text, file names, or paths.
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import * as fs from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { withFileLock } from "./config-lock.mjs";
|
||||
import { globalMediaDir } from "./media-home.mjs";
|
||||
|
||||
const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
|
||||
@@ -62,10 +64,24 @@ function readSharedConfig() {
|
||||
return {};
|
||||
}
|
||||
|
||||
function writeSharedConfig(config) {
|
||||
const dir = join(homedir(), ".hyperframes");
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "config.json"), JSON.stringify(config, null, 2) + "\n");
|
||||
// Best effort: skipped when the lock cannot be taken or the file cannot be read, never written unlocked.
|
||||
function updateSharedConfig(patch) {
|
||||
const file = sharedConfigPath();
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
try {
|
||||
withFileLock(`${file}.lock`, fs, () => {
|
||||
let config = {};
|
||||
try {
|
||||
config = JSON.parse(readFileSync(file, "utf8"));
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") return;
|
||||
}
|
||||
if (!config || typeof config !== "object" || Array.isArray(config)) return;
|
||||
const tmp = `${file}.${process.pid}.tmp`;
|
||||
writeFileSync(tmp, JSON.stringify({ ...config, ...patch }, null, 2) + "\n", { mode: 0o600 });
|
||||
renameSync(tmp, file);
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Adopt a pre-existing media-use-only id (~/.media/anon-id from before this
|
||||
@@ -93,7 +109,7 @@ function anonymousId() {
|
||||
return config.anonymousId.trim();
|
||||
}
|
||||
const id = legacyMediaAnonId() || randomUUID();
|
||||
writeSharedConfig({ ...config, anonymousId: id });
|
||||
updateSharedConfig({ anonymousId: id });
|
||||
return id;
|
||||
} catch {
|
||||
return "anon"; // best-effort; a shared bucket is fine if the fs is read-only
|
||||
@@ -133,7 +149,7 @@ function showTelemetryNotice() {
|
||||
"If you sign in to HeyGen, usage links to your account email or username. Opt out with HYPERFRAMES_NO_TELEMETRY=1 or DO_NOT_TRACK=1.",
|
||||
].join("\n"),
|
||||
);
|
||||
writeSharedConfig({ ...config, telemetryNoticeShown: true });
|
||||
updateSharedConfig({ telemetryNoticeShown: true });
|
||||
} catch {
|
||||
// notice is best-effort; never surface into the command
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { spawn } from "node:child_process";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
existsSync,
|
||||
@@ -372,3 +373,65 @@ test("read-only telemetry state degrades without throwing", async () => {
|
||||
__resetTelemetryForTest();
|
||||
}
|
||||
});
|
||||
|
||||
async function trackQuietly(home) {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalError = console.error;
|
||||
globalThis.fetch = async () => ({ ok: true });
|
||||
console.error = () => {};
|
||||
try {
|
||||
withoutTelemetryOptOut();
|
||||
await track("media_use_resolve", { type: "bgm" });
|
||||
return readFileSync(join(home, ".hyperframes/config.json"), "utf8");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
console.error = originalError;
|
||||
}
|
||||
}
|
||||
|
||||
test("waits for another process's locked settings write, then keeps the no it saved", async () => {
|
||||
const savedEnv = { ...process.env };
|
||||
const { root, home } = sandbox();
|
||||
const dir = join(home, ".hyperframes");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const holder = spawn(process.execPath, [
|
||||
"-e",
|
||||
`const fs = require("fs");
|
||||
const dir = process.argv[1];
|
||||
fs.writeFileSync(dir + "/config.json.lock", "", { flag: "wx" });
|
||||
process.stdout.write("locked\\n");
|
||||
setTimeout(() => {
|
||||
fs.writeFileSync(dir + "/other.tmp", JSON.stringify({ localEmbeddingEnabled: false }));
|
||||
fs.renameSync(dir + "/other.tmp", dir + "/config.json");
|
||||
fs.rmSync(dir + "/config.json.lock");
|
||||
}, 1000);`,
|
||||
dir,
|
||||
]);
|
||||
const exited = new Promise((done) => holder.on("exit", done));
|
||||
try {
|
||||
await new Promise((locked) => holder.stdout.once("data", locked));
|
||||
const saved = JSON.parse(await trackQuietly(home));
|
||||
await exited;
|
||||
assert.equal(saved.localEmbeddingEnabled, false);
|
||||
assert.equal(saved.telemetryNoticeShown, true);
|
||||
assert.equal(typeof saved.anonymousId, "string");
|
||||
} finally {
|
||||
restoreEnv(savedEnv);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
__resetTelemetryForTest();
|
||||
}
|
||||
});
|
||||
|
||||
test("leaves a settings file it cannot read untouched", async () => {
|
||||
const savedEnv = { ...process.env };
|
||||
const { root, home } = sandbox();
|
||||
mkdirSync(join(home, ".hyperframes"), { recursive: true });
|
||||
writeFileSync(join(home, ".hyperframes/config.json"), "{not json");
|
||||
try {
|
||||
assert.equal(await trackQuietly(home), "{not json");
|
||||
} finally {
|
||||
restoreEnv(savedEnv);
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
__resetTelemetryForTest();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const installs = vi.hoisted(() => ({ count: 0 }));
|
||||
|
||||
vi.mock("../utils/optionalPackages.js", () => ({
|
||||
installedOptionalPackageVersion: () => null,
|
||||
loadInstalledOptionalPackage: () => null,
|
||||
loadOptionalPackage: async () => {
|
||||
installs.count += 1;
|
||||
return {};
|
||||
},
|
||||
}));
|
||||
|
||||
import { hasLocalRuntime, loadLocalEmbedder } from "./localEmbedder.js";
|
||||
|
||||
describe("loadLocalEmbedder", () => {
|
||||
it("never installs the runtime; a search without it falls back instead", async () => {
|
||||
await expect(loadLocalEmbedder()).rejects.toThrow("not installed");
|
||||
expect(installs.count).toBe(0);
|
||||
expect(hasLocalRuntime()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,11 @@
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { loadOptionalPackage } from "../utils/optionalPackages.js";
|
||||
import {
|
||||
installedOptionalPackageVersion,
|
||||
loadInstalledOptionalPackage,
|
||||
loadOptionalPackage,
|
||||
} from "../utils/optionalPackages.js";
|
||||
import {
|
||||
LOCAL_MODEL_DIMENSIONS,
|
||||
QUERY_INSTRUCTION,
|
||||
@@ -44,8 +48,14 @@ export async function ensureLocalRuntime(): Promise<{ ok: true } | { ok: false;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasLocalRuntime(): boolean {
|
||||
return installedOptionalPackageVersion("onnxruntime-node") !== null;
|
||||
}
|
||||
|
||||
/** Loads what is installed and never installs: only ensureLocalRuntime does, after consent is settled. */
|
||||
export async function loadLocalEmbedder(): Promise<LocalEmbedder> {
|
||||
const ort = await loadOptionalPackage("onnxruntime-node", FEATURE);
|
||||
const ort = loadInstalledOptionalPackage("onnxruntime-node");
|
||||
if (ort === null) throw new Error("the on-device runtime is not installed yet");
|
||||
const config = configFromTokenizerJson(readFileSync(localTokenizerPath(), "utf-8"));
|
||||
const session = await ort.InferenceSession.create(localModelPath());
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const readConfig = vi.fn();
|
||||
const writeConfig = vi.fn();
|
||||
const disk = { consent: undefined as boolean | undefined };
|
||||
const existsSync = vi.fn();
|
||||
const readFileSync = vi.fn();
|
||||
const unlinkSync = vi.fn();
|
||||
@@ -10,7 +10,8 @@ const digest = vi.fn();
|
||||
|
||||
vi.mock("../telemetry/config.js", () => ({
|
||||
readConfig: () => readConfig(),
|
||||
writeConfig: (c: unknown) => writeConfig(c),
|
||||
updateLocalModelConsent: (decide: (onDisk: boolean | undefined) => boolean | undefined) =>
|
||||
(disk.consent = decide(disk.consent)),
|
||||
}));
|
||||
vi.mock("node:fs", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("node:fs")>()),
|
||||
@@ -45,6 +46,7 @@ const {
|
||||
localTokenizerPath,
|
||||
localModelPath,
|
||||
recordLocalModelConsent,
|
||||
assumeLocalModelConsent,
|
||||
} = await import("./localModel.js");
|
||||
|
||||
function returnMatchingDigests(rounds = 1): void {
|
||||
@@ -64,7 +66,7 @@ function makeDownloadsAppearOnDisk(): void {
|
||||
beforeEach(() => {
|
||||
readConfig.mockReturnValue({});
|
||||
existsSync.mockReturnValue(false);
|
||||
writeConfig.mockReset();
|
||||
disk.consent = undefined;
|
||||
readFileSync.mockReturnValue(Buffer.from("artifact"));
|
||||
unlinkSync.mockReset();
|
||||
downloadFile.mockReset();
|
||||
@@ -78,20 +80,20 @@ describe("consent state", () => {
|
||||
expect(localModelConsent()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("persists a yes", () => {
|
||||
readConfig.mockReturnValue({ telemetryEnabled: true });
|
||||
it("records a person's answer over whatever was saved", () => {
|
||||
disk.consent = false;
|
||||
recordLocalModelConsent(true);
|
||||
expect(writeConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ localEmbeddingEnabled: true }),
|
||||
);
|
||||
expect(disk.consent).toBe(true);
|
||||
recordLocalModelConsent(false);
|
||||
expect(disk.consent).toBe(false);
|
||||
});
|
||||
|
||||
it("persists a no without discarding other settings", () => {
|
||||
readConfig.mockReturnValue({ telemetryEnabled: false, anonymousId: "abc" });
|
||||
recordLocalModelConsent(false);
|
||||
expect(writeConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ localEmbeddingEnabled: false, anonymousId: "abc" }),
|
||||
);
|
||||
it("assumes yes only for a question never asked, and keeps a saved no", () => {
|
||||
disk.consent = undefined;
|
||||
expect(assumeLocalModelConsent()).toBe(true);
|
||||
disk.consent = false;
|
||||
expect(assumeLocalModelConsent()).toBe(false);
|
||||
expect(disk.consent).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,7 +173,7 @@ describe("status", () => {
|
||||
// The caller owns the prompt, because only it knows whether word matching
|
||||
// already answered well enough to make the offer pointless.
|
||||
expect(() => localModelStatus()).not.toThrow();
|
||||
expect(writeConfig).not.toHaveBeenCalled();
|
||||
expect(disk.consent).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { downloadFile } from "../utils/download.js";
|
||||
import { readConfig, writeConfig } from "../telemetry/config.js";
|
||||
import { readConfig, updateLocalModelConsent } from "../telemetry/config.js";
|
||||
|
||||
/**
|
||||
* bge-small-en-v1.5: 62.17 MTEB average against 62.3 for text-embedding-3-small,
|
||||
@@ -82,8 +82,18 @@ export function localModelConsent(): LocalModelDecision {
|
||||
return readConfig().localEmbeddingEnabled;
|
||||
}
|
||||
|
||||
export function recordLocalModelConsent(enabled: boolean): void {
|
||||
writeConfig({ ...readConfig(), localEmbeddingEnabled: enabled });
|
||||
export function recordLocalModelConsent(enabled: boolean): LocalModelDecision {
|
||||
return updateLocalModelConsent(() => enabled);
|
||||
}
|
||||
|
||||
/** The answer saved right now, read through the settings lock so a write in progress lands first. */
|
||||
export function savedLocalModelConsent(): LocalModelDecision {
|
||||
return updateLocalModelConsent((onDisk) => onDisk);
|
||||
}
|
||||
|
||||
/** --yes in a run nobody watches: it answers only a question never asked. Returns the decision that stands. */
|
||||
export function assumeLocalModelConsent(): LocalModelDecision {
|
||||
return updateLocalModelConsent((onDisk) => onDisk ?? true);
|
||||
}
|
||||
|
||||
export function localModelPath(): string {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// @vitest-environment node
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let home: string;
|
||||
let configDir: string;
|
||||
const saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE };
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), "hf-config-lock-"));
|
||||
configDir = join(home, ".hyperframes");
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
process.env.HOME = home;
|
||||
process.env.USERPROFILE = home;
|
||||
// A worker thread keeps the real home; never write a test answer into it.
|
||||
if (homedir() !== home) throw new Error("these tests need a process per test file");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.HOME = saved.HOME;
|
||||
process.env.USERPROFILE = saved.USERPROFILE;
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const consentOnDisk = () =>
|
||||
JSON.parse(readFileSync(join(configDir, "config.json"), "utf-8")).localEmbeddingEnabled;
|
||||
|
||||
/** Another CLI process: takes the settings lock, saves the person's no after a pause, lets go. */
|
||||
function anotherProcessSavesNo(): Promise<void> {
|
||||
const script = `
|
||||
const fs = require("fs");
|
||||
const dir = process.argv[1];
|
||||
fs.writeFileSync(dir + "/config.json.lock", "", { flag: "wx" });
|
||||
process.stdout.write("locked\\n");
|
||||
setTimeout(() => {
|
||||
fs.writeFileSync(dir + "/config.json.other.tmp", JSON.stringify({ localEmbeddingEnabled: false }));
|
||||
fs.renameSync(dir + "/config.json.other.tmp", dir + "/config.json");
|
||||
fs.rmSync(dir + "/config.json.lock");
|
||||
}, 300);`;
|
||||
const child = spawn(process.execPath, ["-e", script, configDir]);
|
||||
return new Promise((locked, failed) => {
|
||||
child.stdout.on("data", () => locked());
|
||||
child.on("error", failed);
|
||||
});
|
||||
}
|
||||
|
||||
describe("a settings file that exists but cannot be read", () => {
|
||||
it.each([
|
||||
[
|
||||
"corrupt",
|
||||
() => writeFileSync(join(configDir, "config.json"), '{"localEmbeddingEnabled": fal'),
|
||||
],
|
||||
["not a file", () => mkdirSync(join(configDir, "config.json"))],
|
||||
[
|
||||
"its answer is not a yes or no",
|
||||
() => writeFileSync(join(configDir, "config.json"), '{"localEmbeddingEnabled": "false"}'),
|
||||
],
|
||||
])("counts as a no when %s, never as a question not yet asked", async (_, make) => {
|
||||
make();
|
||||
const { readConfig, updateLocalModelConsent } = await import("./config.js");
|
||||
readConfig();
|
||||
|
||||
expect(updateLocalModelConsent((onDisk) => onDisk ?? true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a settings lock left by a process that stopped", () => {
|
||||
const leaveLock = () => {
|
||||
const lock = join(configDir, "config.json.lock");
|
||||
writeFileSync(lock, "stopped");
|
||||
const past = new Date(Date.now() - 60_000);
|
||||
utimesSync(lock, past, past);
|
||||
return lock;
|
||||
};
|
||||
|
||||
it.each([
|
||||
["a terminal", true, [], undefined, 1],
|
||||
["--json", true, ["--json"], undefined, 0],
|
||||
["--json=true", true, ["--json=true"], undefined, 0],
|
||||
["piped output", false, [], undefined, 0],
|
||||
["CI", true, [], "true", 0],
|
||||
])("warns once per process on %s", async (_, tty, extraArgs, ci, lines) => {
|
||||
const lock = leaveLock();
|
||||
const { writeConfig, readConfig } = await import("./config.js");
|
||||
const streams = [process.stdin, process.stdout];
|
||||
const saved = streams.map((stream) => Object.getOwnPropertyDescriptor(stream, "isTTY"));
|
||||
const argv = process.argv;
|
||||
const savedCi = process.env.CI;
|
||||
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
for (const stream of streams)
|
||||
Object.defineProperty(stream, "isTTY", { value: tty, configurable: true });
|
||||
process.argv = [...argv, ...extraArgs];
|
||||
if (ci === undefined) delete process.env.CI;
|
||||
else process.env.CI = ci;
|
||||
try {
|
||||
writeConfig(readConfig());
|
||||
writeConfig(readConfig());
|
||||
const warnings = error.mock.calls.map(([line]) => String(line));
|
||||
expect(warnings).toHaveLength(lines);
|
||||
if (lines) expect(warnings[0]).toContain(lock);
|
||||
if (lines) expect(warnings[0]).toContain("hyperframes doctor");
|
||||
} finally {
|
||||
error.mockRestore();
|
||||
process.argv = argv;
|
||||
if (savedCi === undefined) delete process.env.CI;
|
||||
else process.env.CI = savedCi;
|
||||
streams.forEach((stream, i) => {
|
||||
const descriptor = saved[i];
|
||||
if (descriptor) Object.defineProperty(stream, "isTTY", descriptor);
|
||||
else delete (stream as { isTTY?: boolean }).isTTY;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("config writes across processes", () => {
|
||||
it("reads a no another process is saving at that moment, not the answer before it", async () => {
|
||||
const { readConfig, updateLocalModelConsent } = await import("./config.js");
|
||||
writeFileSync(join(configDir, "config.json"), JSON.stringify({ localEmbeddingEnabled: true }));
|
||||
readConfig();
|
||||
await anotherProcessSavesNo();
|
||||
|
||||
expect(updateLocalModelConsent((onDisk) => onDisk)).toBe(false);
|
||||
});
|
||||
|
||||
it("waits for another process's settings write, then keeps the no it saved", async () => {
|
||||
const { readConfig, updateLocalModelConsent } = await import("./config.js");
|
||||
expect(readConfig().localEmbeddingEnabled).toBeUndefined();
|
||||
await anotherProcessSavesNo();
|
||||
|
||||
expect(updateLocalModelConsent((onDisk) => onDisk ?? true)).toBe(false);
|
||||
expect(consentOnDisk()).toBe(false);
|
||||
});
|
||||
|
||||
it("reads a no saved elsewhere even when keeping it writes nothing", async () => {
|
||||
const { readConfig, updateLocalModelConsent } = await import("./config.js");
|
||||
const early = readConfig();
|
||||
writeFileSync(
|
||||
join(configDir, "config.json"),
|
||||
JSON.stringify({ ...early, localEmbeddingEnabled: false }),
|
||||
);
|
||||
|
||||
expect(updateLocalModelConsent((onDisk) => onDisk ?? true)).toBe(false);
|
||||
expect(readConfig().localEmbeddingEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("never lets a copy read before another process's no put the question back", async () => {
|
||||
const { readConfig, writeConfig } = await import("./config.js");
|
||||
const early = readConfig();
|
||||
writeFileSync(
|
||||
join(configDir, "config.json"),
|
||||
JSON.stringify({ ...early, localEmbeddingEnabled: false }),
|
||||
);
|
||||
|
||||
writeConfig({ ...early, commandCount: early.commandCount + 1 });
|
||||
expect(consentOnDisk()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -10,6 +11,7 @@ import { join } from "node:path";
|
||||
// vitest worker.
|
||||
const fsState = vi.hoisted(() => ({
|
||||
files: new Map<string, string>(),
|
||||
fds: new Map<number, string>(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
@@ -17,7 +19,8 @@ vi.mock("node:fs", () => ({
|
||||
mkdirSync: vi.fn(() => undefined),
|
||||
readFileSync: vi.fn((path: string) => {
|
||||
const content = fsState.files.get(path);
|
||||
if (content === undefined) throw new Error(`ENOENT: ${path}`);
|
||||
if (content === undefined)
|
||||
throw Object.assign(new Error(`ENOENT: ${path}`), { code: "ENOENT" });
|
||||
return content;
|
||||
}),
|
||||
writeFileSync: vi.fn((path: string, content: string) => {
|
||||
@@ -34,6 +37,21 @@ vi.mock("node:fs", () => ({
|
||||
rmSync: vi.fn((path: string) => {
|
||||
fsState.files.delete(path);
|
||||
}),
|
||||
// The settings lock: an exclusive create, then the owner's token written through the descriptor.
|
||||
openSync: vi.fn((path: string, flag: string) => {
|
||||
if (flag === "wx" && fsState.files.has(path))
|
||||
throw Object.assign(new Error(`EEXIST: ${path}`), { code: "EEXIST" });
|
||||
fsState.files.set(path, "");
|
||||
fsState.fds.set(3, path);
|
||||
return 3;
|
||||
}),
|
||||
writeSync: vi.fn((fd: number, data: string) => {
|
||||
const path = fsState.fds.get(fd) ?? "";
|
||||
fsState.files.set(path, (fsState.files.get(path) ?? "") + data);
|
||||
}),
|
||||
closeSync: vi.fn(),
|
||||
linkSync: vi.fn(),
|
||||
statSync: vi.fn(() => ({ mtimeMs: Date.now() })),
|
||||
}));
|
||||
|
||||
// The backfill warning is suppressed under a telemetry runtime override (a
|
||||
@@ -828,3 +846,56 @@ describe("identity-persistence classification (sticky per anonymous id)", () =>
|
||||
expect(getIdentityWriteOutcome()).toBe("ok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateLocalModelConsent fails closed to the answer on disk", () => {
|
||||
let readConfig: typeof import("./config.js").readConfig;
|
||||
let updateLocalModelConsent: typeof import("./config.js").updateLocalModelConsent;
|
||||
let CONFIG_PATH: typeof import("./config.js").CONFIG_PATH;
|
||||
|
||||
beforeEach(async () => {
|
||||
fsState.files.clear();
|
||||
vi.resetModules();
|
||||
({ readConfig, updateLocalModelConsent, CONFIG_PATH } = await import("./config.js"));
|
||||
});
|
||||
|
||||
const consentOnDisk = () =>
|
||||
JSON.parse(fsState.files.get(CONFIG_PATH) ?? "{}").localEmbeddingEnabled;
|
||||
|
||||
it("returns the answer on disk, not the new one, when the write fails", async () => {
|
||||
readConfig();
|
||||
const fs = await import("node:fs");
|
||||
vi.mocked(fs.renameSync).mockImplementationOnce(() => {
|
||||
throw new Error("EACCES: permission denied");
|
||||
});
|
||||
|
||||
expect(updateLocalModelConsent(() => true)).toBeUndefined();
|
||||
expect(consentOnDisk()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the no on disk and writes nothing when another process keeps the lock", () => {
|
||||
fsState.files.set(
|
||||
CONFIG_PATH,
|
||||
JSON.stringify({ ...readConfig(), localEmbeddingEnabled: false }),
|
||||
);
|
||||
fsState.files.set(`${CONFIG_PATH}.lock`, "");
|
||||
let now = Date.now();
|
||||
const clock = vi.spyOn(Date, "now").mockImplementation(() => (now += 4_000));
|
||||
try {
|
||||
expect(updateLocalModelConsent(() => true)).toBe(false);
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
expect(consentOnDisk()).toBe(false);
|
||||
});
|
||||
|
||||
it("does not wait on its own lock when the read inside it has to write", async () => {
|
||||
const fs = await import("node:fs");
|
||||
vi.mocked(fs.openSync).mockClear();
|
||||
|
||||
expect(updateLocalModelConsent(() => true)).toBe(true);
|
||||
const lockTakes = vi
|
||||
.mocked(fs.openSync)
|
||||
.mock.calls.filter(([path, flag]) => flag === "wx" && path === `${CONFIG_PATH}.lock`);
|
||||
expect(lockTakes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import * as fs from "node:fs";
|
||||
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";
|
||||
import { telemetryRuntimeOverride } from "./policy.js";
|
||||
import { isAttendedTerminal } from "../utils/attendedTerminal.js";
|
||||
import { withFileLock } from "../media-use/lib/config-lock.mjs";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config directory: ~/.hyperframes/
|
||||
@@ -654,9 +657,7 @@ function passthroughFields(parsed: Partial<HyperframesConfig>): Partial<Hyperfra
|
||||
skillsOutdatedCount: parsed.skillsOutdatedCount,
|
||||
skillsMissingCount: parsed.skillsMissingCount,
|
||||
skillsRemovedCount: parsed.skillsRemovedCount,
|
||||
// Consent, so it survives the run that recorded it. Undefined stays
|
||||
// undefined on purpose: it means never asked, which is not the same as no.
|
||||
localEmbeddingEnabled: parsed.localEmbeddingEnabled,
|
||||
localEmbeddingEnabled: consentFrom(parsed.localEmbeddingEnabled),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -760,7 +761,7 @@ export function readConfig(): HyperframesConfig {
|
||||
// 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 };
|
||||
const config = { ...mintConfig(), telemetryEnabled: false, localEmbeddingEnabled: false };
|
||||
const write = writeConfigWithResult(config);
|
||||
classifyIdentity(
|
||||
config.anonymousId,
|
||||
@@ -815,6 +816,83 @@ export type ConfigWriteResult =
|
||||
* must distinguish a durable preference write from a best-effort update.
|
||||
*/
|
||||
export function writeConfigWithResult(config: HyperframesConfig): ConfigWriteResult {
|
||||
try {
|
||||
// The consent answer changes only through updateLocalModelConsent; a copy read earlier must not undo one.
|
||||
return withConfigLock(() =>
|
||||
persistConfig({ ...config, localEmbeddingEnabled: localModelConsentOnDisk() }),
|
||||
);
|
||||
} catch (error) {
|
||||
warnSettingsLockedOnce(error);
|
||||
return { ok: false, error: normalizeErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
let warnedSettingsLocked = false;
|
||||
|
||||
/** One line per process while a leftover lock blocks settings; never where a program reads the output. */
|
||||
function warnSettingsLockedOnce(error: unknown): void {
|
||||
if (warnedSettingsLocked || (error as NodeJS.ErrnoException)?.code !== "HF_SETTINGS_LOCKED")
|
||||
return;
|
||||
warnedSettingsLocked = true;
|
||||
if (
|
||||
!isAttendedTerminal() ||
|
||||
process.argv.some((arg) => arg === "--json" || arg.startsWith("--json="))
|
||||
)
|
||||
return;
|
||||
console.error(`${(error as Error).message} (see \`hyperframes doctor\`).`);
|
||||
}
|
||||
|
||||
export function updateLocalModelConsent(
|
||||
decide: (onDisk: boolean | undefined) => boolean | undefined,
|
||||
): boolean | undefined {
|
||||
try {
|
||||
return withConfigLock(() => {
|
||||
const onDisk = localModelConsentOnDisk();
|
||||
const next = decide(onDisk);
|
||||
if (next === onDisk) {
|
||||
if (cachedConfig) cachedConfig.localEmbeddingEnabled = onDisk;
|
||||
return next;
|
||||
}
|
||||
const written = persistConfig({ ...readConfigFresh(), localEmbeddingEnabled: next });
|
||||
return written.ok ? next : localModelConsentOnDisk();
|
||||
});
|
||||
} catch (error) {
|
||||
warnSettingsLockedOnce(error);
|
||||
return localModelConsentOnDisk();
|
||||
}
|
||||
}
|
||||
|
||||
/** Absent means never asked; anything present but not `true` counts as a no. */
|
||||
function consentFrom(value: unknown): boolean | undefined {
|
||||
return value === undefined ? undefined : value === true;
|
||||
}
|
||||
|
||||
function localModelConsentOnDisk(): boolean | undefined {
|
||||
try {
|
||||
return consentFrom(JSON.parse(readFileSync(CONFIG_FILE, "utf-8")).localEmbeddingEnabled);
|
||||
} catch (error) {
|
||||
// A settings file that exists but cannot be read may hold a no; only a missing one was never asked.
|
||||
return (error as NodeJS.ErrnoException).code === "ENOENT" ? undefined : false;
|
||||
}
|
||||
}
|
||||
|
||||
let holdingConfigLock = false;
|
||||
|
||||
/** One process at a time between reading config.json and replacing it; re-entrant within this one. */
|
||||
function withConfigLock<T>(task: () => T): T {
|
||||
if (holdingConfigLock) return task();
|
||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
return withFileLock(`${CONFIG_FILE}.lock`, fs, () => {
|
||||
holdingConfigLock = true;
|
||||
try {
|
||||
return task();
|
||||
} finally {
|
||||
holdingConfigLock = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function persistConfig(config: HyperframesConfig): ConfigWriteResult {
|
||||
try {
|
||||
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
||||
const tmpFile = `${CONFIG_FILE}.${process.pid}.tmp`;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export function envFlagEnabled(name: string): boolean {
|
||||
const value = process.env[name]?.trim().toLowerCase();
|
||||
return Boolean(value && value !== "0" && value !== "false" && value !== "no");
|
||||
}
|
||||
|
||||
/** A person can answer here: stdin and stdout are both terminals and this is not CI. */
|
||||
export function isAttendedTerminal(): boolean {
|
||||
return !envFlagEnabled("CI") && process.stdin.isTTY === true && process.stdout.isTTY === true;
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
// fallow-ignore-file code-duplication
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* These tests exercise the policy — when a background install should or
|
||||
@@ -20,6 +25,7 @@ function setupMocks(opts: {
|
||||
devMode?: boolean;
|
||||
config?: ConfigShape;
|
||||
env?: Record<string, string | undefined>;
|
||||
writeFails?: boolean;
|
||||
}): {
|
||||
writeSpy: ReturnType<typeof vi.fn>;
|
||||
spawnSpy: ReturnType<typeof vi.fn>;
|
||||
@@ -29,12 +35,14 @@ function setupMocks(opts: {
|
||||
|
||||
const config = { ...(opts.config ?? {}) };
|
||||
const writeSpy = vi.fn((next: ConfigShape) => {
|
||||
if (opts.writeFails) return false;
|
||||
Object.assign(config, next);
|
||||
// writeConfig is given a full replacement — mirror that by pruning keys
|
||||
// that disappeared.
|
||||
for (const k of Object.keys(config)) {
|
||||
if (!(k in next)) delete (config as Record<string, unknown>)[k];
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
vi.doMock("../telemetry/config.js", () => ({
|
||||
@@ -116,6 +124,17 @@ describe("scheduleBackgroundInstall", () => {
|
||||
expect(config.pendingUpdate?.command).toBe("npm install -g hyperframes@0.4.4");
|
||||
});
|
||||
|
||||
it("launches nothing when the pending install cannot be recorded", async () => {
|
||||
const { spawnSpy } = setupMocks({
|
||||
installer: { kind: "npm", command: "npm install -g hyperframes@0.4.4" },
|
||||
writeFails: true,
|
||||
});
|
||||
const { scheduleBackgroundInstall } = await import("./autoUpdate.js");
|
||||
|
||||
expect(scheduleBackgroundInstall("0.4.4", "0.4.3")).toBe(false);
|
||||
expect(spawnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT schedule across a major-version jump", async () => {
|
||||
const { spawnSpy } = setupMocks({
|
||||
installer: { kind: "npm", command: "npm install -g hyperframes@1.0.0" },
|
||||
@@ -306,4 +325,73 @@ describe("scheduleBackgroundInstall", () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("the detached child's settings write", () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "hf-auto-update-"));
|
||||
const dir = join(home, ".hyperframes");
|
||||
const cfg = join(dir, "config.json");
|
||||
|
||||
beforeEach(() => mkdirSync(dir, { recursive: true }));
|
||||
afterEach(() => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
vi.doUnmock("node:os");
|
||||
});
|
||||
|
||||
/** The script the CLI would spawn, pointed at a throwaway home and an install that does nothing. */
|
||||
async function runChild(): Promise<void> {
|
||||
const { spawnSpy } = setupMocks({ installer: { kind: "npm", command: "npm i" } });
|
||||
vi.doMock("node:os", async () => ({
|
||||
...(await vi.importActual<typeof import("node:os")>("node:os")),
|
||||
homedir: () => home,
|
||||
}));
|
||||
vi.doMock("./installerDetection.js", () => ({
|
||||
detectInstaller: () => ({ kind: "npm", installCommand: () => "npm i" }),
|
||||
installInvocation: () => ({ bin: process.execPath, args: ["-e", ""] }),
|
||||
}));
|
||||
const { scheduleBackgroundInstall } = await import("./autoUpdate.js");
|
||||
expect(scheduleBackgroundInstall("0.4.4", "0.4.3")).toBe(true);
|
||||
const args = spawnSpy.mock.calls[0]?.[1] as unknown as string[];
|
||||
expect(args[1]).toContain(JSON.stringify(cfg));
|
||||
execFileSync(process.execPath, args);
|
||||
}
|
||||
|
||||
it("waits for another process's locked write, then keeps the no it saved", async () => {
|
||||
const script = `
|
||||
const fs = require("fs");
|
||||
const dir = process.argv[1];
|
||||
fs.writeFileSync(dir + "/config.json.lock", "", { flag: "wx" });
|
||||
process.stdout.write("locked\\n");
|
||||
setTimeout(() => {
|
||||
fs.writeFileSync(dir + "/other.tmp", JSON.stringify({ localEmbeddingEnabled: false }));
|
||||
fs.renameSync(dir + "/other.tmp", dir + "/config.json");
|
||||
fs.rmSync(dir + "/config.json.lock");
|
||||
}, 1000);`;
|
||||
const holder = spawn(process.execPath, ["-e", script, dir]);
|
||||
const exited = new Promise((done) => holder.on("exit", done));
|
||||
await new Promise((locked) => holder.stdout.once("data", locked));
|
||||
|
||||
await runChild();
|
||||
await exited;
|
||||
|
||||
const saved = JSON.parse(readFileSync(cfg, "utf-8"));
|
||||
expect([saved.localEmbeddingEnabled, saved.completedUpdate?.version]).toEqual([
|
||||
false,
|
||||
"0.4.4",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves a settings file it cannot read untouched", async () => {
|
||||
writeFileSync(cfg, "{not json");
|
||||
|
||||
await runChild();
|
||||
|
||||
expect(readFileSync(cfg, "utf-8")).toBe("{not json");
|
||||
});
|
||||
|
||||
it("starts a settings file when there is none", async () => {
|
||||
await runChild();
|
||||
|
||||
expect(JSON.parse(readFileSync(cfg, "utf-8")).completedUpdate?.version).toBe("0.4.4");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ import { appendFileSync, mkdirSync, openSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { compareVersions } from "compare-versions";
|
||||
import { withFileLock } from "../media-use/lib/config-lock.mjs";
|
||||
import { readConfig, writeConfig } from "../telemetry/config.js";
|
||||
import { isDevMode } from "./env.js";
|
||||
import {
|
||||
@@ -90,20 +91,24 @@ function launchDetachedInstall(
|
||||
// 1. Runs the install via execFile (bin + argv, NO shell) so a version
|
||||
// string can never be re-interpreted as shell syntax — structural
|
||||
// symmetry with the interactive `runDetectedInstall` path.
|
||||
// 2. Rewrites the config file with completedUpdate, clears pendingUpdate.
|
||||
// 2. Under the shared settings lock (withFileLock, embedded as source), rewrites the config with
|
||||
// completedUpdate and clears pendingUpdate; skips on lock timeout or an unreadable file.
|
||||
// We run it through `node -e` so we don't need to ship a separate file. Bin
|
||||
// and args are embedded as JSON literals (data, not code).
|
||||
const nodeScript = `
|
||||
const { execFile } = require("node:child_process");
|
||||
const { readFileSync, renameSync, writeFileSync } = require("node:fs");
|
||||
const fs = require("node:fs");
|
||||
const { readFileSync, renameSync, writeFileSync } = fs;
|
||||
const CFG = ${JSON.stringify(configFile)};
|
||||
const TMP = \`\${CFG}.tmp\`;
|
||||
const VERSION = ${JSON.stringify(version)};
|
||||
const BIN = ${JSON.stringify(invocation.bin)};
|
||||
const ARGS = ${JSON.stringify(invocation.args)};
|
||||
execFile(BIN, ARGS, { windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (err, _stdout, stderr) => {
|
||||
const withFileLock = ${withFileLock.toString()};
|
||||
const withLock = (task) => { try { withFileLock(\`\${CFG}.lock\`, fs, task); } catch (e) {} };
|
||||
execFile(BIN, ARGS, { windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (err, _stdout, stderr) => withLock(() => {
|
||||
let cfg = {};
|
||||
try { cfg = JSON.parse(readFileSync(CFG, "utf-8")); } catch (e) {}
|
||||
try { cfg = JSON.parse(readFileSync(CFG, "utf-8")); } catch (e) { if (e.code !== "ENOENT") return; }
|
||||
cfg.completedUpdate = {
|
||||
version: VERSION,
|
||||
ok: !err,
|
||||
@@ -115,7 +120,7 @@ function launchDetachedInstall(
|
||||
writeFileSync(TMP, JSON.stringify(cfg, null, 2) + "\\n", { mode: 0o600 });
|
||||
renameSync(TMP, CFG);
|
||||
} catch (e) {}
|
||||
});
|
||||
}));
|
||||
`;
|
||||
|
||||
const out = openSync(LOG_FILE, "a", 0o600);
|
||||
@@ -187,7 +192,7 @@ export function scheduleBackgroundInstall(latestVersion: string, currentVersion:
|
||||
command: installCommand,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
writeConfig(config);
|
||||
if (!writeConfig(config)) return false;
|
||||
|
||||
try {
|
||||
launchDetachedInstall(invocation, installCommand, latestVersion);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
install,
|
||||
installedOptionalPackageVersion,
|
||||
loadBesideCli,
|
||||
loadInstalledOptionalPackage,
|
||||
loadOptionalPackage,
|
||||
optionalPackageDir,
|
||||
type OptionalPackageDeps,
|
||||
@@ -40,6 +42,15 @@ function fakeDeps(overrides: Partial<OptionalPackageDeps> = {}) {
|
||||
return { deps, install, log, installed };
|
||||
}
|
||||
|
||||
describe("loadInstalledOptionalPackage", () => {
|
||||
it("returns null without installing when the package is not on this machine", () => {
|
||||
const { deps, install } = fakeDeps();
|
||||
|
||||
expect(loadInstalledOptionalPackage("onnxruntime-node", deps)).toBeNull();
|
||||
expect(install).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadOptionalPackage", () => {
|
||||
it("installs on first use with one plain line, then loads from the cache without installing again", async () => {
|
||||
const { deps, install, log } = fakeDeps();
|
||||
|
||||
@@ -59,6 +59,15 @@ export function installedOptionalPackageVersion(
|
||||
.version;
|
||||
}
|
||||
|
||||
export function loadInstalledOptionalPackage<N extends OptionalPackage>(
|
||||
name: N,
|
||||
deps: OptionalPackageDeps = defaultDeps,
|
||||
): OptionalPackageModules[N] | null {
|
||||
const found =
|
||||
deps.loadBesideCli(name) ?? deps.loadInstalled(optionalPackageDir(name, deps.cacheDir), name);
|
||||
return found as OptionalPackageModules[N] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load an optional package, installing it once on first use. No prompt: agents run headless.
|
||||
* Throws an error that names the manual command when the install cannot complete.
|
||||
@@ -68,11 +77,9 @@ export async function loadOptionalPackage<N extends OptionalPackage>(
|
||||
feature: string,
|
||||
deps: OptionalPackageDeps = defaultDeps,
|
||||
): Promise<OptionalPackageModules[N]> {
|
||||
const beside = deps.loadBesideCli(name);
|
||||
if (beside !== null) return beside as OptionalPackageModules[N];
|
||||
const present = loadInstalledOptionalPackage(name, deps);
|
||||
if (present !== null) return present;
|
||||
const dir = optionalPackageDir(name, deps.cacheDir);
|
||||
const installed = deps.loadInstalled(dir, name);
|
||||
if (installed !== null) return installed as OptionalPackageModules[N];
|
||||
|
||||
const version = OPTIONAL_PACKAGES[name];
|
||||
deps.log(`installing ${name} for ${feature}, once`);
|
||||
|
||||
@@ -9,6 +9,7 @@ const skillLibDir = resolve("skills/media-use/scripts/lib");
|
||||
const cliLibDir = resolve("packages/cli/src/media-use/lib");
|
||||
|
||||
export const MEDIA_USE_COPY_NAMES = [
|
||||
"config-lock.mjs",
|
||||
"cutlist.mjs",
|
||||
"duck.mjs",
|
||||
"error-diffusion.mjs",
|
||||
|
||||
@@ -54,8 +54,8 @@
|
||||
"files": 1
|
||||
},
|
||||
"media-use": {
|
||||
"hash": "7915441c22070c00",
|
||||
"files": 99
|
||||
"hash": "7b96502739a63f48",
|
||||
"files": 100
|
||||
},
|
||||
"motion-graphics": {
|
||||
"hash": "ab9d2e9c205ce1b1",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// The one lock on ~/.hyperframes/config.json, for the CLI, its auto-update step and media-use. The auto-update step
|
||||
// embeds this function's source, so it must use only its arguments and globals.
|
||||
export function withFileLock(lockPath, fs, task) {
|
||||
const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const started = Date.now();
|
||||
for (;;) {
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(lockPath, "wx");
|
||||
} catch (error) {
|
||||
if (error.code !== "EEXIST") throw error;
|
||||
// Never taken over: a lock older than any hold was left by a process that stopped, and a person removes it.
|
||||
let leftover = false;
|
||||
try {
|
||||
leftover = Date.now() - fs.statSync(lockPath).mtimeMs > 5000;
|
||||
} catch {}
|
||||
if (leftover || Date.now() - started > 10000)
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
`Settings are locked by another hyperframes process. If none is running, delete ${lockPath}`,
|
||||
),
|
||||
{ code: "HF_SETTINGS_LOCKED" },
|
||||
);
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
fs.writeSync(fd, token);
|
||||
} catch (error) {
|
||||
fs.rmSync(lockPath, { force: true });
|
||||
throw error;
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
break;
|
||||
}
|
||||
try {
|
||||
return task();
|
||||
} finally {
|
||||
// No code removes another's lock, so release needs no check (only a person deleting a live one defeats it).
|
||||
try {
|
||||
fs.rmSync(lockPath);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
// never carry intent text, file names, or paths.
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import * as fs from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { withFileLock } from "./config-lock.mjs";
|
||||
import { globalMediaDir } from "./media-home.mjs";
|
||||
|
||||
const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
|
||||
@@ -62,10 +64,24 @@ function readSharedConfig() {
|
||||
return {};
|
||||
}
|
||||
|
||||
function writeSharedConfig(config) {
|
||||
const dir = join(homedir(), ".hyperframes");
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "config.json"), JSON.stringify(config, null, 2) + "\n");
|
||||
// Best effort: skipped when the lock cannot be taken or the file cannot be read, never written unlocked.
|
||||
function updateSharedConfig(patch) {
|
||||
const file = sharedConfigPath();
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
try {
|
||||
withFileLock(`${file}.lock`, fs, () => {
|
||||
let config = {};
|
||||
try {
|
||||
config = JSON.parse(readFileSync(file, "utf8"));
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") return;
|
||||
}
|
||||
if (!config || typeof config !== "object" || Array.isArray(config)) return;
|
||||
const tmp = `${file}.${process.pid}.tmp`;
|
||||
writeFileSync(tmp, JSON.stringify({ ...config, ...patch }, null, 2) + "\n", { mode: 0o600 });
|
||||
renameSync(tmp, file);
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Adopt a pre-existing media-use-only id (~/.media/anon-id from before this
|
||||
@@ -93,7 +109,7 @@ function anonymousId() {
|
||||
return config.anonymousId.trim();
|
||||
}
|
||||
const id = legacyMediaAnonId() || randomUUID();
|
||||
writeSharedConfig({ ...config, anonymousId: id });
|
||||
updateSharedConfig({ anonymousId: id });
|
||||
return id;
|
||||
} catch {
|
||||
return "anon"; // best-effort; a shared bucket is fine if the fs is read-only
|
||||
@@ -133,7 +149,7 @@ function showTelemetryNotice() {
|
||||
"If you sign in to HeyGen, usage links to your account email or username. Opt out with HYPERFRAMES_NO_TELEMETRY=1 or DO_NOT_TRACK=1.",
|
||||
].join("\n"),
|
||||
);
|
||||
writeSharedConfig({ ...config, telemetryNoticeShown: true });
|
||||
updateSharedConfig({ telemetryNoticeShown: true });
|
||||
} catch {
|
||||
// notice is best-effort; never surface into the command
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user