diggylab 0.1.42 → 0.1.43

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 +47 -6
  2. package/cli.js +221 -21
  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:
@@ -111,6 +124,16 @@ instead of failing during a scan.
111
124
  Note: Facebook sometimes asks for a security check when a session moves to a server's IP. If that
112
125
  happens the command says so — approve the login from your phone and import again.
113
126
 
127
+ ## Troubleshooting
128
+
129
+ - `socket closed: 4009` right after `session: ONLINE` means **two workers use the same device** — for
130
+ example the systemd service plus a manual `diggylab run`. The worker that connected last keeps the
131
+ device and the older one stops with a message. Check `pgrep -af "diggylab run"` and
132
+ `systemctl status diggylab --no-pager`, then keep only one.
133
+ - `socket closed: 1006` with reconnects is a dropped connection; the worker keeps retrying with
134
+ backoff. If it never settles, check that the server can reach the API origin.
135
+ - `Facebook: missing` after a login means the session did not survive; run `diggylab login` again.
136
+
114
137
  ## Security notes
115
138
 
116
139
  - `~/.diggylab/key` (mode `600`) encrypts device credentials, the Facebook session, and the
@@ -121,22 +144,40 @@ happens the command says so — approve the login from your phone and import aga
121
144
 
122
145
  ## Run as a service
123
146
 
147
+ On a Linux server with systemd, let the CLI write the unit for you:
148
+
149
+ ```bash
150
+ diggylab install-service # writes /etc/systemd/system/diggylab.service, enables + starts it
151
+ diggylab uninstall-service # stops it and removes the unit
152
+ journalctl -u diggylab -f # follow the worker log
153
+ ```
154
+
155
+ The unit keeps the same state directory and API origin you are using, restarts the worker if it
156
+ crashes, and starts it on boot. It points at npm's `diggylab` symlink, so upgrading the package is
157
+ enough — no need to reinstall the service. Run it as root (or with `sudo`) to write the unit.
158
+
159
+ <details>
160
+ <summary>Equivalent unit, if you prefer to write it yourself</summary>
161
+
124
162
  ```ini
125
163
  [Unit]
126
- Description=DiggyLab Desktop Agent CLI
164
+ Description=DiggyLab Desktop Agent
127
165
  After=network-online.target
166
+ Wants=network-online.target
128
167
 
129
168
  [Service]
130
- User=diggylab
131
- WorkingDirectory=/opt/diggylab
132
- ExecStart=/usr/bin/node /opt/diggylab/dist-cli/cli/cli.js run
133
- Restart=always
169
+ ExecStart=/usr/local/bin/diggylab run
170
+ Environment=DIGGYLAB_AGENT_DIR=/root/.diggylab
134
171
  Environment=DIGGYLAB_API_URL=https://api.diggylab.com
172
+ Restart=always
173
+ RestartSec=10
135
174
 
136
175
  [Install]
137
176
  WantedBy=multi-user.target
138
177
  ```
139
178
 
179
+ </details>
180
+
140
181
  ## Docker
141
182
 
142
183
  ```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,
@@ -2493,17 +2525,42 @@ function createFileCrypto(keyPath) {
2493
2525
  };
2494
2526
  }
2495
2527
 
2528
+ // src/cli/systemd.ts
2529
+ var SERVICE_NAME = "diggylab";
2530
+ var SERVICE_PATH = `/etc/systemd/system/${SERVICE_NAME}.service`;
2531
+ function buildSystemdUnit(options) {
2532
+ return `[Unit]
2533
+ Description=DiggyLab Desktop Agent
2534
+ After=network-online.target
2535
+ Wants=network-online.target
2536
+
2537
+ [Service]
2538
+ ExecStart=${options.execStart} run
2539
+ Environment=DIGGYLAB_AGENT_DIR=${options.configDirectory}
2540
+ Environment=DIGGYLAB_API_URL=${options.apiOrigin}
2541
+ Restart=on-failure
2542
+ RestartSec=10
2543
+
2544
+ [Install]
2545
+ WantedBy=multi-user.target
2546
+ `;
2547
+ }
2548
+
2496
2549
  // src/cli/cli.ts
2497
2550
  var LOGIN_TIMEOUT_MS = 10 * 60 * 1e3;
2498
2551
  var CONFIG_DIRECTORY = ".diggylab";
2499
2552
  var USAGE = `DiggyLab Desktop Agent CLI
