hyperframes 0.8.79 → 0.8.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ // The one lock on ~/.hyperframes/config.json, for the CLI, its auto-update step and media-use. The auto-update step
2
+ // embeds this function's source, so it must use only its arguments and globals.
3
+ export function withFileLock(lockPath, fs, task) {
4
+ const token = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
5
+ const started = Date.now();
6
+ for (;;) {
7
+ let fd;
8
+ try {
9
+ fd = fs.openSync(lockPath, "wx");
10
+ } catch (error) {
11
+ if (error.code !== "EEXIST") throw error;
12
+ // Never taken over: a lock older than any hold was left by a process that stopped, and a person removes it.
13
+ let leftover = false;
14
+ try {
15
+ leftover = Date.now() - fs.statSync(lockPath).mtimeMs > 5000;
16
+ } catch {}
17
+ if (leftover || Date.now() - started > 10000)
18
+ throw Object.assign(
19
+ new Error(
20
+ `Settings are locked by another hyperframes process. If none is running, delete ${lockPath}`,
21
+ ),
22
+ { code: "HF_SETTINGS_LOCKED" },
23
+ );
24
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
25
+ continue;
26
+ }
27
+ try {
28
+ fs.writeSync(fd, token);
29
+ } catch (error) {
30
+ fs.rmSync(lockPath, { force: true });
31
+ throw error;
32
+ } finally {
33
+ fs.closeSync(fd);
34
+ }
35
+ break;
36
+ }
37
+ try {
38
+ return task();
39
+ } finally {
40
+ // No code removes another's lock, so release needs no check (only a person deleting a live one defeats it).
41
+ try {
42
+ fs.rmSync(lockPath);
43
+ } catch {}
44
+ }
45
+ }
@@ -0,0 +1,79 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { spawn } from "node:child_process";
3
+ import { test } from "node:test";
4
+ import * as fs from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { withFileLock } from "./config-lock.mjs";
8
+
9
+ function lockIn() {
10
+ const dir = fs.mkdtempSync(join(tmpdir(), "hf-config-lock-"));
11
+ return {
12
+ lock: join(dir, "config.json.lock"),
13
+ cleanup: () => fs.rmSync(dir, { recursive: true }),
14
+ };
15
+ }
16
+
17
+ test("returns the task's result even when the lock cannot be released", () => {
18
+ const { lock, cleanup } = lockIn();
19
+ try {
20
+ const refusing = {
21
+ ...fs,
22
+ rmSync(path, options) {
23
+ if (path === lock) throw Object.assign(new Error("EPERM"), { code: "EPERM" });
24
+ return fs.rmSync(path, options);
25
+ },
26
+ };
27
+ assert.equal(
28
+ withFileLock(lock, refusing, () => "saved"),
29
+ "saved",
30
+ );
31
+ } finally {
32
+ cleanup();
33
+ }
34
+ });
35
+
36
+ test("refuses a lock left by a process that stopped, names it, and never removes it", () => {
37
+ const { lock, cleanup } = lockIn();
38
+ try {
39
+ fs.writeFileSync(lock, "dead");
40
+ const past = new Date(Date.now() - 60_000);
41
+ fs.utimesSync(lock, past, past);
42
+ const started = Date.now();
43
+ let ran = false;
44
+
45
+ assert.throws(
46
+ () => withFileLock(lock, fs, () => (ran = true)),
47
+ (error) => error.code === "HF_SETTINGS_LOCKED" && error.message.includes(lock),
48
+ );
49
+ assert.ok(Date.now() - started < 1000);
50
+ assert.equal(ran, false);
51
+ assert.equal(fs.readFileSync(lock, "utf8"), "dead");
52
+ } finally {
53
+ cleanup();
54
+ }
55
+ });
56
+
57
+ test("waits for a process that holds the lock, then takes it", async () => {
58
+ const { lock, cleanup } = lockIn();
59
+ try {
60
+ const script = `
61
+ const fs = require("fs");
62
+ const lock = process.argv[1];
63
+ fs.writeFileSync(lock, "other", { flag: "wx" });
64
+ process.stdout.write("locked\\n");
65
+ setTimeout(() => {
66
+ fs.writeFileSync(lock + ".released", String(Date.now()));
67
+ fs.rmSync(lock);
68
+ }, 300);`;
69
+ const other = spawn(process.execPath, ["-e", script, lock]);
70
+ await new Promise((ready) => other.stdout.once("data", ready));
71
+
72
+ const ranAt = withFileLock(lock, fs, () => Date.now());
73
+
74
+ assert.ok(ranAt >= Number(fs.readFileSync(`${lock}.released`, "utf8")));
75
+ await new Promise((exited) => other.once("exit", exited));
76
+ } finally {
77
+ cleanup();
78
+ }
79
+ });
@@ -2,9 +2,11 @@
2
2
  // never carry intent text, file names, or paths.
