@decentnetwork/beagle 0.1.124 → 0.1.126

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.
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.124";
1
+ window.__DK_UI_VERSION="0.1.126";
2
2
  "use strict";
3
3
  (() => {
4
4
  // packages/beagle-ui/dist/beagle-ui.js
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The npm global prefix a file inside a globally installed package lives
3
+ * under — the value `npm i -g --prefix` needs to replace THAT copy.
4
+ *
5
+ * /Users/x/.nvm/versions/node/v24.2.0/lib/node_modules/@decentnetwork/beagle → /Users/x/.nvm/versions/node/v24.2.0
6
+ * /usr/local/lib/node_modules/@decentnetwork/lan/dist/cli/index.js → /usr/local
7
+ * C:\Users\x\AppData\Roaming\npm\node_modules\@decentnetwork\beagle → C:\Users\x\AppData\Roaming\npm
8
+ *
9
+ * Empty when the path is not inside a global node_modules (dev checkout, npx).
10
+ * The FIRST match is the global root; a nested dependency's node_modules
11
+ * sits below it.
12
+ */
13
+ export declare function globalPrefixOf(path: string, s?: string): string;
@@ -0,0 +1,25 @@
1
+ import { sep } from "node:path";
2
+ /**
3
+ * The npm global prefix a file inside a globally installed package lives
4
+ * under — the value `npm i -g --prefix` needs to replace THAT copy.
5
+ *
6
+ * /Users/x/.nvm/versions/node/v24.2.0/lib/node_modules/@decentnetwork/beagle → /Users/x/.nvm/versions/node/v24.2.0
7
+ * /usr/local/lib/node_modules/@decentnetwork/lan/dist/cli/index.js → /usr/local
8
+ * C:\Users\x\AppData\Roaming\npm\node_modules\@decentnetwork\beagle → C:\Users\x\AppData\Roaming\npm
9
+ *
10
+ * Empty when the path is not inside a global node_modules (dev checkout, npx).
11
+ * The FIRST match is the global root; a nested dependency's node_modules
12
+ * sits below it.
13
+ */
14
+ export function globalPrefixOf(path, s = sep) {
15
+ const posix = `${s}lib${s}node_modules${s}`;
16
+ const i = path.indexOf(posix);
17
+ if (i > 0)
18
+ return path.slice(0, i);
19
+ if (s === "\\") {
20
+ const w = path.indexOf(`${s}node_modules${s}`);
21
+ if (w > 0)
22
+ return path.slice(0, w);
23
+ }
24
+ return "";
25
+ }
package/dist/server.js CHANGED
@@ -14,7 +14,7 @@
14
14
  //
15
15
  import http from "node:http";
16
16
  import net from "node:net";
17
- import { existsSync, readFileSync, writeFileSync, statSync, createReadStream } from "node:fs";
17
+ import { existsSync, readFileSync, writeFileSync, statSync, createReadStream, realpathSync } from "node:fs";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { dirname, join } from "node:path";
20
20
  import { INSTALL_COMMAND as LAN_INSTALL_COMMAND } from "./lan-handoff.js";
@@ -29,6 +29,8 @@ import { readFriendStore, pickPunk } from "./friend-store.js";
29
29
  import { desktopRequestRow } from "./pending-requests.js";
30
30
  import { createDiscoverFetch } from "./discover-cache.js";
31
31
  import { applyWalletBind } from "@decentnetwork/beagle-ui/wallet-proof";
32
+ import { settleUpload } from "./upload-settle.js";
33
+ import { globalPrefixOf } from "./global-prefix.js";
32
34
  // Directory holding the built desktop UI bundle (index.html, app.js, vendor/).
33
35
  // scripts/build-ui.mjs emits it next to this compiled module at dist/ui/desktop/.
34
36
  const DESKTOP_DIR = join(dirname(fileURLToPath(import.meta.url)), "desktop");
@@ -344,9 +346,15 @@ function verLt(a, b) {
344
346
  * different node than this app (nvm: launchd starts v24.2.0 while the shell's
345
347
  * npm is v24.3.0), and a global install lands in the prefix of whichever npm
346
348
  * ran it — the wrong one leaves the daemon on the old code with no error. */
347
- async function npmGlobalInstall(pkg, nodePath) {
349
+ /** `targetPrefix` is the global prefix the RUNNING copy lives in. Pinning node
350
+ * is not enough: an nvm npm-cli.js runs under whatever node its caller picks,
351
+ * and npm on PATH (pm2's PATH on mac-dev: v24.3.0) installs into its own
352
+ * prefix — beagle 0.1.124 ran from v24.2.0 while every click of Update put
353
+ * the new version into v24.3.0, reported success, and restarted onto the old
354
+ * code. An explicit --prefix puts the files where the restart will load them. */
355
+ async function npmGlobalInstall(pkg, nodePath, targetPrefix) {
348
356
  const { spawn } = await import("node:child_process");
349
- const args = ["i", "-g", `${pkg}@latest`];
357
+ const args = ["i", "-g", `${pkg}@latest`, ...(targetPrefix ? ["--prefix", targetPrefix] : [])];
350
358
  const cliFor = (node) => join(dirname(node), "..", "lib", "node_modules", "npm", "bin", "npm-cli.js");
351
359
  const npmCli = cliFor(process.execPath);
352
360
  const pinned = nodePath && existsSync(nodePath) && existsSync(cliFor(nodePath)) ? cliFor(nodePath) : "";
@@ -356,21 +364,45 @@ async function npmGlobalInstall(pkg, nodePath) {
356
364
  { cmd: "npm", args },
357
365
  ...(existsSync(npmCli) ? [{ cmd: process.execPath, args: [npmCli, ...args] }] : []),
358
366
  ];
367
+ const runOnce = (cmd, cmdArgs, timeoutMs = 5 * 60_000) => new Promise((resolveRun) => {
368
+ const ch = spawn(cmd, cmdArgs, { shell: process.platform === "win32" });
369
+ let out = "";
370
+ const timer = setTimeout(() => ch.kill("SIGKILL"), timeoutMs);
371
+ ch.stdout?.on("data", (c) => (out += c));
372
+ ch.stderr?.on("data", (c) => (out += c));
373
+ ch.on("error", (e) => { clearTimeout(timer); resolveRun({ code: null, out, spawnErr: e.message }); });
374
+ ch.on("close", (code) => { clearTimeout(timer); resolveRun({ code, out }); });
375
+ });
359
376
  for (const a of attempts) {
360
- const r = await new Promise((resolveRun) => {
361
- const ch = spawn(a.cmd, a.args, { shell: process.platform === "win32" });
362
- let out = "";
363
- const timer = setTimeout(() => ch.kill("SIGKILL"), 5 * 60_000);
364
- ch.stdout?.on("data", (c) => (out += c));
365
- ch.stderr?.on("data", (c) => (out += c));
366
- ch.on("error", (e) => { clearTimeout(timer); resolveRun({ code: null, out, spawnErr: e.message }); });
367
- ch.on("close", (code) => { clearTimeout(timer); resolveRun({ code, out }); });
368
- });
377
+ const r = await runOnce(a.cmd, a.args);
369
378
  if (r.spawnErr)
370
379
  continue; // npm not found this way — try the next resolver
371
380
  const tail = r.out.slice(-2000);
372
381
  if (r.code === 0)
373
382
  return { ok: true, log: tail };
383
+ // Root-owned prefix, but this account may run root commands without a
384
+ // password (WSL's default user, cloud images). A privileged install is then
385
+ // something the person can already do with no prompt, and going through
386
+ // `sudo -n` EVERY time keeps the layout consistent: the dir stays
387
+ // root-owned and the next click takes the same path, so no loop. Never
388
+ // prompts (-n), never changes permissions. -H gives npm root's own cache,
389
+ // so no root-owned files land in ~/.npm. Absolute node + npm-cli and an
390
+ // explicit --prefix, because sudo resets PATH and HOME and root's npm
391
+ // could otherwise resolve a different global prefix than this user's.
392
+ if (/EACCES|permission denied/i.test(r.out) && (await canSudoNonInteractive())) {
393
+ const npmCmd = a.cmd === "npm" ? resolveNpmOnPath(npmCli) : [a.cmd, a.args[0]];
394
+ if (npmCmd.length) {
395
+ const [exe, ...pre0] = npmCmd;
396
+ const pre = targetPrefix ? undefined : await runOnce(exe, [...pre0, "prefix", "-g"], 15_000);
397
+ const prefix = targetPrefix || (pre?.code === 0 ? pre.out.trim().split("\n").pop()?.trim() : "");
398
+ if (prefix) {
399
+ const s = await runOnce("sudo", ["-n", "-H", ...npmCmd, ...args, ...(targetPrefix ? [] : ["--prefix", prefix])]);
400
+ if (s.code === 0)
401
+ return { ok: true, log: s.out.slice(-2000) };
402
+ return { ok: false, error: `sudo -n npm exited ${s.code}`, log: s.out.slice(-2000) };
403
+ }
404
+ }
405
+ }
374
406
  // Do NOT tell people to re-run it with sudo. `sudo npm i -g` succeeds and
375
407
  // leaves the package directory owned by root, so the NEXT unprivileged
376
408
  // update fails exactly the same way — the advice was the thing keeping
@@ -412,9 +444,48 @@ async function globalNpmWritable() {
412
444
  return { ok: true };
413
445
  }
414
446
  catch {
447
+ // Not writable by us — but npmGlobalInstall falls back to `sudo -n` when
448
+ // that needs no password, so the button does work there.
449
+ if (await canSudoNonInteractive())
450
+ return { ok: true };
415
451
  return { ok: false, reason: await globalNpmOwnershipHint(BEAGLE_PKG) };
416
452
  }
417
453
  }
454
+ /** The `npm` this process would run from PATH, as an absolute command sudo can
455
+ * run after it resets PATH: `[node, npm-cli.js]` when npm is a symlink to the
456
+ * JS entry (nvm, nodejs.org, Debian's /usr/bin/npm → /usr/share/nodejs/npm),
457
+ * so the same node runs it; else the resolved file itself. Falls back to the
458
+ * npm-cli.js beside the running node. Empty when none is found. */
459
+ function resolveNpmOnPath(besideNode) {
460
+ for (const dir of (process.env.PATH ?? "").split(":")) {
461
+ if (!dir)
462
+ continue;
463
+ const p = join(dir, "npm");
464
+ if (!existsSync(p))
465
+ continue;
466
+ try {
467
+ const real = realpathSync(p);
468
+ return real.endsWith(".js") ? [process.execPath, real] : [real];
469
+ }
470
+ catch {
471
+ continue;
472
+ }
473
+ }
474
+ return existsSync(besideNode) ? [process.execPath, besideNode] : [];
475
+ }
476
+ /** True when `sudo -n true` succeeds: this account runs root commands without
477
+ * a password prompt. Never prompts. Cached for a minute. */
478
+ let sudoNoPassCache = null;
479
+ async function canSudoNonInteractive() {
480
+ if (process.platform === "win32" || process.getuid?.() === 0)
481
+ return false;
482
+ if (sudoNoPassCache && Date.now() - sudoNoPassCache.at < 60_000)
483
+ return sudoNoPassCache.ok;
484
+ const { execFile } = await import("node:child_process");
485
+ const ok = await new Promise((res) => execFile("sudo", ["-n", "true"], { timeout: 5000 }, (err) => res(!err)));
486
+ sudoNoPassCache = { at: Date.now(), ok };
487
+ return ok;
488
+ }
418
489
  /**
419
490
  * What to tell someone whose global npm directory this process cannot write.
420
491
  *
@@ -491,13 +562,31 @@ async function npmQuery(args) {
491
562
  /** The node binary the running daemon was started with, from its pid's
492
563
  * command line (`/…/bin/node /…/bin/agentnet up …`). Empty when unknown. */
493
564
  async function daemonNodePath(dataDir) {
565
+ const first = (await daemonCommand(dataDir))[0] ?? "";
566
+ return /(^|\/)node(\.exe)?$/.test(first) && existsSync(first) ? first : "";
567
+ }
568
+ /** The global prefix the running daemon's lan was loaded from: its script
569
+ * (`/…/bin/agentnet`, a symlink into `<prefix>/lib/node_modules/…`) resolved.
570
+ * Empty when unknown — the install then falls back to npm's own prefix. */
571
+ async function daemonLanPrefix(dataDir) {
572
+ const script = (await daemonCommand(dataDir))[1] ?? "";
573
+ if (!script || !existsSync(script))
574
+ return "";
575
+ try {
576
+ return globalPrefixOf(realpathSync(script));
577
+ }
578
+ catch {
579
+ return "";
580
+ }
581
+ }
582
+ /** The daemon's command line split on whitespace, from its pidfile's pid. */
583
+ async function daemonCommand(dataDir) {
494
584
  const pid = dataDir ? readDaemonPid(dataDir) : "";
495
585
  if (!/^\d+$/.test(pid) || process.platform === "win32")
496
- return "";
586
+ return [];
497
587
  const { execFile } = await import("node:child_process");
498
588
  const cmd = await new Promise((res) => execFile("ps", ["-o", "command=", "-p", pid], { timeout: 5000 }, (err, out) => res(err ? "" : String(out).trim())));
499
- const first = cmd.split(/\s+/)[0] ?? "";
500
- return /(^|\/)node(\.exe)?$/.test(first) && existsSync(first) ? first : "";
589
+ return cmd ? cmd.split(/\s+/) : [];
501
590
  }
502
591
  async function agentnetRestart(dataDir) {
503
592
  const pidBefore = dataDir ? readDaemonPid(dataDir) : "";
@@ -1425,42 +1514,33 @@ export function startBeagleServer(opts) {
1425
1514
  else {
1426
1515
  destDir = await fsp.mkdtemp(join(os.tmpdir(), "agentnet-up-"));
1427
1516
  }
1428
- let destName = safe;
1429
- let destPath = join(destDir, destName);
1430
- // Re-sending a file the user already has must not duplicate it.
1431
- // The -N suffix exists so two DIFFERENT files sharing a name both
1432
- // survive — but an identical re-send hit it too, so every retry of
1433
- // the same 52 MB video wrote another full copy (observed: four
1434
- // byte-identical copies of one file, and a 3.9 GB downloads dir).
1435
- // Reuse the existing path when the size already matches, so the
1436
- // upload overwrites that copy instead of growing a new one. Tradeoff:
1437
- // a different file with the same name AND the same byte count
1438
- // replaces the older copy rather than sitting beside it — the same
1439
- // thing any "save to downloads" does, and far better than unbounded
1440
- // duplication. Hashing instead would mean buffering the whole upload.
1517
+ // The upload ALWAYS lands in a fresh temp file first, never on an
1518
+ // existing path. It used to open the same-name, same-size file in
1519
+ // downloads/ for writing ("re-send dedupe") — and the most natural
1520
+ // way to forward a received file is to pick exactly that file. The
1521
+ // write truncated the very file the browser was still streaming the
1522
+ // upload from: a 29.7 MB recording was cut to 832 KB on disk and the
1523
+ // 832 KB was sent as "delivered" (mac-dev → nv4090-wsl, 2026-11-02).
1441
1524
  const uploadSize = Number(req.headers["content-length"] ?? NaN);
1442
- if (keep) {
1443
- const dot = safe.lastIndexOf(".");
1444
- const base = dot > 0 ? safe.slice(0, dot) : safe;
1445
- const ext = dot > 0 ? safe.slice(dot) : "";
1446
- for (let i = 1; existsSync(destPath); i++) {
1447
- // Same size is a cheap, reliable proxy here: these are the
1448
- // sender's own files, not adversarial input, and the full
1449
- // compare would mean buffering the upload to hash it.
1450
- if (Number.isFinite(uploadSize) && statSync(destPath).size === uploadSize)
1451
- break;
1452
- destName = `${base}-${i}${ext}`;
1453
- destPath = join(destDir, destName);
1454
- }
1455
- }
1525
+ const tmpPath = join(destDir, `.${safe}.${process.pid}-${Date.now()}.part`);
1526
+ let destPath = tmpPath;
1456
1527
  try {
1457
1528
  await new Promise((resolve2, reject) => {
1458
- const ws = fs.createWriteStream(destPath);
1529
+ const ws = fs.createWriteStream(tmpPath);
1459
1530
  req.pipe(ws);
1460
1531
  req.on("error", reject);
1532
+ // A browser that gives up mid-upload closes the socket; without
1533
+ // this the partial file could still be handed to the daemon.
1534
+ req.on("close", () => { if (!req.complete)
1535
+ reject(new Error("upload aborted by the browser")); });
1461
1536
  ws.on("error", reject);
1462
1537
  ws.on("finish", () => resolve2());
1463
1538
  });
1539
+ const got = statSync(tmpPath).size;
1540
+ if (Number.isFinite(uploadSize) && got !== uploadSize) {
1541
+ throw new Error(`upload incomplete (${got} of ${uploadSize} bytes) — nothing was sent`);
1542
+ }
1543
+ destPath = keep ? await settleUpload(tmpPath, destDir, safe) : tmpPath;
1464
1544
  const r = await opts.call({ op: "file-send", userid, path: destPath });
1465
1545
  if (!r.ok) {
1466
1546
  sendJson(res, 502, r);
@@ -1474,6 +1554,8 @@ export function startBeagleServer(opts) {
1474
1554
  finally {
1475
1555
  if (!keep)
1476
1556
  await fsp.rm(destDir, { recursive: true, force: true }).catch(() => undefined);
1557
+ else
1558
+ await fsp.rm(tmpPath, { force: true }).catch(() => undefined); // no-op once settled
1477
1559
  }
1478
1560
  return;
1479
1561
  }
@@ -1838,7 +1920,7 @@ export function startBeagleServer(opts) {
1838
1920
  // lan (the agentnet daemon) updates independently of beagle's own
1839
1921
  // install mode: global reinstall, then ask the daemon to re-exec.
1840
1922
  if (body.pkg === "lan") {
1841
- const inst = await npmGlobalInstall("@decentnetwork/lan", await daemonNodePath(opts.lanHost?.dataDir));
1923
+ const inst = await npmGlobalInstall("@decentnetwork/lan", await daemonNodePath(opts.lanHost?.dataDir), await daemonLanPrefix(opts.lanHost?.dataDir));
1842
1924
  if (!inst.ok) {
1843
1925
  sendJson(res, 502, inst);
1844
1926
  return;
@@ -1862,7 +1944,8 @@ export function startBeagleServer(opts) {
1862
1944
  sendJson(res, 200, { ok: true, npx: true });
1863
1945
  return;
1864
1946
  }
1865
- const r = await npmGlobalInstall(BEAGLE_PKG);
1947
+ // Into the prefix THIS copy runs from, with this node's npm — see npmGlobalInstall.
1948
+ const r = await npmGlobalInstall(BEAGLE_PKG, process.execPath, globalPrefixOf(BEAGLE_ROOT) || undefined);
1866
1949
  updateCache = null; // next check reflects the new install
1867
1950
  sendJson(res, r.ok ? 200 : 502, r);
1868
1951
  return;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Give a finished upload (already complete in `tmpPath`, inside `dir`) its
3
+ * final name, without ever writing over an existing file.
4
+ *
5
+ * Re-sending a file that is already in downloads/ must not grow another copy
6
+ * (observed: four byte-identical copies of one 52 MB video). The old dedupe
7
+ * did that by opening the same-name, same-size file for WRITING before the
8
+ * upload arrived — which truncated the source when that file was the one
9
+ * being uploaded (forwarding a received file). Now the comparison happens
10
+ * after the upload is safely on disk: an existing copy is reused only when
11
+ * its bytes are identical; a different file with the same name gets `-N`.
12
+ */
13
+ export declare function settleUpload(tmpPath: string, dir: string, name: string): Promise<string>;
@@ -0,0 +1,42 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream, existsSync, statSync } from "node:fs";
3
+ import { rename, rm } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ /**
6
+ * Give a finished upload (already complete in `tmpPath`, inside `dir`) its
7
+ * final name, without ever writing over an existing file.
8
+ *
9
+ * Re-sending a file that is already in downloads/ must not grow another copy
10
+ * (observed: four byte-identical copies of one 52 MB video). The old dedupe
11
+ * did that by opening the same-name, same-size file for WRITING before the
12
+ * upload arrived — which truncated the source when that file was the one
13
+ * being uploaded (forwarding a received file). Now the comparison happens
14
+ * after the upload is safely on disk: an existing copy is reused only when
15
+ * its bytes are identical; a different file with the same name gets `-N`.
16
+ */
17
+ export async function settleUpload(tmpPath, dir, name) {
18
+ const dot = name.lastIndexOf(".");
19
+ const base = dot > 0 ? name.slice(0, dot) : name;
20
+ const ext = dot > 0 ? name.slice(dot) : "";
21
+ const size = statSync(tmpPath).size;
22
+ for (let i = 0;; i++) {
23
+ const candidate = join(dir, i === 0 ? name : `${base}-${i}${ext}`);
24
+ if (!existsSync(candidate)) {
25
+ await rename(tmpPath, candidate);
26
+ return candidate;
27
+ }
28
+ if (statSync(candidate).size === size && (await sha256File(candidate)) === (await sha256File(tmpPath))) {
29
+ await rm(tmpPath, { force: true });
30
+ return candidate;
31
+ }
32
+ }
33
+ }
34
+ function sha256File(path) {
35
+ return new Promise((resolve, reject) => {
36
+ const h = createHash("sha256");
37
+ createReadStream(path)
38
+ .on("data", (c) => h.update(c))
39
+ .on("error", reject)
40
+ .on("end", () => resolve(h.digest("hex")));
41
+ });
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.124",
3
+ "version": "0.1.126",
4
4
  "description": "Beagle — P2P chat, file transfer and calls for regular users, on the Decent Network. No admin privilege required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",