diggylab 0.1.34 → 0.1.36

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.
Files changed (3) hide show
  1. package/README.md +14 -4
  2. package/cli.js +102 -18
  3. package/package.json +5 -2
package/README.md CHANGED
@@ -6,9 +6,9 @@ headless Chromium — the Electron app is not involved.
6
6
 
7
7
  ## Requirements
8
8
 
9
- - Node.js 22+
10
- - Linux packages: `xvfb`, `x11vnc`, `websockify`, `novnc` (Debian/Ubuntu: `apt install xvfb x11vnc websockify novnc`). The Docker image below bundles them.
11
- - Chromium for Playwright: run `diggylab install-browser` once after installing the CLI.
9
+ - Node.js 20+
10
+ - Linux packages: `xvfb`, `x11vnc`, `websockify`, `novnc` — run `diggylab install-display` (Debian/Ubuntu) or install them yourself. The Docker image below bundles them.
11
+ - Chromium for Playwright: downloaded automatically during `npm install`; rerun `diggylab install-browser` if that download failed.
12
12
 
13
13
  ## Install
14
14
 
@@ -61,7 +61,8 @@ npm publish apps/desktop-agent/dist-npm --access public
61
61
  | `login` | Opens the Facebook login page on a virtual screen and prints a noVNC link + QR code. Log in from your phone; the session is stored encrypted on the server. |
62
62
  | `run` | Connects the WebSocket worker, receives assigned scans, scans headlessly, uploads results. |
63
63
  | `status` | Shows the config directory, API origin, paired device, and Facebook session state. |
64
- | `install-browser` | Downloads the Chromium build Playwright needs (one time, ~150 MB). Run it once after installing the CLI. |
64
+ | `install-browser` | Downloads the Chromium build Playwright needs (~150 MB). It also runs during `npm install`, so you only need it if that download failed. |
65
+ | `install-display` | On Debian/Ubuntu, installs `xvfb`, `x11vnc`, `websockify`, and `novnc` for the shared login screen. |
65
66
  | `unpair` | Removes device credentials, the Facebook session, and pending scans. |
66
67
 
67
68
  Useful options: `--api <origin>` (default `https://api.diggylab.com`), `--port` / `--bind` /
@@ -80,6 +81,15 @@ The noVNC URL carries a one-time token, the port is only open while `login` runs
80
81
  torn down when the login finishes or times out (10 minutes). If you prefer not to expose a port,
81
82
  pass `--bind 127.0.0.1` and forward it over SSH (`ssh -L 6080:127.0.0.1:6080 <server>`).
82
83
 
84
+ ### Which address the link uses
85
+
86
+ The link points at the machine's default-route address, so a multi-homed server advertises the
87
+ network it actually reaches the internet through. When that address is private, the agent also looks
88
+ up the public IP and prints a second link for phones on another network — forward the port first.
89
+
90
+ Pin it explicitly with `--public-host <ip or domain>` or `DIGGYLAB_PUBLIC_HOST`, for example when a
91
+ domain points at the server.
92
+
83
93
  ## Security notes
84
94
 
85
95
  - `~/.diggylab/key` (mode `600`) encrypts device credentials, the Facebook session, and the
package/cli.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // src/cli/cli.ts
4
4
  import { randomUUID as randomUUID3 } from "node:crypto";
5
5
  import { spawn as spawn2 } from "node:child_process";
6
- import { existsSync as existsSync2, readFileSync as readFileSync2, realpathSync } from "node:fs";
6
+ import { existsSync as existsSync2, readFileSync as readFileSync3, realpathSync } from "node:fs";
7
7
  import { hostname, homedir } from "node:os";
8
8
  import { dirname as dirname3, join as join4 } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
@@ -2351,6 +2351,12 @@ function parseCliArgs(argv) {
2351
2351
  return { command, options };
2352
2352
  }
2353
2353
 
2354
+ // src/cli/browser-install.ts
2355
+ function browserInstallCommands(platform) {
2356
+ const install = ["install", "chromium"];
2357
+ return platform === "linux" ? [["install", "--with-deps", "chromium"], install] : [install];
2358
+ }
2359
+
2354
2360
  // src/cli/file-crypto.ts
2355
2361
  import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
