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.
- package/README.md +14 -4
- package/cli.js +102 -18
- 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
|
|
10
|
-
- Linux packages: `xvfb`, `x11vnc`, `websockify`, `novnc` (Debian/Ubuntu
|
|
11
|
-
- Chromium for Playwright:
|
|
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 (
|
|
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
|
|
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
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
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 = {
|
|
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:
|
|
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(
|
|
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:
|
|
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
|
|
2687
|
-
const child = spawn2(process.execPath, [cli,
|
|
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.
|
|
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": ">=
|
|
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"
|