2500
2553
 
2501
2554
  Usage:
2502
- diggylab pair --code <code> [--name <device name>] [--api <https://api.diggylab.com>]
2555
+ diggylab pair [--code <code>] [--name <device name>] (asks for the code when you leave it out)
2503
2556
  diggylab login (opens a browser window when the machine has a screen)
2504
2557
  diggylab login cookies (no screen: paste facebook.com cookies when asked)
2505
2558
  diggylab login --cookies <file> (or --cookies 'name=value; ...')
2559
+ diggylab logout
2560
+ diggylab rename [--name <new name>] (asks for the name when you leave it out)
2506
2561
  diggylab run
2562
+ diggylab install-service (systemd: start "run" on boot)
2563
+ diggylab uninstall-service
2507
2564
  diggylab status
2508
2565
  diggylab install-browser
2509
2566
  diggylab unpair
@@ -2552,6 +2609,9 @@ function createRuntime(options) {
2552
2609
  const manager = new DeviceCredentialManager(sessions, http, createDeviceIdentity);
2553
2610
  return { activity, browserType: createBrowserLauncher(chromium), directory, endpoint, facebook, manager, sessions, spool };
2554
2611
  }
2612
+ function stamp() {
2613
+ return `[${(/* @__PURE__ */ new Date()).toTimeString().slice(0, 8)}]`;
2614
+ }
2555
2615
  function waitForFacebookLogin(login, timeoutMs) {
2556
2616
  return new Promise((resolve, reject) => {
2557
2617
  const timer = setTimeout(() => {
@@ -2596,9 +2656,9 @@ function pairErrorMessage(error, origin) {
2596
2656
  }
2597
2657
  }
2598
2658
  async function commandPair(runtime, options, io) {
2599
- const code = options.code?.trim();
2659
+ const code = options.code?.trim() || await promptForPairingCode(io);
2600
2660
  if (!code) {
2601
- io.error("Missing --code <pairing code>. Create one on the Desktop Agent page of the web app.");
2661
+ io.error("No pairing code entered. Create one in the web app: Settings \u2192 Desktop Agent \u2192 Create pairing code.");
2602
2662
  return 2;
2603
2663
  }
2604
2664
  try {
@@ -2609,13 +2669,67 @@ async function commandPair(runtime, options, io) {
2609
2669
  platform: resolvePlatform()
2610
2670
  });
2611
2671
  io.log(`Paired as "${access.session.device.name}".`);
2612
- io.log("Next: run `diggylab login` to connect a Facebook account.");
2672
+ await printReadiness(runtime, io);
2613
2673
  return 0;
2614
2674
  } catch (error) {
2615
2675
  io.error(pairErrorMessage(error, runtime.endpoint.httpOrigin));
2616
2676
  return 1;
2617
2677
  }
2618
2678
  }
2679
+ async function promptForPairingCode(io) {
2680
+ if (process.stdin.isTTY) {
2681
+ io.log("Create a pairing code in the web app: Settings \u2192 Desktop Agent \u2192 Create pairing code.");
2682
+ io.log("It works once and expires after 10 minutes.");
2683
+ io.log("Paste the code here, then press Enter.");
2684
+ }
2685
+ return (await readAnswer()).trim();
2686
+ }
2687
+ async function printReadiness(runtime, io) {
2688
+ const stored = await runtime.sessions.load();
2689
+ const facebook = await runtime.facebook.load();
2690
+ const hasUser = Boolean(facebook?.cookies?.some((cookie) => cookie.name === "c_user" && cookie.value));
2691
+ io.log(`Paired: ${stored ? `"${stored.device.name}" (${stored.device.id})` : "no"}`);
2692
+ io.log(`Facebook: ${hasUser ? "connected" : "missing"}`);
2693
+ if (!stored) {
2694
+ io.log("Next: create a pairing code in the web app, then run `diggylab pair`.");
2695
+ } else if (!hasUser) {
2696
+ io.log("Next: run `diggylab login` \u2014 on a server without a screen it asks you to paste cookies.");
2697
+ } else {
2698
+ io.log("Ready: start the worker with `diggylab run`.");
2699
+ }
2700
+ }
2701
+ async function commandLogout(runtime, io) {
2702
+ await runtime.facebook.clear();
2703
+ io.log("Facebook session removed from this server.");
2704
+ await printReadiness(runtime, io);
2705
+ return 0;
2706
+ }
2707
+ async function commandRename(runtime, options, io) {
2708
+ const stored = await runtime.manager.load();
2709
+ if (!stored) {
2710
+ io.error("This server is not paired yet. Run `diggylab pair` first.");
2711
+ return 1;
2712
+ }
2713
+ const requested = options.name?.trim() || await promptForName(io);
2714
+ const name = sanitizeDeviceName(requested);
2715
+ if (!name) {
2716
+ io.error('A device name is required, for example `diggylab rename --name "VPS H\xE0 N\u1ED9i"`.');
2717
+ return 2;
2718
+ }
2719
+ try {
2720
+ const device = await runtime.manager.rename(name);
2721
+ io.log(`Renamed to "${device.name}".`);
2722
+ await printReadiness(runtime, io);
2723
+ return 0;
2724
+ } catch (error) {
2725
+ io.error(`Could not rename this device: ${error instanceof Error ? error.message : String(error)}`);
2726
+ return 1;
2727
+ }
2728
+ }
2729
+ async function promptForName(io) {
2730
+ if (process.stdin.isTTY) io.log("Type the new device name, then press Enter.");
2731
+ return (await readAnswer()).trim();
2732
+ }
2619
2733
  async function commandLogin(runtime, options, io) {
2620
2734
  if (options.cookies !== void 0) return importFacebookCookies(runtime, options.cookies || "-", io);
2621
2735
  if (process.platform === "linux" && !process.env.DISPLAY) {
@@ -2643,6 +2757,7 @@ async function commandLogin(runtime, options, io) {
2643
2757
  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
2758
  await waitForFacebookLogin(login, LOGIN_TIMEOUT_MS + 6e4);
2645
2759
  io.log("Facebook session saved.");
2760
+ await printReadiness(runtime, io);
2646
2761
  return 0;
2647
2762
  } catch (error) {
2648
2763
  const message = error instanceof Error ? error.message : String(error);
@@ -2704,6 +2819,7 @@ async function importFacebookCookies(runtime, source, io) {
2704
2819
  return 1;
2705
2820
  }
2706
2821
  io.log("Facebook session saved.");
2822
+ await printReadiness(runtime, io);
2707
2823
  return 0;
2708
2824
  }
2709
2825
  async function validateImportedSession(runtime, state) {
@@ -2729,9 +2845,12 @@ function readStdin() {
2729
2845
  });
2730
2846
  }
2731
2847
  function readPastedCookies(io) {
2732
- if (!process.stdin.isTTY) return readStdin();
2733
2848
  io.log("Paste the facebook.com cookies here, then press Enter.");
2734
2849
  io.log("In your browser: F12 \u2192 Network \u2192 click any facebook.com request \u2192 copy the whole `cookie:` value.");
2850
+ return readAnswer({ until: (value) => value.includes("c_user") });
2851
+ }
2852
+ function readAnswer(options = {}) {
2853
+ if (!process.stdin.isTTY) return readStdin();
2735
2854
  return new Promise((resolve) => {
2736
2855
  let value = "";
2737
2856
  let idle;
@@ -2743,11 +2862,11 @@ function readPastedCookies(io) {
2743
2862
  };
2744
2863
  const handleData = (chunk) => {
2745
2864
  value += chunk;
2746
- if (idle) clearTimeout(idle);
2747
- if (value.includes("\n") && value.includes("c_user")) {
2865
+ if (value.includes("\n") && (!options.until || options.until(value))) {
2748
2866
  finish();
2749
2867
  return;
2750
2868
  }
2869
+ if (idle) clearTimeout(idle);
2751
2870
  idle = setTimeout(finish, 350);
2752
2871
  };
2753
2872
  process.stdin.setEncoding("utf8");
@@ -2806,8 +2925,9 @@ async function commandRun(runtime, io) {
2806
2925
  headers: { authorization: `Bearer ${accessToken}` },
2807
2926
  maxPayload: 16 * 1024
2808
2927
  });
2928
+ socket.on("error", (error) => io.log(`${stamp()} socket error: ${error.message}`));
2809
2929
  socket.on("close", (code, reason) => {
2810
- io.log(`socket closed: ${code}${reason?.length ? ` (${reason.toString()})` : ""}`);
2930
+ io.log(`${stamp()} socket closed: ${code}${reason?.length ? ` (${reason.toString()})` : ""}`);
2811
2931
  });
2812
2932
  return socket;
2813
2933
  },
@@ -2851,9 +2971,18 @@ async function commandRun(runtime, io) {
2851
2971
  spool: runtime.spool,
2852
2972
  wait: abortableDelay
2853
2973
  });
2974
+ let onReplaced = () => void 0;
2975
+ const replaced = new Promise((resolve) => {
2976
+ onReplaced = resolve;
2977
+ });
2854
2978
  session.subscribe((snapshot) => {
2855
- io.log(`session: ${snapshot.state}`);
2979
+ io.log(`${stamp()} session: ${snapshot.state}${snapshot.errorCode ? ` (${snapshot.errorCode})` : ""}`);
2856
2980
  recordActivity(`Session state changed to ${snapshot.state}.`, snapshot.state);
2981
+ if (snapshot.errorCode === "CONNECTION_REPLACED") {
2982
+ io.error("Another worker is already running with this device, so this one stopped.");
2983
+ io.error("Stop the other worker (for example: systemctl stop diggylab), then start this one again.");
2984
+ onReplaced();
2985
+ }
2857
2986
  });
2858
2987
  scanRunner.subscribe((snapshot) => {
2859
2988
  io.log(`scanner: ${snapshot.state}${snapshot.jobId ? ` (job ${snapshot.jobId})` : ""}`);
@@ -2862,13 +2991,16 @@ async function commandRun(runtime, io) {
2862
2991
  scanRunner.start();
2863
2992
  await scanRunner.recover();
2864
2993
  await session.start();
2865
- io.log(`Worker running as "${stored.device.name}". Press Ctrl+C to stop.`);
2994
+ io.log(`Worker running as "${stored.device.name}" (API ${runtime.endpoint.httpOrigin}). Press Ctrl+C to stop.`);
2866
2995
  const keepAlive = setInterval(() => void 0, 6e4);
2867
2996
  try {
2868
- await new Promise((resolve) => {
2869
- process.once("SIGINT", resolve);
2870
- process.once("SIGTERM", resolve);
2871
- });
2997
+ await Promise.race([
2998
+ new Promise((resolve) => {
2999
+ process.once("SIGINT", resolve);
3000
+ process.once("SIGTERM", resolve);
3001
+ }),
3002
+ replaced
3003
+ ]);
2872
3004
  } finally {
2873
3005
  clearInterval(keepAlive);
2874
3006
  session.stop();
@@ -2894,6 +3026,66 @@ async function commandUnpair(runtime, io) {
2894
3026
  io.log("Device unpaired; local credentials, Facebook session, and pending scans were removed.");
2895
3027
  return 0;
2896
3028
  }
3029
+ function runCommand(command, args) {
3030
+ return new Promise((resolve, reject) => {
3031
+ const child = spawn(command, args, { stdio: "inherit" });
3032
+ child.once("error", reject);
3033
+ child.once("exit", (value) => resolve(value ?? 1));
3034
+ });
3035
+ }
3036
+ function resolveServiceExecStart() {
3037
+ const candidates = [join3(dirname3(process.execPath), "diggylab"), "/usr/local/bin/diggylab", "/usr/bin/diggylab"];
3038
+ const link = candidates.find((candidate) => existsSync2(candidate));
3039
+ return link ?? `${process.execPath} ${fileURLToPath(import.meta.url)}`;
3040
+ }
3041
+ async function commandInstallService(runtime, io) {
3042
+ if (process.platform !== "linux") {
3043
+ io.error("Automatic service setup supports Linux (systemd). Use the Docker image or your platform's service manager.");
3044
+ return 1;
3045
+ }
3046
+ if (!existsSync2("/usr/bin/systemctl") && !existsSync2("/bin/systemctl")) {
3047
+ io.error("systemd is not available on this machine.");
3048
+ return 1;
3049
+ }
3050
+ const unit = buildSystemdUnit({
3051
+ apiOrigin: runtime.endpoint.httpOrigin,
3052
+ configDirectory: runtime.directory,
3053
+ execStart: resolveServiceExecStart()
3054
+ });
3055
+ try {
3056
+ writeFileSync2(SERVICE_PATH, unit, { mode: 420 });
3057
+ } catch {
3058
+ io.error(`Could not write ${SERVICE_PATH}. Run this command as root (or with sudo).`);
3059
+ return 1;
3060
+ }
3061
+ io.log(`Wrote ${SERVICE_PATH}.`);
3062
+ await runCommand("systemctl", ["daemon-reload"]).catch(() => 1);
3063
+ const code = await runCommand("systemctl", ["enable", "--now", SERVICE_NAME]);
3064
+ if (code !== 0) {
3065
+ io.error(`systemctl could not enable the service. Check it with: systemctl status ${SERVICE_NAME}`);
3066
+ return code;
3067
+ }
3068
+ io.log(`Service ${SERVICE_NAME} is enabled: it runs now and after every reboot.`);
3069
+ io.log(`Follow its log with: journalctl -u ${SERVICE_NAME} -f`);
3070
+ await printReadiness(runtime, io);
3071
+ return 0;
3072
+ }
3073
+ async function commandUninstallService(io) {
3074
+ if (process.platform !== "linux") {
3075
+ io.error("Automatic service setup supports Linux (systemd).");
3076
+ return 1;
3077
+ }
3078
+ await runCommand("systemctl", ["disable", "--now", SERVICE_NAME]).catch(() => 1);
3079
+ try {
3080
+ rmSync(SERVICE_PATH, { force: true });
3081
+ } catch {
3082
+ io.error(`Could not remove ${SERVICE_PATH}. Run this command as root (or with sudo).`);
3083
+ return 1;
3084
+ }
3085
+ await runCommand("systemctl", ["daemon-reload"]).catch(() => 1);
3086
+ io.log(`Service ${SERVICE_NAME} removed.`);
3087
+ return 0;
3088
+ }
2897
3089
  async function main(argv, io = console) {
2898
3090
  if (argv.includes("--help") || argv.includes("-h")) {
2899
3091
  io.log(USAGE);
@@ -2910,7 +3102,7 @@ async function main(argv, io = console) {
2910
3102
  io.log(USAGE);
2911
3103
  return 0;
2912
3104
  }
2913
- if (!["install-browser", "login", "pair", "run", "status", "unpair"].includes(command)) {
3105
+ if (!["install-browser", "install-service", "login", "logout", "pair", "rename", "run", "status", "uninstall-service", "unpair"].includes(command)) {
2914
3106
  io.error(`Unknown command "${command}".`);
2915
3107
  io.error(USAGE);
2916
3108
  return 2;
@@ -2922,6 +3114,14 @@ async function main(argv, io = console) {
2922
3114
  return commandPair(runtime, parsed.options, io);
2923
3115
  case "login":
2924
3116
  return commandLogin(runtime, parsed.options, io);
3117
+ case "logout":
3118
+ return commandLogout(runtime, io);
3119
+ case "rename":
3120
+ return commandRename(runtime, parsed.options, io);
3121
+ case "install-service":
3122
+ return commandInstallService(runtime, io);
3123
+ case "uninstall-service":
3124
+ return commandUninstallService(io);
2925
3125
  case "run":
2926
3126
  return commandRun(runtime, io);
2927
3127
  case "status":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diggylab",
3
- "version": "0.1.42",
3
+ "version": "0.1.43",
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": {