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.
Files changed (3) hide show
  1. package/README.md +59 -9
  2. package/cli.js +264 -31
  3. 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>] [--api <origin>]` | Pairs the server as a device. Create the code on `/settings/desktop-agent` in the web app. |
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, and at least `c_user` plus `xs` must be present — those
102
- two carry the session, and `datr` is worth keeping too.
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 CLI
173
+ Description=DiggyLab Desktop Agent
127
174
  After=network-online.target
175
+ Wants=network-online.target
128
176
 
129
177
  [Service]
130
- User=diggylab
131
- WorkingDirectory=/opt/diggylab
132
- ExecStart=/usr/bin/node /opt/diggylab/dist-cli/cli/cli.js run
133
- Restart=always
134
- Environment=DIGGYLAB_API_URL=https://api.diggylab.com
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, "PROTOCOL_VIOLATION");
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
- if (columns.length < 2) return [];
2406
- const name = columns[0].trim();
2437
+ const name = columns[0]?.trim() ?? "";
2407
2438
  const value = columns[1];
2408
- if (!name || name === "Name" || !value) continue;
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>] [--api <https://api.diggylab.com>]
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("Missing --code <pairing code>. Create one on the Desktop Agent page of the web app.");
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
- io.log("Next: run `diggylab login` to connect a Facebook account.");
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 (idle) clearTimeout(idle);
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 = setTimeout(finish, 350);
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
- for (const [index, args] of browserInstallCommands(process.platform).entries()) {
2780
- if (index > 0) io.log("Installing the system libraries failed; downloading the browser only...");
2781
- code = await run(args);
2782
- if (code === 0) break;
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(`socket closed: ${code}${reason?.length ? ` (${reason.toString()})` : ""}`);
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(`session: ${snapshot.state}`);
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 new Promise((resolve) => {
2869
- process.once("SIGINT", resolve);
2870
- process.once("SIGTERM", resolve);
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
- default:
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() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diggylab",
3
- "version": "0.1.42",
3
+ "version": "0.1.44",
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": {