diggylab 0.1.42 → 0.1.44
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 +59 -9
- package/cli.js +264 -31
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,16 +56,29 @@ npm publish apps/desktop-agent/dist-npm --access public
|
|
|
56
56
|
|
|
57
57
|
| Command | What it does |
|
|
58
58
|
| --- | --- |
|
|
59
|
-
| `pair --code <code> [--name <device name>]
|
|
59
|
+
| `pair [--code <code>] [--name <device name>]` | Pairs the server as a device. Create the code on `/settings/desktop-agent` in the web app; leave `--code` out and the command asks for it. |
|
|
60
60
|
| `login` | Opens Facebook's real login page in a browser window when the machine has a screen. On a server without one it asks for `facebook.com` cookies pasted from your own browser, and the session is stored encrypted. |
|
|
61
|
+
| `logout` | Removes the stored Facebook session from this server (the device pairing stays). |
|
|
62
|
+
| `rename [--name <new name>]` | Renames the device shown in the web app; leave `--name` out and the command asks for it. |
|
|
61
63
|
| `run` | Connects the WebSocket worker, receives assigned scans, scans headlessly, uploads results. |
|
|
62
64
|
| `status` | Shows the config directory, API origin, paired device, and Facebook session state. |
|
|
63
65
|
| `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. |
|
|
66
|
+
| `install-service` | On a Linux server with systemd, writes the unit for `run`, enables it, and starts it now and on every boot. |
|
|
67
|
+
| `uninstall-service` | Stops that service and removes the unit. |
|
|
64
68
|
| `unpair` | Removes device credentials, the Facebook session, and pending scans. |
|
|
65
69
|
|
|
66
70
|
Useful options: `--api <origin>` (default `https://api.diggylab.com`), `--cookies <file>` to import
|
|
67
71
|
cookies directly.
|
|
68
72
|
|
|
73
|
+
`pair` and `login` finish by printing what is still missing, so you do not need to run `status`
|
|
74
|
+
between steps:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
Paired: "DiggyLab" (device-1)
|
|
78
|
+
Facebook: missing
|
|
79
|
+
Next: run `diggylab login` — on a server without a screen it asks you to paste cookies.
|
|
80
|
+
```
|
|
81
|
+
|
|
69
82
|
## How `login` works
|
|
70
83
|
|
|
71
84
|
Two paths, depending on the machine:
|
|
@@ -98,8 +111,12 @@ The equivalents: `diggylab login --cookies 'name=value; ...'` for a quoted one-l
|
|
|
98
111
|
`diggylab login --cookies cookies.json` for a saved file.
|
|
99
112
|
|
|
100
113
|
Only `name` and `value` are required; the agent fills in the rest (`.facebook.com`, `/`, session
|
|
101
|
-
expiry). Every `facebook.com` cookie is kept
|
|
102
|
-
|
|
114
|
+
expiry). Every `facebook.com` cookie is kept. The export must contain `c_user` (the agent rejects it
|
|
115
|
+
otherwise) and `xs`, which carries the session — without `xs` Facebook refuses the session during the
|
|
116
|
+
check, and `datr` is worth keeping too.
|
|
117
|
+
|
|
118
|
+
If you paste cookies, prefer the file form or `--cookies -` over `--cookies '...'`: a value on the
|
|
119
|
+
command line stays in your shell history, and pasted text stays in the terminal scrollback.
|
|
103
120
|
|
|
104
121
|
Do not use `document.cookie` from the Console: Facebook's `c_user` and `xs` are HttpOnly, so they
|
|
105
122
|
never appear there. The DevTools cookie table and the extension do show them.
|
|
@@ -111,6 +128,21 @@ instead of failing during a scan.
|
|
|
111
128
|
Note: Facebook sometimes asks for a security check when a session moves to a server's IP. If that
|
|
112
129
|
happens the command says so — approve the login from your phone and import again.
|
|
113
130
|
|
|
131
|
+
## Troubleshooting
|
|
132
|
+
|
|
133
|
+
- `socket closed: 1005` followed by `session: ONLINE` about every 14 minutes is **normal**: the access
|
|
134
|
+
token lives 15 minutes, so the worker refreshes it one minute early and reconnects with the new one
|
|
135
|
+
(the disconnect lasts a second or two, and a running scan keeps going).
|
|
136
|
+
- `socket closed: 4009` right after `session: ONLINE` means **two workers use the same device** — for
|
|
137
|
+
example the systemd service plus a manual `diggylab run`. The worker that connected last keeps the
|
|
138
|
+
device and the older one stops with a message. Check `pgrep -af "diggylab run"` and
|
|
139
|
+
`systemctl status diggylab --no-pager`, then keep only one.
|
|
140
|
+
- `socket error:` plus `socket closed: 1006` and `RECONNECTING (NETWORK_ERROR)` is a dropped
|
|
141
|
+
connection; the worker retries with backoff. If it never settles, check `curl -sS -o /dev/null -w
|
|
142
|
+
'%{http_code}\n' https://api.diggylab.com/health` and that the machine clock is right (`date -u`),
|
|
143
|
+
because a clock off by more than five minutes makes the server's messages look stale.
|
|
144
|
+
- `Facebook: missing` after a login means the session did not survive; run `diggylab login` again.
|
|
145
|
+
|
|
114
146
|
## Security notes
|
|
115
147
|
|
|
116
148
|
- `~/.diggylab/key` (mode `600`) encrypts device credentials, the Facebook session, and the
|
|
@@ -121,22 +153,40 @@ happens the command says so — approve the login from your phone and import aga
|
|
|
121
153
|
|
|
122
154
|
## Run as a service
|
|
123
155
|
|
|
156
|
+
On a Linux server with systemd, let the CLI write the unit for you:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
diggylab install-service # writes /etc/systemd/system/diggylab.service, enables + starts it
|
|
160
|
+
diggylab uninstall-service # stops it and removes the unit
|
|
161
|
+
journalctl -u diggylab -f # follow the worker log
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The unit keeps the same state directory and API origin you are using, restarts the worker if it
|
|
165
|
+
crashes, and starts it on boot. It points at npm's `diggylab` symlink, so upgrading the package is
|
|
166
|
+
enough — no need to reinstall the service. Run it as root (or with `sudo`) to write the unit.
|
|
167
|
+
|
|
168
|
+
<details>
|
|
169
|
+
<summary>Equivalent unit, if you prefer to write it yourself</summary>
|
|
170
|
+
|
|
124
171
|
```ini
|
|
125
172
|
[Unit]
|
|
126
|
-
Description=DiggyLab Desktop Agent
|
|
173
|
+
Description=DiggyLab Desktop Agent
|
|
127
174
|
After=network-online.target
|
|
175
|
+
Wants=network-online.target
|
|
128
176
|
|
|
129
177
|
[Service]
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
Restart=
|
|
134
|
-
|
|
178
|
+
ExecStart=/usr/local/bin/diggylab run
|
|
179
|
+
Environment="DIGGYLAB_AGENT_DIR=/root/.diggylab"
|
|
180
|
+
Environment="DIGGYLAB_API_URL=https://api.diggylab.com"
|
|
181
|
+
Restart=on-failure
|
|
182
|
+
RestartSec=10
|
|
135
183
|
|
|
136
184
|
[Install]
|
|
137
185
|
WantedBy=multi-user.target
|
|
138
186
|
```
|
|
139
187
|
|
|
188
|
+
</details>
|
|
189
|
+
|
|
140
190
|
## Docker
|
|
141
191
|
|
|
142
192
|
```bash
|
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 } from "node:child_process";
|
|
6
|
-
import { existsSync as existsSync2, readFileSync as readFileSync2, realpathSync } from "node:fs";
|
|
6
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, realpathSync, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
7
7
|
import { hostname, homedir } from "node:os";
|
|
8
8
|
import { dirname as dirname3, join as join3 } from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
@@ -727,6 +727,16 @@ var DeviceCredentialManager = class {
|
|
|
727
727
|
});
|
|
728
728
|
return this.refreshPromise;
|
|
729
729
|
}
|
|
730
|
+
/** Renames the device on the server and in the stored session, so `status` and the web agree. */
|
|
731
|
+
async rename(name) {
|
|
732
|
+
const access = this.access ?? await this.refresh();
|
|
733
|
+
const device = await this.http.rename(access.accessToken, name);
|
|
734
|
+
const session = { ...access.session, device };
|
|
735
|
+
await this.store.save(session);
|
|
736
|
+
this.stored = session;
|
|
737
|
+
this.access = { ...access, session };
|
|
738
|
+
return device;
|
|
739
|
+
}
|
|
730
740
|
getStoredSession() {
|
|
731
741
|
return this.stored;
|
|
732
742
|
}
|
|
@@ -816,6 +826,15 @@ var DesktopAgentHttpClient = class {
|
|
|
816
826
|
if (!isDeviceSummary(value)) throw new DesktopAgentClientError("DESKTOP_RESPONSE_INVALID");
|
|
817
827
|
return value;
|
|
818
828
|
}
|
|
829
|
+
async rename(accessToken, name) {
|
|
830
|
+
const value = await this.request("/desktop-agent-auth/me", {
|
|
831
|
+
body: JSON.stringify({ name }),
|
|
832
|
+
headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
|
|
833
|
+
method: "PATCH"
|
|
834
|
+
});
|
|
835
|
+
if (!isDeviceSummary(value)) throw new DesktopAgentClientError("DESKTOP_RESPONSE_INVALID");
|
|
836
|
+
return value;
|
|
837
|
+
}
|
|
819
838
|
async requestCredential(path, body) {
|
|
820
839
|
const value = await this.request(path, {
|
|
821
840
|
body: JSON.stringify(body),
|
|
@@ -1031,13 +1050,13 @@ var DesktopAgentSession = class {
|
|
|
1031
1050
|
this.socket = socket;
|
|
1032
1051
|
socket.on("message", (data) => this.handleMessage(socket, data, access, generation));
|
|
1033
1052
|
socket.on("error", () => this.handleDisconnect(socket, access.session, generation));
|
|
1034
|
-
socket.on("close", () => this.handleDisconnect(socket, access.session, generation));
|
|
1053
|
+
socket.on("close", (code) => this.handleDisconnect(socket, access.session, generation, typeof code === "number" ? code : void 0));
|
|
1035
1054
|
}
|
|
1036
1055
|
handleMessage(socket, data, access, generation) {
|
|
1037
1056
|
if (generation !== this.generation || socket !== this.socket) return;
|
|
1038
1057
|
const envelope = parseServerEnvelope(data);
|
|
1039
1058
|
if (!envelope || envelope.deviceId !== access.session.device.id || envelope.protocolVersion !== DESKTOP_AGENT_PROTOCOL_VERSION || Math.abs(Date.now() - Date.parse(envelope.sentAt)) > MESSAGE_FRESHNESS_MS || this.replayIds.has(envelope.messageId)) {
|
|
1040
|
-
this.protocolFailure(socket, access.session, generation);
|
|
1059
|
+
this.protocolFailure(socket, access.session, generation, describeEnvelopeProblem(envelope, access.session.device.id, this.replayIds));
|
|
1041
1060
|
return;
|
|
1042
1061
|
}
|
|
1043
1062
|
this.rememberMessage(envelope.messageId);
|
|
@@ -1147,19 +1166,23 @@ var DesktopAgentSession = class {
|
|
|
1147
1166
|
void this.start(false);
|
|
1148
1167
|
}, delay);
|
|
1149
1168
|
}
|
|
1150
|
-
handleDisconnect(socket, stored, generation) {
|
|
1169
|
+
handleDisconnect(socket, stored, generation, code) {
|
|
1151
1170
|
if (generation !== this.generation || socket !== this.socket) return;
|
|
1152
1171
|
this.socket = null;
|
|
1153
1172
|
this.clearHeartbeat();
|
|
1154
1173
|
this.clearAccessRefresh();
|
|
1174
|
+
if (code === 4009) {
|
|
1175
|
+
this.update(snapshotForSession("RECONNECTING", stored, "CONNECTION_REPLACED"));
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1155
1178
|
this.scheduleReconnect(stored, generation, "NETWORK_ERROR");
|
|
1156
1179
|
}
|
|
1157
|
-
protocolFailure(socket, stored, generation) {
|
|
1180
|
+
protocolFailure(socket, stored, generation, reason = "malformed message") {
|
|
1158
1181
|
if (socket === this.socket) this.socket = null;
|
|
1159
1182
|
socket.close();
|
|
1160
1183
|
this.clearHeartbeat();
|
|
1161
1184
|
this.clearAccessRefresh();
|
|
1162
|
-
this.scheduleReconnect(stored, generation,
|
|
1185
|
+
this.scheduleReconnect(stored, generation, `PROTOCOL_VIOLATION: ${reason}`);
|
|
1163
1186
|
}
|
|
1164
1187
|
scheduleReconnect(stored, generation, errorCode) {
|
|
1165
1188
|
if (generation !== this.generation || this.reconnectTimer) return;
|
|
@@ -1248,6 +1271,15 @@ function parseServerEnvelope(data) {
|
|
|
1248
1271
|
return null;
|
|
1249
1272
|
}
|
|
1250
1273
|
}
|
|
1274
|
+
function describeEnvelopeProblem(envelope, deviceId, replayIds) {
|
|
1275
|
+
if (!envelope) return "unreadable message";
|
|
1276
|
+
if (envelope.deviceId !== deviceId) return `wrong device ${envelope.deviceId}`;
|
|
1277
|
+
if (envelope.protocolVersion !== DESKTOP_AGENT_PROTOCOL_VERSION) return `protocol version ${envelope.protocolVersion}`;
|
|
1278
|
+
const offsetMs = Date.now() - Date.parse(envelope.sentAt);
|
|
1279
|
+
if (Math.abs(offsetMs) > MESSAGE_FRESHNESS_MS) return `stale message (sentAt ${envelope.sentAt}, offset ${Math.round(offsetMs / 1e3)}s)`;
|
|
1280
|
+
if (replayIds.has(envelope.messageId)) return `replayed message ${envelope.messageId}`;
|
|
1281
|
+
return "unknown";
|
|
1282
|
+
}
|
|
1251
1283
|
function emptySnapshot(state) {
|
|
1252
1284
|
return {
|
|
1253
1285
|
state,
|
|
@@ -2402,11 +2434,10 @@ function parseCookieTable(input) {
|
|
|
2402
2434
|
const cookies = [];
|
|
2403
2435
|
for (const line of input.split("\n")) {
|
|
2404
2436
|
const columns = line.split(" ");
|
|
2405
|
-
|
|
2406
|
-
const name = columns[0].trim();
|
|
2437
|
+
const name = columns[0]?.trim() ?? "";
|
|
2407
2438
|
const value = columns[1];
|
|
2408
|
-
if (!name || name === "Name"
|
|
2409
|
-
cookies.push({ name, value });
|
|
2439
|
+
if (!value || !name || name === "Name") continue;
|
|
2440
|
+
cookies.push({ domain: columns[2]?.trim(), name, value });
|
|
2410
2441
|
}
|
|
2411
2442
|
return cookies;
|
|
2412
2443
|
}
|
|
@@ -2493,17 +2524,45 @@ function createFileCrypto(keyPath) {
|
|
|
2493
2524
|
};
|
|
2494
2525
|
}
|
|
2495
2526
|
|
|
2527
|
+
// src/cli/systemd.ts
|
|
2528
|
+
var SERVICE_NAME = "diggylab";
|
|
2529
|
+
var SERVICE_PATH = `/etc/systemd/system/${SERVICE_NAME}.service`;
|
|
2530
|
+
function quote(value) {
|
|
2531
|
+
return /\s/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value;
|
|
2532
|
+
}
|
|
2533
|
+
function buildSystemdUnit(options) {
|
|
2534
|
+
return `[Unit]
|
|
2535
|
+
Description=DiggyLab Desktop Agent
|
|
2536
|
+
After=network-online.target
|
|
2537
|
+
Wants=network-online.target
|
|
2538
|
+
|
|
2539
|
+
[Service]
|
|
2540
|
+
ExecStart=${options.execStart.map(quote).join(" ")} run
|
|
2541
|
+
Environment="DIGGYLAB_AGENT_DIR=${options.configDirectory}"
|
|
2542
|
+
Environment="DIGGYLAB_API_URL=${options.apiOrigin}"
|
|
2543
|
+
Restart=on-failure
|
|
2544
|
+
RestartSec=10
|
|
2545
|
+
|
|
2546
|
+
[Install]
|
|
2547
|
+
WantedBy=multi-user.target
|
|
2548
|
+
`;
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2496
2551
|
// src/cli/cli.ts
|
|
2497
2552
|
var LOGIN_TIMEOUT_MS = 10 * 60 * 1e3;
|
|
2498
2553
|
var CONFIG_DIRECTORY = ".diggylab";
|
|
2499
2554
|
var USAGE = `DiggyLab Desktop Agent CLI
|
|
2500
2555
|
|
|
2501
2556
|
Usage:
|
|
2502
|
-
diggylab pair --code <code> [--name <device name>]
|
|
2557
|
+
diggylab pair [--code <code>] [--name <device name>] (asks for the code when you leave it out)
|
|
2503
2558
|
diggylab login (opens a browser window when the machine has a screen)
|
|
2504
2559
|
diggylab login cookies (no screen: paste facebook.com cookies when asked)
|
|
2505
2560
|
diggylab login --cookies <file> (or --cookies 'name=value; ...')
|
|
2561
|
+
diggylab logout
|
|
2562
|
+
diggylab rename [--name <new name>] (asks for the name when you leave it out)
|
|
2506
2563
|
diggylab run
|
|
2564
|
+
diggylab install-service (systemd: start "run" on boot)
|
|
2565
|
+
diggylab uninstall-service
|
|
2507
2566
|
diggylab status
|
|
2508
2567
|
diggylab install-browser
|
|
2509
2568
|
diggylab unpair
|
|
@@ -2552,6 +2611,17 @@ function createRuntime(options) {
|
|
|
2552
2611
|
const manager = new DeviceCredentialManager(sessions, http, createDeviceIdentity);
|
|
2553
2612
|
return { activity, browserType: createBrowserLauncher(chromium), directory, endpoint, facebook, manager, sessions, spool };
|
|
2554
2613
|
}
|
|
2614
|
+
function stamp() {
|
|
2615
|
+
return `[${(/* @__PURE__ */ new Date()).toTimeString().slice(0, 8)}]`;
|
|
2616
|
+
}
|
|
2617
|
+
function describeSocketError(error) {
|
|
2618
|
+
if (error instanceof Error) {
|
|
2619
|
+
const code = error.code;
|
|
2620
|
+
return `${error.message || error.name}${typeof code === "string" ? ` (${code})` : ""}`;
|
|
2621
|
+
}
|
|
2622
|
+
if (error && typeof error === "object" && "type" in error) return `event ${String(error.type)}`;
|
|
2623
|
+
return String(error);
|
|
2624
|
+
}
|
|
2555
2625
|
function waitForFacebookLogin(login, timeoutMs) {
|
|
2556
2626
|
return new Promise((resolve, reject) => {
|
|
2557
2627
|
const timer = setTimeout(() => {
|
|
@@ -2596,9 +2666,9 @@ function pairErrorMessage(error, origin) {
|
|
|
2596
2666
|
}
|
|
2597
2667
|
}
|
|
2598
2668
|
async function commandPair(runtime, options, io) {
|
|
2599
|
-
const code = options.code?.trim();
|
|
2669
|
+
const code = options.code?.trim() || await promptForPairingCode(io);
|
|
2600
2670
|
if (!code) {
|
|
2601
|
-
io.error("
|
|
2671
|
+
io.error("No pairing code entered. Create one in the web app: Settings \u2192 Desktop Agent \u2192 Create pairing code.");
|
|
2602
2672
|
return 2;
|
|
2603
2673
|
}
|
|
2604
2674
|
try {
|
|
@@ -2609,13 +2679,72 @@ async function commandPair(runtime, options, io) {
|
|
|
2609
2679
|
platform: resolvePlatform()
|
|
2610
2680
|
});
|
|
2611
2681
|
io.log(`Paired as "${access.session.device.name}".`);
|
|
2612
|
-
|
|
2682
|
+
await printReadiness(runtime, io);
|
|
2613
2683
|
return 0;
|
|
2614
2684
|
} catch (error) {
|
|
2615
2685
|
io.error(pairErrorMessage(error, runtime.endpoint.httpOrigin));
|
|
2616
2686
|
return 1;
|
|
2617
2687
|
}
|
|
2618
2688
|
}
|
|
2689
|
+
async function promptForPairingCode(io) {
|
|
2690
|
+
if (process.stdin.isTTY) {
|
|
2691
|
+
io.log("Create a pairing code in the web app: Settings \u2192 Desktop Agent \u2192 Create pairing code.");
|
|
2692
|
+
io.log("It works once and expires after 10 minutes.");
|
|
2693
|
+
io.log("Paste the code here, then press Enter.");
|
|
2694
|
+
}
|
|
2695
|
+
return (await readAnswer()).trim();
|
|
2696
|
+
}
|
|
2697
|
+
async function printReadiness(runtime, io) {
|
|
2698
|
+
const stored = await runtime.sessions.load();
|
|
2699
|
+
const facebook = await runtime.facebook.load();
|
|
2700
|
+
const hasUser = Boolean(facebook?.cookies?.some((cookie) => cookie.name === "c_user" && cookie.value));
|
|
2701
|
+
io.log(`Paired: ${stored ? `"${stored.device.name}" (${stored.device.id})` : "no"}`);
|
|
2702
|
+
io.log(`Facebook: ${hasUser ? "connected" : "missing"}`);
|
|
2703
|
+
if (!stored) {
|
|
2704
|
+
io.log("Next: create a pairing code in the web app, then run `diggylab pair`.");
|
|
2705
|
+
} else if (!hasUser) {
|
|
2706
|
+
io.log("Next: run `diggylab login` \u2014 on a server without a screen it asks you to paste cookies.");
|
|
2707
|
+
} else {
|
|
2708
|
+
io.log("Ready: start the worker with `diggylab run`.");
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
async function commandLogout(runtime, io) {
|
|
2712
|
+
await runtime.facebook.clear();
|
|
2713
|
+
io.log("Facebook session removed from this server.");
|
|
2714
|
+
await printReadiness(runtime, io);
|
|
2715
|
+
return 0;
|
|
2716
|
+
}
|
|
2717
|
+
async function commandRename(runtime, options, io) {
|
|
2718
|
+
const stored = await runtime.manager.load();
|
|
2719
|
+
if (!stored) {
|
|
2720
|
+
io.error("This server is not paired yet. Run `diggylab pair` first.");
|
|
2721
|
+
return 1;
|
|
2722
|
+
}
|
|
2723
|
+
const requested = options.name?.trim() || await promptForName(io);
|
|
2724
|
+
if (!requested) {
|
|
2725
|
+
io.error('A device name is required, for example `diggylab rename --name "VPS H\xE0 N\u1ED9i"`.');
|
|
2726
|
+
return 2;
|
|
2727
|
+
}
|
|
2728
|
+
const name = sanitizeDeviceName(requested);
|
|
2729
|
+
try {
|
|
2730
|
+
const device = await runtime.manager.rename(name);
|
|
2731
|
+
io.log(`Renamed to "${device.name}".`);
|
|
2732
|
+
await printReadiness(runtime, io);
|
|
2733
|
+
return 0;
|
|
2734
|
+
} catch (error) {
|
|
2735
|
+
if (error instanceof DesktopAgentClientError && error.code === "DESKTOP_RESPONSE_INVALID") {
|
|
2736
|
+
io.error("The API answered the rename with something unexpected \u2014 usually an API that does not have this endpoint yet.");
|
|
2737
|
+
io.error(`Deploy the API, then check it with: curl -s -o /dev/null -w '%{http_code}\\n' -X PATCH https://api.diggylab.com/desktop-agent-auth/me -H 'content-type: application/json' -d '{"name":"x"}' (401 = the endpoint exists)`);
|
|
2738
|
+
return 1;
|
|
2739
|
+
}
|
|
2740
|
+
io.error(`Could not rename this device: ${error instanceof Error ? error.message : String(error)}`);
|
|
2741
|
+
return 1;
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
async function promptForName(io) {
|
|
2745
|
+
if (process.stdin.isTTY) io.log("Type the new device name, then press Enter.");
|
|
2746
|
+
return (await readAnswer()).trim();
|
|
2747
|
+
}
|
|
2619
2748
|
async function commandLogin(runtime, options, io) {
|
|
2620
2749
|
if (options.cookies !== void 0) return importFacebookCookies(runtime, options.cookies || "-", io);
|
|
2621
2750
|
if (process.platform === "linux" && !process.env.DISPLAY) {
|
|
@@ -2643,6 +2772,7 @@ async function commandLogin(runtime, options, io) {
|
|
|
2643
2772
|
io.log(process.env.DISPLAY ? `Opening the Facebook login page on display ${process.env.DISPLAY}.` : "Opening the Facebook login page in a browser window.");
|
|
2644
2773
|
await waitForFacebookLogin(login, LOGIN_TIMEOUT_MS + 6e4);
|
|
2645
2774
|
io.log("Facebook session saved.");
|
|
2775
|
+
await printReadiness(runtime, io);
|
|
2646
2776
|
return 0;
|
|
2647
2777
|
} catch (error) {
|
|
2648
2778
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -2683,8 +2813,10 @@ async function importFacebookCookies(runtime, source, io) {
|
|
|
2683
2813
|
io.error("Open facebook.com in your browser, make sure you are logged in, then copy the `cookie:` request header again.");
|
|
2684
2814
|
return 1;
|
|
2685
2815
|
}
|
|
2816
|
+
let saved = false;
|
|
2686
2817
|
try {
|
|
2687
2818
|
const accountId = await runtime.facebook.save(state);
|
|
2819
|
+
saved = true;
|
|
2688
2820
|
io.log(`Facebook session saved for account ${accountId}. Asking Facebook to confirm it...`);
|
|
2689
2821
|
const condition = await validateImportedSession(runtime, state);
|
|
2690
2822
|
if (condition !== "READY") {
|
|
@@ -2694,6 +2826,7 @@ async function importFacebookCookies(runtime, source, io) {
|
|
|
2694
2826
|
}
|
|
2695
2827
|
} catch (error) {
|
|
2696
2828
|
const message = error instanceof Error ? error.message : String(error);
|
|
2829
|
+
if (saved) await runtime.facebook.clear();
|
|
2697
2830
|
if (/Executable doesn't exist|playwright install/i.test(message)) {
|
|
2698
2831
|
io.error(MISSING_BROWSER_HINT);
|
|
2699
2832
|
} else if (message === "FACEBOOK_SESSION_INVALID") {
|
|
@@ -2704,6 +2837,7 @@ async function importFacebookCookies(runtime, source, io) {
|
|
|
2704
2837
|
return 1;
|
|
2705
2838
|
}
|
|
2706
2839
|
io.log("Facebook session saved.");
|
|
2840
|
+
await printReadiness(runtime, io);
|
|
2707
2841
|
return 0;
|
|
2708
2842
|
}
|
|
2709
2843
|
async function validateImportedSession(runtime, state) {
|
|
@@ -2729,9 +2863,12 @@ function readStdin() {
|
|
|
2729
2863
|
});
|
|
2730
2864
|
}
|
|
2731
2865
|
function readPastedCookies(io) {
|
|
2732
|
-
if (!process.stdin.isTTY) return readStdin();
|
|
2733
2866
|
io.log("Paste the facebook.com cookies here, then press Enter.");
|
|
2734
2867
|
io.log("In your browser: F12 \u2192 Network \u2192 click any facebook.com request \u2192 copy the whole `cookie:` value.");
|
|
2868
|
+
return readAnswer({ until: (value) => value.includes("c_user") });
|
|
2869
|
+
}
|
|
2870
|
+
function readAnswer(options = {}) {
|
|
2871
|
+
if (!process.stdin.isTTY) return readStdin();
|
|
2735
2872
|
return new Promise((resolve) => {
|
|
2736
2873
|
let value = "";
|
|
2737
2874
|
let idle;
|
|
@@ -2743,12 +2880,14 @@ function readPastedCookies(io) {
|
|
|
2743
2880
|
};
|
|
2744
2881
|
const handleData = (chunk) => {
|
|
2745
2882
|
value += chunk;
|
|
2746
|
-
if (
|
|
2747
|
-
if (value.includes("\n") && value.includes("c_user")) {
|
|
2883
|
+
if (value.includes("\n") && (!options.until || options.until(value))) {
|
|
2748
2884
|
finish();
|
|
2749
2885
|
return;
|
|
2750
2886
|
}
|
|
2751
|
-
idle
|
|
2887
|
+
if (idle) clearTimeout(idle);
|
|
2888
|
+
idle = setTimeout(() => {
|
|
2889
|
+
if (!options.until || options.until(value)) finish();
|
|
2890
|
+
}, 350);
|
|
2752
2891
|
};
|
|
2753
2892
|
process.stdin.setEncoding("utf8");
|
|
2754
2893
|
process.stdin.on("data", handleData);
|
|
@@ -2776,10 +2915,14 @@ async function commandInstallBrowser(io) {
|
|
|
2776
2915
|
child.once("exit", (value) => resolve(value ?? 1));
|
|
2777
2916
|
});
|
|
2778
2917
|
let code = 1;
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2918
|
+
try {
|
|
2919
|
+
for (const [index, args] of browserInstallCommands(process.platform).entries()) {
|
|
2920
|
+
if (index > 0) io.log("Installing the system libraries failed; downloading the browser only...");
|
|
2921
|
+
code = await run(args);
|
|
2922
|
+
if (code === 0) break;
|
|
2923
|
+
}
|
|
2924
|
+
} catch {
|
|
2925
|
+
code = 1;
|
|
2783
2926
|
}
|
|
2784
2927
|
if (code !== 0) {
|
|
2785
2928
|
io.error(soft ? "Chromium download failed. Run `diggylab install-browser` later." : "Chromium download failed. Check the network and try again.");
|
|
@@ -2806,8 +2949,9 @@ async function commandRun(runtime, io) {
|
|
|
2806
2949
|
headers: { authorization: `Bearer ${accessToken}` },
|
|
2807
2950
|
maxPayload: 16 * 1024
|
|
2808
2951
|
});
|
|
2952
|
+
socket.on("error", (error) => io.log(`${stamp()} socket error: ${describeSocketError(error)}`));
|
|
2809
2953
|
socket.on("close", (code, reason) => {
|
|
2810
|
-
io.log(
|
|
2954
|
+
io.log(`${stamp()} socket closed: ${code}${reason?.length ? ` (${reason.toString()})` : ""}`);
|
|
2811
2955
|
});
|
|
2812
2956
|
return socket;
|
|
2813
2957
|
},
|
|
@@ -2851,9 +2995,18 @@ async function commandRun(runtime, io) {
|
|
|
2851
2995
|
spool: runtime.spool,
|
|
2852
2996
|
wait: abortableDelay
|
|
2853
2997
|
});
|
|
2998
|
+
let onReplaced = () => void 0;
|
|
2999
|
+
const replaced = new Promise((resolve) => {
|
|
3000
|
+
onReplaced = resolve;
|
|
3001
|
+
});
|
|
2854
3002
|
session.subscribe((snapshot) => {
|
|
2855
|
-
io.log(
|
|
3003
|
+
io.log(`${stamp()} session: ${snapshot.state}${snapshot.errorCode ? ` (${snapshot.errorCode})` : ""}`);
|
|
2856
3004
|
recordActivity(`Session state changed to ${snapshot.state}.`, snapshot.state);
|
|
3005
|
+
if (snapshot.errorCode === "CONNECTION_REPLACED") {
|
|
3006
|
+
io.error("Another worker is already running with this device, so this one stopped.");
|
|
3007
|
+
io.error("Stop the other worker (for example: systemctl stop diggylab), then start this one again.");
|
|
3008
|
+
onReplaced();
|
|
3009
|
+
}
|
|
2857
3010
|
});
|
|
2858
3011
|
scanRunner.subscribe((snapshot) => {
|
|
2859
3012
|
io.log(`scanner: ${snapshot.state}${snapshot.jobId ? ` (job ${snapshot.jobId})` : ""}`);
|
|
@@ -2862,13 +3015,16 @@ async function commandRun(runtime, io) {
|
|
|
2862
3015
|
scanRunner.start();
|
|
2863
3016
|
await scanRunner.recover();
|
|
2864
3017
|
await session.start();
|
|
2865
|
-
io.log(`Worker running as "${stored.device.name}". Press Ctrl+C to stop.`);
|
|
3018
|
+
io.log(`Worker running as "${stored.device.name}" (API ${runtime.endpoint.httpOrigin}). Press Ctrl+C to stop.`);
|
|
2866
3019
|
const keepAlive = setInterval(() => void 0, 6e4);
|
|
2867
3020
|
try {
|
|
2868
|
-
await
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
3021
|
+
await Promise.race([
|
|
3022
|
+
new Promise((resolve) => {
|
|
3023
|
+
process.once("SIGINT", resolve);
|
|
3024
|
+
process.once("SIGTERM", resolve);
|
|
3025
|
+
}),
|
|
3026
|
+
replaced
|
|
3027
|
+
]);
|
|
2872
3028
|
} finally {
|
|
2873
3029
|
clearInterval(keepAlive);
|
|
2874
3030
|
session.stop();
|
|
@@ -2894,6 +3050,71 @@ async function commandUnpair(runtime, io) {
|
|
|
2894
3050
|
io.log("Device unpaired; local credentials, Facebook session, and pending scans were removed.");
|
|
2895
3051
|
return 0;
|
|
2896
3052
|
}
|
|
3053
|
+
function runCommand(command, args) {
|
|
3054
|
+
return new Promise((resolve, reject) => {
|
|
3055
|
+
const child = spawn(command, args, { stdio: "inherit" });
|
|
3056
|
+
child.once("error", reject);
|
|
3057
|
+
child.once("exit", (value) => resolve(value ?? 1));
|
|
3058
|
+
});
|
|
3059
|
+
}
|
|
3060
|
+
function resolveServiceExecStart() {
|
|
3061
|
+
const candidates = [join3(dirname3(process.execPath), "diggylab"), "/usr/local/bin/diggylab", "/usr/bin/diggylab"];
|
|
3062
|
+
const link = candidates.find((candidate) => existsSync2(candidate));
|
|
3063
|
+
return link ? [link] : [process.execPath, fileURLToPath(import.meta.url)];
|
|
3064
|
+
}
|
|
3065
|
+
async function commandInstallService(runtime, io) {
|
|
3066
|
+
if (process.platform !== "linux") {
|
|
3067
|
+
io.error("Automatic service setup supports Linux (systemd). Use the Docker image or your platform's service manager.");
|
|
3068
|
+
return 1;
|
|
3069
|
+
}
|
|
3070
|
+
if (!existsSync2("/usr/bin/systemctl") && !existsSync2("/bin/systemctl")) {
|
|
3071
|
+
io.error("systemd is not available on this machine.");
|
|
3072
|
+
return 1;
|
|
3073
|
+
}
|
|
3074
|
+
const unit = buildSystemdUnit({
|
|
3075
|
+
apiOrigin: runtime.endpoint.httpOrigin,
|
|
3076
|
+
configDirectory: runtime.directory,
|
|
3077
|
+
execStart: resolveServiceExecStart()
|
|
3078
|
+
});
|
|
3079
|
+
try {
|
|
3080
|
+
writeFileSync2(SERVICE_PATH, unit, { mode: 420 });
|
|
3081
|
+
} catch {
|
|
3082
|
+
io.error(`Could not write ${SERVICE_PATH}. Run this command as root (or with sudo).`);
|
|
3083
|
+
return 1;
|
|
3084
|
+
}
|
|
3085
|
+
io.log(`Wrote ${SERVICE_PATH}.`);
|
|
3086
|
+
io.log(`State directory: ${runtime.directory}`);
|
|
3087
|
+
if (process.env.SUDO_USER && process.env.SUDO_USER !== "root" && runtime.directory === join3(homedir(), CONFIG_DIRECTORY)) {
|
|
3088
|
+
io.error(`Heads up: this ran as root, so the service will use ${runtime.directory} (root's home).`);
|
|
3089
|
+
io.error(`If you paired as "${process.env.SUDO_USER}", pass --config to point at that user's ~/${CONFIG_DIRECTORY} instead.`);
|
|
3090
|
+
}
|
|
3091
|
+
await runCommand("systemctl", ["daemon-reload"]).catch(() => 1);
|
|
3092
|
+
const code = await runCommand("systemctl", ["enable", "--now", SERVICE_NAME]);
|
|
3093
|
+
if (code !== 0) {
|
|
3094
|
+
io.error(`systemctl could not enable the service. Check it with: systemctl status ${SERVICE_NAME}`);
|
|
3095
|
+
return code;
|
|
3096
|
+
}
|
|
3097
|
+
io.log(`Service ${SERVICE_NAME} is enabled: it runs now and after every reboot.`);
|
|
3098
|
+
io.log(`Follow its log with: journalctl -u ${SERVICE_NAME} -f`);
|
|
3099
|
+
await printReadiness(runtime, io);
|
|
3100
|
+
return 0;
|
|
3101
|
+
}
|
|
3102
|
+
async function commandUninstallService(io) {
|
|
3103
|
+
if (process.platform !== "linux") {
|
|
3104
|
+
io.error("Automatic service setup supports Linux (systemd).");
|
|
3105
|
+
return 1;
|
|
3106
|
+
}
|
|
3107
|
+
await runCommand("systemctl", ["disable", "--now", SERVICE_NAME]).catch(() => 1);
|
|
3108
|
+
try {
|
|
3109
|
+
rmSync(SERVICE_PATH, { force: true });
|
|
3110
|
+
} catch {
|
|
3111
|
+
io.error(`Could not remove ${SERVICE_PATH}. Run this command as root (or with sudo).`);
|
|
3112
|
+
return 1;
|
|
3113
|
+
}
|
|
3114
|
+
await runCommand("systemctl", ["daemon-reload"]).catch(() => 1);
|
|
3115
|
+
io.log(`Service ${SERVICE_NAME} removed.`);
|
|
3116
|
+
return 0;
|
|
3117
|
+
}
|
|
2897
3118
|
async function main(argv, io = console) {
|
|
2898
3119
|
if (argv.includes("--help") || argv.includes("-h")) {
|
|
2899
3120
|
io.log(USAGE);
|
|
@@ -2910,7 +3131,7 @@ async function main(argv, io = console) {
|
|
|
2910
3131
|
io.log(USAGE);
|
|
2911
3132
|
return 0;
|
|
2912
3133
|
}
|
|
2913
|
-
if (!["install-browser", "login", "pair", "run", "status", "unpair"].includes(command)) {
|
|
3134
|
+
if (!["install-browser", "install-service", "login", "logout", "pair", "rename", "run", "status", "uninstall-service", "unpair"].includes(command)) {
|
|
2914
3135
|
io.error(`Unknown command "${command}".`);
|
|
2915
3136
|
io.error(USAGE);
|
|
2916
3137
|
return 2;
|
|
@@ -2922,12 +3143,24 @@ async function main(argv, io = console) {
|
|
|
2922
3143
|
return commandPair(runtime, parsed.options, io);
|
|
2923
3144
|
case "login":
|
|
2924
3145
|
return commandLogin(runtime, parsed.options, io);
|
|
3146
|
+
case "logout":
|
|
3147
|
+
return commandLogout(runtime, io);
|
|
3148
|
+
case "rename":
|
|
3149
|
+
return commandRename(runtime, parsed.options, io);
|
|
3150
|
+
case "install-service":
|
|
3151
|
+
return commandInstallService(runtime, io);
|
|
3152
|
+
case "uninstall-service":
|
|
3153
|
+
return commandUninstallService(io);
|
|
2925
3154
|
case "run":
|
|
2926
3155
|
return commandRun(runtime, io);
|
|
2927
3156
|
case "status":
|
|
2928
3157
|
return commandStatus(runtime, io);
|
|
2929
|
-
|
|
3158
|
+
case "unpair":
|
|
2930
3159
|
return commandUnpair(runtime, io);
|
|
3160
|
+
default:
|
|
3161
|
+
io.error(`Unknown command "${command}".`);
|
|
3162
|
+
io.error(USAGE);
|
|
3163
|
+
return 2;
|
|
2931
3164
|
}
|
|
2932
3165
|
}
|
|
2933
3166
|
function isEntryPoint() {
|