3
3
 
4
4
  import { randomUUID } from "node:crypto";
5
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import * as fs from "node:fs";
6
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
7
  import { homedir } from "node:os";
7
- import { join } from "node:path";
8
+ import { dirname, join } from "node:path";
9
+ import { withFileLock } from "./config-lock.mjs";
8
10
  import { globalMediaDir } from "./media-home.mjs";
9
11
 
10
12
  const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
@@ -62,10 +64,24 @@ function readSharedConfig() {
62
64
  return {};
63
65
  }
64
66
 
65
- function writeSharedConfig(config) {
66
- const dir = join(homedir(), ".hyperframes");
67
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
68
- writeFileSync(join(dir, "config.json"), JSON.stringify(config, null, 2) + "\n");
67
+ // Best effort: skipped when the lock cannot be taken or the file cannot be read, never written unlocked.
68
+ function updateSharedConfig(patch) {
69
+ const file = sharedConfigPath();
70
+ mkdirSync(dirname(file), { recursive: true });
71
+ try {
72
+ withFileLock(`${file}.lock`, fs, () => {
73
+ let config = {};
74
+ try {
75
+ config = JSON.parse(readFileSync(file, "utf8"));
76
+ } catch (error) {
77
+ if (error.code !== "ENOENT") return;
78
+ }
79
+ if (!config || typeof config !== "object" || Array.isArray(config)) return;
80
+ const tmp = `${file}.${process.pid}.tmp`;
81
+ writeFileSync(tmp, JSON.stringify({ ...config, ...patch }, null, 2) + "\n", { mode: 0o600 });
82
+ renameSync(tmp, file);
83
+ });
84
+ } catch {}
69
85
  }
70
86
 
71
87
  // Adopt a pre-existing media-use-only id (~/.media/anon-id from before this
@@ -93,7 +109,7 @@ function anonymousId() {
93
109
  return config.anonymousId.trim();
94
110
  }
95
111
  const id = legacyMediaAnonId() || randomUUID();
96
- writeSharedConfig({ ...config, anonymousId: id });
112
+ updateSharedConfig({ anonymousId: id });
97
113
  return id;
98
114
  } catch {
99
115
  return "anon"; // best-effort; a shared bucket is fine if the fs is read-only
@@ -133,7 +149,7 @@ function showTelemetryNotice() {
133
149
  "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.",
134
150
  ].join("\n"),
135
151
  );
136
- writeSharedConfig({ ...config, telemetryNoticeShown: true });
152
+ updateSharedConfig({ telemetryNoticeShown: true });
137
153
  } catch {
138
154
  // notice is best-effort; never surface into the command
139
155
  }
@@ -1,4 +1,5 @@
1
1
  import { strict as assert } from "node:assert";
2
+ import { spawn } from "node:child_process";
2
3
  import { test } from "node:test";
3
4
  import {
4
5
  existsSync,
@@ -372,3 +373,65 @@ test("read-only telemetry state degrades without throwing", async () => {
372
373
  __resetTelemetryForTest();
373
374
  }
374
375
  });