2356
2362
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -2411,11 +2417,13 @@ function terminalQr(value) {
2411
2417
  // src/cli/virtual-display.ts
2412
2418
  import { spawn } from "node:child_process";
2413
2419
  import { randomBytes as randomBytes2 } from "node:crypto";
2414
- import { mkdirSync as mkdirSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
2420
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
2415
2421
  import { createConnection } from "node:net";
2416
2422
  import { networkInterfaces } from "node:os";
2417
2423
  import { join as join3 } from "node:path";
2418
2424
  var START_TIMEOUT_MS = 15e3;
2425
+ var PUBLIC_IP_TIMEOUT_MS = 3e3;
2426
+ var PUBLIC_IP_ENDPOINT = "https://api.ipify.org";
2419
2427
  function waitForPort(host, port, timeoutMs) {
2420
2428
  const deadline = Date.now() + timeoutMs;
2421
2429
  return new Promise((resolve, reject) => {
@@ -2437,13 +2445,36 @@ function waitForPort(host, port, timeoutMs) {
2437
2445
  attempt();
2438
2446
  });
2439
2447
  }
2440
- function localAddress() {
2441
- for (const addresses of Object.values(networkInterfaces())) {
2442
- for (const address of addresses ?? []) {
2443
- if (address.family === "IPv4" && !address.internal) return address.address;
2448
+ function isPrivateAddress(address) {
2449
+ const [first, second] = address.split(".").map(Number);
2450
+ return first === 10 || first === 127 || first === 169 && second === 254 || first === 172 && second >= 16 && second <= 31 || first === 192 && second === 168 || first === 100 && second >= 64 && second <= 127;
2451
+ }
2452
+ function defaultRouteInterface() {
2453
+ if (process.platform !== "linux") return null;
2454
+ try {
2455
+ for (const line of readFileSync2("/proc/net/route", "utf8").split("\n").slice(1)) {
2456
+ const [iface, destination, , , , , flags] = line.trim().split(/\s+/);
2457
+ if (destination === "00000000" && Number.parseInt(flags ?? "0", 16) & 2) return iface;
2444
2458
  }
2459
+ } catch {
2460
+ }
2461
+ return null;
2462
+ }
2463
+ function localAddress() {
2464
+ const interfaces = networkInterfaces();
2465
+ const addressOf = (addresses) => addresses?.find((entry) => entry.family === "IPv4" && !entry.internal)?.address ?? null;
2466
+ const preferred = defaultRouteInterface();
2467
+ return (preferred ? addressOf(interfaces[preferred]) : null) ?? Object.values(interfaces).map(addressOf).find((address) => address !== null) ?? "127.0.0.1";
2468
+ }
2469
+ async function detectPublicIp(fetcher = fetch) {
2470
+ try {
2471
+ const response = await fetcher(PUBLIC_IP_ENDPOINT, { signal: AbortSignal.timeout(PUBLIC_IP_TIMEOUT_MS) });
2472
+ if (!response.ok) return null;
2473
+ const value = (await response.text()).trim();
2474
+ return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(value) ? value : null;
2475
+ } catch {
2476
+ return null;
2445
2477
  }
2446
- return "127.0.0.1";
2447
2478
  }
2448
2479
  function start(command, args, env, deps) {
2449
2480
  const child = deps.spawnProcess(command, args, env);
@@ -2454,7 +2485,13 @@ function start(command, args, env, deps) {
2454
2485
  return { child, failure };
2455
2486
  }
2456
2487
  async function startVirtualDisplay(options, overrides = {}) {
2457
- const deps = { spawnProcess: spawn, waitForPort, ...overrides };
2488
+ const deps = {
2489
+ detectPublicIp: () => detectPublicIp(),
2490
+ localAddress,
2491
+ spawnProcess: spawn,
2492
+ waitForPort,
2493
+ ...overrides
2494
+ };
2458
2495
  const bind = options.bind ?? "0.0.0.0";
2459
2496
  const displayNumber = options.displayNumber ?? 99;
2460
2497
  const height = options.height ?? 900;
@@ -2488,10 +2525,13 @@ async function startVirtualDisplay(options, overrides = {}) {
2488
2525
  await stop();
2489
2526
  throw error;
2490
2527
  }
2491
- const publicHost = options.publicHost ?? localAddress();
2528
+ const publicHost = options.publicHost ?? deps.localAddress();
2529
+ const link = (host) => `http://${host}:${port}/vnc.html?autoconnect=1&token=${encodeURIComponent(token)}`;
2530
+ const publicIp = options.publicHost || !isPrivateAddress(publicHost) ? null : await deps.detectPublicIp();
2492
2531
  return {
2532
+ alternateUrl: publicIp && publicIp !== publicHost ? link(publicIp) : null,
2493
2533
  stop,
2494
- url: `http://${publicHost}:${port}/vnc.html?autoconnect=1&token=${encodeURIComponent(token)}`
2534
+ url: link(publicHost)
2495
2535
  };
2496
2536
  }
2497
2537
 
@@ -2506,19 +2546,21 @@ Usage:
2506
2546
  diggylab run
2507
2547
  diggylab status
2508
2548
  diggylab install-browser
2549
+ diggylab install-display
2509
2550
  diggylab unpair
2510
2551
 
2511
2552
  Global options:
2512
2553
  --config <directory> Agent state directory (default ~/.diggylab)
2513
2554
  --api <origin> API origin (default $DIGGYLAB_API_URL or https://api.diggylab.com)`;
2514
2555
  var MISSING_BROWSER_HINT = "Chromium for Playwright is not installed yet. Run `diggylab install-browser` once.";
2556
+ var MISSING_DISPLAY_HINT = "The shared login screen needs Xvfb, x11vnc, websockify, and noVNC. Run `diggylab install-display` (Debian/Ubuntu) or use the Docker image.";
2515
2557
  function agentVersion() {
2516
2558
  let directory = fileURLToPath(new URL(".", import.meta.url));
2517
2559
  for (let depth = 0; depth < 4; depth += 1) {
2518
2560
  const manifest = join4(directory, "package.json");
2519
2561
  if (existsSync2(manifest)) {
2520
2562
  try {
2521
- const version = JSON.parse(readFileSync2(manifest, "utf8")).version;
2563
+ const version = JSON.parse(readFileSync3(manifest, "utf8")).version;
2522
2564
  if (typeof version === "string" && version) return version;
2523
2565
  } catch {
2524
2566
  }
@@ -2640,18 +2682,23 @@ async function commandLogin(runtime, options, io) {
2640
2682
  if (!needsVirtualDisplay) {
2641
2683
  io.log(process.env.DISPLAY ? `Opening the Facebook login page on display ${process.env.DISPLAY}.` : "Opening the Facebook login page in a browser window.");
2642
2684
  } else {
2685
+ const configuredHost = options.publicHost ?? process.env.DIGGYLAB_PUBLIC_HOST?.trim();
2643
2686
  display = await startVirtualDisplay({
2644
2687
  bind: options.bind,
2645
2688
  displayNumber: options.display,
2646
2689
  novncWeb: options.novncWeb,
2647
2690
  port: options.port,
2648
- publicHost: options.publicHost,
2691
+ publicHost: configuredHost || void 0,
2649
2692
  tokenDirectory: join4(runtime.directory, "novnc")
2650
2693
  });
2651
2694
  process.env.DISPLAY = `:${options.display ?? 99}`;
2652
2695
  io.log(terminalQr(display.url));
2653
2696
  io.log(`Open this link on your phone, or scan the QR above:
2654
2697
  ${display.url}`);
2698
+ if (display.alternateUrl) {
2699
+ io.log(`Phone on another network? Forward port ${options.port ?? 6080} and use:
2700
+ ${display.alternateUrl}`);
2701
+ }
2655
2702
  io.log("Log into Facebook on that screen; the agent saves the session automatically.");
2656
2703
  }
2657
2704
  await waitForFacebookLogin(login, LOGIN_TIMEOUT_MS + 6e4);
@@ -2659,6 +2706,10 @@ ${display.url}`);
2659
2706
  return 0;
2660
2707
  } catch (error) {
2661
2708
  const message = error instanceof Error ? error.message : String(error);
2709
+ if (/spawn (?:Xvfb|x11vnc|websockify) ENOENT/.test(message)) {
2710
+ io.error(MISSING_DISPLAY_HINT);
2711
+ return 1;
2712
+ }
2662
2713
  if (/Executable doesn't exist|playwright install/i.test(message)) {
2663
2714
  io.error(MISSING_BROWSER_HINT);
2664
2715
  return 1;
@@ -2677,24 +2728,56 @@ function playwrightCliPath() {
2677
2728
  }
2678
2729
  }
2679
2730
  async function commandInstallBrowser(io) {
2731
+ const soft = process.env.npm_lifecycle_event === "postinstall";
2680
2732
  const cli = playwrightCliPath();
2681
2733
  if (!cli) {
2682
2734
  io.error("Could not locate the bundled Playwright. Reinstall with `npm install -g diggylab@latest`.");
2683
- return 1;
2735
+ return soft ? 0 : 1;
2684
2736
  }
2685
2737
  io.log("Downloading Chromium for Playwright...");
2686
- const code = await new Promise((resolve, reject) => {
2687
- const child = spawn2(process.execPath, [cli, "install", "chromium"], { stdio: "inherit" });
2738
+ const run = (args) => new Promise((resolve, reject) => {
2739
+ const child = spawn2(process.execPath, [cli, ...args], { stdio: "inherit" });
2688
2740
  child.once("error", reject);
2689
2741
  child.once("exit", (value) => resolve(value ?? 1));
2690
2742
  });
2743
+ let code = 1;
2744
+ for (const [index, args] of browserInstallCommands(process.platform).entries()) {
2745
+ if (index > 0) io.log("Installing the system libraries failed; downloading the browser only...");
2746
+ code = await run(args);
2747
+ if (code === 0) break;
2748
+ }
2691
2749
  if (code !== 0) {
2692
- io.error("Chromium download failed. Check the network and try again.");
2693
- return code;
2750
+ io.error(soft ? "Chromium download failed. Run `diggylab install-browser` later." : "Chromium download failed. Check the network and try again.");
2751
+ return soft ? 0 : code;
2694
2752
  }
2695
2753
  io.log("Chromium installed.");
2696
2754
  return 0;
2697
2755
  }
2756
+ async function commandInstallDisplay(io) {
2757
+ if (process.platform !== "linux") {
2758
+ io.log("The shared login screen is only needed on a Linux server; desktops open a browser window.");
2759
+ return 0;
2760
+ }
2761
+ if (!existsSync2("/usr/bin/apt-get")) {
2762
+ io.error("Automatic installation supports Debian/Ubuntu. Install xvfb, x11vnc, websockify, and noVNC with your package manager.");
2763
+ return 1;
2764
+ }
2765
+ const sudo = typeof process.getuid === "function" && process.getuid() !== 0 ? ["sudo"] : [];
2766
+ const run = (args) => new Promise((resolve, reject) => {
2767
+ const child = spawn2(args[0], args.slice(1), { stdio: "inherit" });
2768
+ child.once("error", reject);
2769
+ child.once("exit", (value) => resolve(value ?? 1));
2770
+ });
2771
+ io.log("Installing xvfb, x11vnc, websockify, and noVNC...");
2772
+ await run([...sudo, "apt-get", "update"]).catch(() => 1);
2773
+ const code = await run([...sudo, "apt-get", "install", "-y", "xvfb", "x11vnc", "websockify", "novnc"]);
2774
+ if (code !== 0) {
2775
+ io.error("Installation failed. Run the same command with apt manually.");
2776
+ return code;
2777
+ }
2778
+ io.log("Login screen packages installed.");
2779
+ return 0;
2780
+ }
2698
2781
  async function commandRun(runtime, io) {
2699
2782
  const stored = await runtime.manager.load();
2700
2783
  if (!stored) {
@@ -2817,12 +2900,13 @@ async function main(argv, io = console) {
2817
2900
  io.log(USAGE);
2818
2901
  return 0;
2819
2902
  }
2820
- if (!["install-browser", "login", "pair", "run", "status", "unpair"].includes(command)) {
2903
+ if (!["install-browser", "install-display", "login", "pair", "run", "status", "unpair"].includes(command)) {
2821
2904
  io.error(`Unknown command "${command}".`);
2822
2905
  io.error(USAGE);
2823
2906
  return 2;
2824
2907
  }
2825
2908
  if (command === "install-browser") return commandInstallBrowser(io);
2909
+ if (command === "install-display") return commandInstallDisplay(io);
2826
2910
  const runtime = createRuntime(parsed.options);
2827
2911
  switch (command) {
2828
2912
  case "pair":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diggylab",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
4
4
  "description": "DiggyLab Desktop Agent collects posts and information from Facebook Groups on a paired computer. (headless CLI)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,13 +11,16 @@
11
11
  "README.md"
12
12
  ],
13
13
  "engines": {
14
- "node": ">=22"
14
+ "node": ">=20"
15
15
  },
16
16
  "dependencies": {
17
17
  "playwright": "^1.62.0",
18
18
  "qrcode-terminal": "^0.12.0",
19
19
  "ws": "^8.21.3"
20
20
  },
21
+ "scripts": {
22
+ "postinstall": "node cli.js install-browser"
23
+ },
21
24
  "license": "UNLICENSED",
22
25
  "publishConfig": {
23
26
  "access": "public"