376
+
377
+ async function trackQuietly(home) {
378
+ const originalFetch = globalThis.fetch;
379
+ const originalError = console.error;
380
+ globalThis.fetch = async () => ({ ok: true });
381
+ console.error = () => {};
382
+ try {
383
+ withoutTelemetryOptOut();
384
+ await track("media_use_resolve", { type: "bgm" });
385
+ return readFileSync(join(home, ".hyperframes/config.json"), "utf8");
386
+ } finally {
387
+ globalThis.fetch = originalFetch;
388
+ console.error = originalError;
389
+ }
390
+ }
391
+
392
+ test("waits for another process's locked settings write, then keeps the no it saved", async () => {
393
+ const savedEnv = { ...process.env };
394
+ const { root, home } = sandbox();
395
+ const dir = join(home, ".hyperframes");
396
+ mkdirSync(dir, { recursive: true });
397
+ const holder = spawn(process.execPath, [
398
+ "-e",
399
+ `const fs = require("fs");
400
+ const dir = process.argv[1];
401
+ fs.writeFileSync(dir + "/config.json.lock", "", { flag: "wx" });
402
+ process.stdout.write("locked\\n");
403
+ setTimeout(() => {
404
+ fs.writeFileSync(dir + "/other.tmp", JSON.stringify({ localEmbeddingEnabled: false }));
405
+ fs.renameSync(dir + "/other.tmp", dir + "/config.json");
406
+ fs.rmSync(dir + "/config.json.lock");
407
+ }, 1000);`,
408
+ dir,
409
+ ]);
410
+ const exited = new Promise((done) => holder.on("exit", done));
411
+ try {
412
+ await new Promise((locked) => holder.stdout.once("data", locked));
413
+ const saved = JSON.parse(await trackQuietly(home));
414
+ await exited;
415
+ assert.equal(saved.localEmbeddingEnabled, false);
416
+ assert.equal(saved.telemetryNoticeShown, true);
417
+ assert.equal(typeof saved.anonymousId, "string");
418
+ } finally {
419
+ restoreEnv(savedEnv);
420
+ rmSync(root, { recursive: true, force: true });
421
+ __resetTelemetryForTest();
422
+ }
423
+ });
424
+
425
+ test("leaves a settings file it cannot read untouched", async () => {
426
+ const savedEnv = { ...process.env };
427
+ const { root, home } = sandbox();
428
+ mkdirSync(join(home, ".hyperframes"), { recursive: true });
429
+ writeFileSync(join(home, ".hyperframes/config.json"), "{not json");
430
+ try {
431
+ assert.equal(await trackQuietly(home), "{not json");
432
+ } finally {
433
+ restoreEnv(savedEnv);
434
+ rmSync(root, { recursive: true, force: true });
435
+ __resetTelemetryForTest();
436
+ }
437
+ });
@@ -50,30 +50,10 @@ var CACHE_DIR = join(homedir(), ".cache", "hyperframes", "optional");
50
50
  function optionalPackageDir(name, cacheDir = CACHE_DIR) {
51
51
  return join(cacheDir, `${name.replace("/", "__")}@${OPTIONAL_PACKAGES[name]}`);
52
52
  }
53
- async function loadOptionalPackage(name, feature, deps = defaultDeps) {
54
- const beside = deps.loadBesideCli(name);
55
- if (beside !== null) return beside;
56
- const dir = optionalPackageDir(name, deps.cacheDir);
57
- const installed = deps.loadInstalled(dir, name);
58
- if (installed !== null) return installed;
59
- const version = OPTIONAL_PACKAGES[name];
60
- deps.log(`installing ${name} for ${feature}, once`);
61
- try {
62
- await deps.install(dir, name, version);
63
- } catch (err) {
64
- const output = err.message.trim();
65
- const advice = NETWORK_FAILURE.test(output) ? "Check your network connection, then retry" : "Retry";
66
- throw new Error(
67
- `${feature} needs ${name}, and installing it failed (${output.split("\n")[0]}). ${advice}, or install it yourself: npm install ${name}@${version} --prefix "${dir}"`
68
- );
69
- }
70
- const loaded = deps.loadInstalled(dir, name);
71
- if (loaded === null) {
72
- throw new Error(`${name}@${version} installed into ${dir} but could not be loaded.`);
73
- }
74
- return loaded;
53
+ function loadInstalledOptionalPackage(name, deps = defaultDeps) {
54
+ const found = deps.loadBesideCli(name) ?? deps.loadInstalled(optionalPackageDir(name, deps.cacheDir), name);
55
+ return found;
75
56
  }
76
- var NETWORK_FAILURE = /ENOTFOUND|ETIMEDOUT|EAI_AGAIN|ECONNRESET|ECONNREFUSED|network/i;
77
57
  function manifestPath(dir, name) {
78
58
  return join(dir, "node_modules", name, "package.json");
79
59
  }
@@ -187,6 +167,7 @@ import { Transform } from "stream";
187
167
  import { pipeline } from "stream/promises";
188
168
 
189
169
  // src/telemetry/config.ts
170
+ import * as fs from "fs";
190
171
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync as renameSync3, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
191
172
  import { join as join2 } from "path";
192
173
  import { homedir as homedir2 } from "os";
@@ -333,9 +314,9 @@ function isChinese(code) {
333
314
 
334
315
  // src/registry/localEmbedder.ts
335
316
  var MAX_TOKENS = 512;
336
- var FEATURE = "on-device search";
337
317
  async function loadLocalEmbedder() {
338
- const ort = await loadOptionalPackage("onnxruntime-node", FEATURE);
318
+ const ort = loadInstalledOptionalPackage("onnxruntime-node");
319
+ if (ort === null) throw new Error("the on-device runtime is not installed yet");
339
320
  const config = configFromTokenizerJson(readFileSync4(localTokenizerPath(), "utf-8"));
340
321
  const session = await ort.InferenceSession.create(localModelPath());
341
322
  return {