twitchdropsminer-cli 0.1.0

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 (52) hide show
  1. package/README.md +153 -0
  2. package/dist/auth/cookieImport.js +7 -0
  3. package/dist/auth/deviceAuth.js +61 -0
  4. package/dist/auth/sessionManager.js +30 -0
  5. package/dist/auth/tokenImport.js +21 -0
  6. package/dist/auth/validate.js +29 -0
  7. package/dist/cli/commands/auth.js +172 -0
  8. package/dist/cli/commands/config.js +51 -0
  9. package/dist/cli/commands/doctor.js +49 -0
  10. package/dist/cli/commands/games.js +79 -0
  11. package/dist/cli/commands/healthcheck.js +25 -0
  12. package/dist/cli/commands/logs.js +14 -0
  13. package/dist/cli/commands/run.js +20 -0
  14. package/dist/cli/commands/service.js +85 -0
  15. package/dist/cli/commands/status.js +29 -0
  16. package/dist/cli/contracts/exitCodes.js +7 -0
  17. package/dist/cli/index.js +36 -0
  18. package/dist/config/schema.js +16 -0
  19. package/dist/config/store.js +29 -0
  20. package/dist/core/channelService.js +105 -0
  21. package/dist/core/constants.js +12 -0
  22. package/dist/core/maintenance.js +15 -0
  23. package/dist/core/miner.js +366 -0
  24. package/dist/core/runtime.js +34 -0
  25. package/dist/core/stateMachine.js +9 -0
  26. package/dist/core/watchLoop.js +26 -0
  27. package/dist/domain/channel.js +31 -0
  28. package/dist/domain/inventory.js +370 -0
  29. package/dist/integrations/gqlClient.js +13 -0
  30. package/dist/integrations/gqlOperations.js +42 -0
  31. package/dist/integrations/httpClient.js +37 -0
  32. package/dist/integrations/twitchPubSub.js +126 -0
  33. package/dist/integrations/twitchSpade.js +112 -0
  34. package/dist/ops/systemd.js +63 -0
  35. package/dist/state/authStore.js +38 -0
  36. package/dist/state/cookieStore.js +21 -0
  37. package/dist/state/sessionState.js +26 -0
  38. package/dist/tests/index.js +7 -0
  39. package/dist/tests/integration/configStore.test.js +8 -0
  40. package/dist/tests/parity/stateMachineFlow.test.js +14 -0
  41. package/dist/tests/unit/channel.test.js +73 -0
  42. package/dist/tests/unit/channelService.test.js +41 -0
  43. package/dist/tests/unit/dropsDomain.test.js +57 -0
  44. package/dist/tests/unit/tokenImport.test.js +13 -0
  45. package/dist/tests/unit/twitchSpade.test.js +24 -0
  46. package/docs/ops/authentication.md +32 -0
  47. package/docs/ops/drops-validation.md +73 -0
  48. package/docs/ops/linux-install.md +15 -0
  49. package/docs/ops/service-management.md +23 -0
  50. package/docs/ops/systemd-hardening.md +13 -0
  51. package/package.json +41 -0
  52. package/resources/systemd/tdm.service.tpl +17 -0
@@ -0,0 +1,63 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ function getUserSystemdDir() {
6
+ const home = os.homedir();
7
+ const dir = path.join(home, ".config", "systemd", "user");
8
+ if (!fs.existsSync(dir)) {
9
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
10
+ }
11
+ return dir;
12
+ }
13
+ export function getUnitPath(opts) {
14
+ const name = opts.serviceName ?? "tdm.service";
15
+ if (opts.userUnit) {
16
+ return path.join(getUserSystemdDir(), name);
17
+ }
18
+ return path.join("/etc/systemd/system", name);
19
+ }
20
+ export function generateUnitContents(opts) {
21
+ const nodePath = process.execPath;
22
+ const cliEntry = process.argv[1];
23
+ const isUserUnit = opts.userUnit;
24
+ const lines = [
25
+ "[Unit]",
26
+ "Description=Twitch Drops Miner CLI",
27
+ "After=network-online.target",
28
+ "Wants=network-online.target",
29
+ "",
30
+ "[Service]",
31
+ `ExecStart=${nodePath} ${cliEntry} run`,
32
+ "Restart=on-failure",
33
+ "RestartSec=5",
34
+ "Type=simple",
35
+ "Environment=TDM_LOG_LEVEL=info",
36
+ "NoNewPrivileges=true",
37
+ "",
38
+ "[Install]",
39
+ "WantedBy=default.target"
40
+ ];
41
+ // For user units, systemd already runs as the invoking user; adding User= breaks them.
42
+ if (!isUserUnit) {
43
+ const user = os.userInfo().username;
44
+ const serviceIndex = lines.indexOf("[Service]");
45
+ if (serviceIndex !== -1) {
46
+ lines.splice(serviceIndex + 2, 0, `User=${user}`);
47
+ }
48
+ }
49
+ return lines.join("\n");
50
+ }
51
+ export function writeUnitFile(opts) {
52
+ const unitPath = getUnitPath(opts);
53
+ const contents = generateUnitContents(opts);
54
+ fs.writeFileSync(unitPath, contents, { mode: 0o644 });
55
+ return unitPath;
56
+ }
57
+ export function systemctl(args, user) {
58
+ return new Promise((resolve) => {
59
+ const fullArgs = [...(user ? ["--user"] : []), ...args];
60
+ const child = spawn("systemctl", fullArgs, { stdio: "inherit" });
61
+ child.on("close", (code) => resolve(code ?? 0));
62
+ });
63
+ }
@@ -0,0 +1,38 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ function getConfigDir() {
5
+ const home = os.homedir();
6
+ const dir = path.join(home, ".config", "tdm");
7
+ if (!fs.existsSync(dir)) {
8
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
9
+ }
10
+ return dir;
11
+ }
12
+ function getAuthFilePath() {
13
+ return path.join(getConfigDir(), "auth.json");
14
+ }
15
+ export function loadAuthState() {
16
+ const file = getAuthFilePath();
17
+ if (!fs.existsSync(file)) {
18
+ return null;
19
+ }
20
+ try {
21
+ const raw = fs.readFileSync(file, "utf8");
22
+ const parsed = JSON.parse(raw);
23
+ return parsed;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ export function saveAuthState(state) {
30
+ const file = getAuthFilePath();
31
+ const payload = {
32
+ accessToken: state.accessToken,
33
+ cookiesHeader: state.cookiesHeader,
34
+ updatedAt: new Date().toISOString()
35
+ };
36
+ const json = JSON.stringify(payload, null, 2);
37
+ fs.writeFileSync(file, json, { mode: 0o600 });
38
+ }
@@ -0,0 +1,21 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ function cookieDir() {
5
+ const dir = path.join(os.homedir(), ".config", "tdm");
6
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
7
+ return dir;
8
+ }
9
+ function cookiePath() {
10
+ return path.join(cookieDir(), "cookies.txt");
11
+ }
12
+ export function loadCookieHeader() {
13
+ const p = cookiePath();
14
+ if (!fs.existsSync(p)) {
15
+ return null;
16
+ }
17
+ return fs.readFileSync(p, "utf8").trim() || null;
18
+ }
19
+ export function saveCookieHeader(cookieHeader) {
20
+ fs.writeFileSync(cookiePath(), cookieHeader, { mode: 0o600 });
21
+ }
@@ -0,0 +1,26 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ function stateDir() {
5
+ const dir = path.join(os.homedir(), ".local", "state", "tdm");
6
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
7
+ return dir;
8
+ }
9
+ function sessionPath() {
10
+ return path.join(stateDir(), "session.json");
11
+ }
12
+ export function loadSessionState() {
13
+ const p = sessionPath();
14
+ if (!fs.existsSync(p)) {
15
+ return null;
16
+ }
17
+ try {
18
+ return JSON.parse(fs.readFileSync(p, "utf8"));
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ export function saveSessionState(next) {
25
+ fs.writeFileSync(sessionPath(), JSON.stringify(next, null, 2), { mode: 0o600 });
26
+ }
@@ -0,0 +1,7 @@
1
+ import "./unit/tokenImport.test.js";
2
+ import "./unit/channel.test.js";
3
+ import "./unit/channelService.test.js";
4
+ import "./unit/dropsDomain.test.js";
5
+ import "./unit/twitchSpade.test.js";
6
+ import "./integration/configStore.test.js";
7
+ import "./parity/stateMachineFlow.test.js";
@@ -0,0 +1,8 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { loadConfig } from "../../config/store.js";
4
+ test("loadConfig returns defaults when config missing/invalid", () => {
5
+ const cfg = loadConfig();
6
+ assert.ok(cfg);
7
+ assert.equal(typeof cfg.connectionQuality, "number");
8
+ });
@@ -0,0 +1,14 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { StateMachine } from "../../core/stateMachine.js";
4
+ test("state machine supports expected flow states", () => {
5
+ const sm = new StateMachine();
6
+ sm.setState("INVENTORY_FETCH");
7
+ assert.equal(sm.state, "INVENTORY_FETCH");
8
+ sm.setState("GAMES_UPDATE");
9
+ assert.equal(sm.state, "GAMES_UPDATE");
10
+ sm.setState("CHANNELS_FETCH");
11
+ assert.equal(sm.state, "CHANNELS_FETCH");
12
+ sm.setState("CHANNEL_SWITCH");
13
+ assert.equal(sm.state, "CHANNEL_SWITCH");
14
+ });
@@ -0,0 +1,73 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { canWatchChannel, sortChannelCandidates, getChannelPriority, shouldSwitchChannel } from "../../domain/channel.js";
4
+ test("canWatchChannel requires online, drops enabled, and wanted game", () => {
5
+ const ok = canWatchChannel({
6
+ id: "1",
7
+ login: "alpha",
8
+ online: true,
9
+ viewers: 100,
10
+ gameName: "GameA",
11
+ dropsEnabled: true
12
+ }, ["GameA"]);
13
+ assert.equal(ok, true);
14
+ });
15
+ test("sortChannelCandidates prioritizes wanted game then viewers", () => {
16
+ const sorted = sortChannelCandidates([
17
+ { id: "1", login: "a", online: true, viewers: 50, gameName: "GameB", dropsEnabled: true },
18
+ { id: "2", login: "b", online: true, viewers: 10, gameName: "GameA", dropsEnabled: true }
19
+ ], ["GameA", "GameB"]);
20
+ assert.equal(sorted[0]?.id, "2");
21
+ });
22
+ test("getChannelPriority prefers ACL then game order then viewers", () => {
23
+ const wanted = ["GameA", "GameB"];
24
+ const acl = {
25
+ id: "acl",
26
+ login: "acl_ch",
27
+ online: true,
28
+ viewers: 5,
29
+ gameName: "GameB",
30
+ dropsEnabled: true,
31
+ aclBased: true
32
+ };
33
+ const nonAcl = {
34
+ id: "dir",
35
+ login: "dir_ch",
36
+ online: true,
37
+ viewers: 1000,
38
+ gameName: "GameA",
39
+ dropsEnabled: true,
40
+ aclBased: false
41
+ };
42
+ assert.ok(getChannelPriority(acl, wanted) < getChannelPriority(nonAcl, wanted));
43
+ const sorted = sortChannelCandidates([nonAcl, acl], wanted);
44
+ assert.equal(sorted[0]?.id, "acl");
45
+ });
46
+ test("shouldSwitchChannel returns true when current is null or candidate has higher priority", () => {
47
+ const wanted = ["GameA", "GameB"];
48
+ const low = {
49
+ id: "1",
50
+ login: "low",
51
+ online: true,
52
+ viewers: 10,
53
+ gameName: "GameB",
54
+ dropsEnabled: true
55
+ };
56
+ const high = {
57
+ id: "2",
58
+ login: "high",
59
+ online: true,
60
+ viewers: 100,
61
+ gameName: "GameA",
62
+ dropsEnabled: true
63
+ };
64
+ assert.equal(shouldSwitchChannel(null, high, wanted), true);
65
+ assert.equal(shouldSwitchChannel(low, high, wanted), true);
66
+ assert.equal(shouldSwitchChannel(high, low, wanted), false);
67
+ });
68
+ test("channels without wanted game or dropsEnabled are filtered by canWatchChannel", () => {
69
+ const wanted = ["GameA"];
70
+ assert.equal(canWatchChannel({ id: "1", login: "x", online: true, viewers: 1, gameName: "Other", dropsEnabled: true }, wanted), false);
71
+ assert.equal(canWatchChannel({ id: "2", login: "y", online: true, viewers: 1, gameName: "GameA", dropsEnabled: false }, wanted), false);
72
+ assert.equal(canWatchChannel({ id: "3", login: "z", online: true, viewers: 1, gameName: "GameA", dropsEnabled: true }, wanted), true);
73
+ });
@@ -0,0 +1,41 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseGameDirectoryResponse } from "../../core/channelService.js";
4
+ test("parseGameDirectoryResponse extracts channels from directory payload", () => {
5
+ const response = {
6
+ data: {
7
+ game: {
8
+ streams: {
9
+ edges: [
10
+ {
11
+ node: {
12
+ broadcasters: [{ id: "123", login: "streamer1" }],
13
+ viewersCount: 500,
14
+ game: { displayName: "Just Chatting" }
15
+ }
16
+ },
17
+ {
18
+ node: {
19
+ broadcaster: { id: "456", login: "streamer2" },
20
+ viewerCount: 100,
21
+ game: { name: "Fallout" }
22
+ }
23
+ }
24
+ ]
25
+ }
26
+ }
27
+ }
28
+ };
29
+ const channels = parseGameDirectoryResponse(response, "Just Chatting", false);
30
+ assert.equal(channels.length, 2);
31
+ assert.equal(channels[0]?.id, "123");
32
+ assert.equal(channels[0]?.login, "streamer1");
33
+ assert.equal(channels[0]?.viewers, 500);
34
+ assert.equal(channels[0]?.gameName, "Just Chatting");
35
+ assert.equal(channels[0]?.online, true);
36
+ assert.equal(channels[0]?.dropsEnabled, true);
37
+ assert.equal(channels[1]?.id, "456");
38
+ assert.equal(channels[1]?.login, "streamer2");
39
+ assert.equal(channels[1]?.viewers, 100);
40
+ assert.equal(channels[1]?.aclBased, false);
41
+ });
@@ -0,0 +1,57 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { DropsCampaign } from "../../domain/inventory.js";
4
+ test("TimedDrop canClaim respects 24h post-campaign window", () => {
5
+ const now = new Date();
6
+ const campaignRaw = {
7
+ id: "c1",
8
+ name: "Test Campaign",
9
+ game: { name: "GameA", slug: "gamea" },
10
+ self: { isAccountConnected: true },
11
+ startAt: new Date(now.getTime() - 60 * 60 * 1000).toISOString(),
12
+ endAt: new Date(now.getTime() - 30 * 60 * 1000).toISOString(),
13
+ status: "ACTIVE",
14
+ timeBasedDrops: [
15
+ {
16
+ id: "d1",
17
+ name: "Drop1",
18
+ startAt: new Date(now.getTime() - 60 * 60 * 1000).toISOString(),
19
+ endAt: new Date(now.getTime() - 30 * 60 * 1000).toISOString(),
20
+ requiredMinutesWatched: 30,
21
+ benefitEdges: [{ benefit: { id: "b1", name: "Reward", distributionType: "DIRECT_ENTITLEMENT", imageAssetURL: "" } }],
22
+ self: { dropInstanceID: "inst1", isClaimed: false, currentMinutesWatched: 30 },
23
+ preconditionDrops: []
24
+ }
25
+ ]
26
+ };
27
+ const campaign = new DropsCampaign(campaignRaw, {}, true);
28
+ const drop = Array.from(campaign.timedDrops.values())[0];
29
+ assert.equal(drop.canClaim, true);
30
+ });
31
+ test("DropsCampaign canEarnWithin filters by timeframe and eligibility", () => {
32
+ const now = new Date();
33
+ const campaignRaw = {
34
+ id: "c2",
35
+ name: "Test Campaign 2",
36
+ game: { name: "GameB", slug: "gameb" },
37
+ self: { isAccountConnected: true },
38
+ startAt: new Date(now.getTime() - 10 * 60 * 1000).toISOString(),
39
+ endAt: new Date(now.getTime() + 50 * 60 * 1000).toISOString(),
40
+ status: "ACTIVE",
41
+ timeBasedDrops: [
42
+ {
43
+ id: "d2",
44
+ name: "Drop2",
45
+ startAt: new Date(now.getTime() - 10 * 60 * 1000).toISOString(),
46
+ endAt: new Date(now.getTime() + 50 * 60 * 1000).toISOString(),
47
+ requiredMinutesWatched: 30,
48
+ benefitEdges: [{ benefit: { id: "b2", name: "Reward", distributionType: "DIRECT_ENTITLEMENT", imageAssetURL: "" } }],
49
+ self: { dropInstanceID: null, isClaimed: false, currentMinutesWatched: 0 },
50
+ preconditionDrops: []
51
+ }
52
+ ]
53
+ };
54
+ const campaign = new DropsCampaign(campaignRaw, {}, true);
55
+ const stamp = new Date(now.getTime() + 30 * 60 * 1000);
56
+ assert.equal(campaign.canEarnWithin(stamp), true);
57
+ });
@@ -0,0 +1,13 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseTokenInput } from "../../auth/tokenImport.js";
4
+ test("parseTokenInput supports raw token", () => {
5
+ const parsed = parseTokenInput("abc123");
6
+ assert.equal(parsed.accessToken, "abc123");
7
+ assert.equal(parsed.source, "raw");
8
+ });
9
+ test("parseTokenInput supports auth-token pair", () => {
10
+ const parsed = parseTokenInput("auth-token=abc123");
11
+ assert.equal(parsed.accessToken, "abc123");
12
+ assert.equal(parsed.source, "auth-token");
13
+ });
@@ -0,0 +1,24 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { buildSpadePayload } from "../../integrations/twitchSpade.js";
4
+ const SPADE_PATTERN = /"(?:beacon|spade)_?url":\s*"(https:\/\/[.\w\-/]+\.ts(?:\?allow_stream=true)?)"/i;
5
+ test("buildSpadePayload returns base64 data with minute-watched event", () => {
6
+ const out = buildSpadePayload("12345", "67890", "streamer", "user1");
7
+ assert.ok(out.data);
8
+ const decoded = Buffer.from(out.data, "base64").toString("utf8");
9
+ const payload = JSON.parse(decoded);
10
+ assert.equal(Array.isArray(payload), true);
11
+ assert.equal(payload[0]?.event, "minute-watched");
12
+ assert.equal(payload[0]?.properties?.broadcast_id, "12345");
13
+ assert.equal(payload[0]?.properties?.channel_id, "67890");
14
+ assert.equal(payload[0]?.properties?.channel, "streamer");
15
+ assert.equal(payload[0]?.properties?.user_id, "user1");
16
+ assert.equal(payload[0]?.properties?.live, true);
17
+ assert.equal(payload[0]?.properties?.player, "site");
18
+ });
19
+ test("spade URL regex extracts URL from synthetic HTML", () => {
20
+ const html = '"beacon_url": "https://spade.example.com/v1/beacon.ts?allow_stream=true"';
21
+ const match = html.match(SPADE_PATTERN);
22
+ assert.ok(match);
23
+ assert.equal(match[1], "https://spade.example.com/v1/beacon.ts?allow_stream=true");
24
+ });
@@ -0,0 +1,32 @@
1
+ ## Authentication (headless)
2
+
3
+ ### Device-code login (recommended)
4
+
5
+ ```bash
6
+ tdm auth login --no-open
7
+ ```
8
+
9
+ Follow the printed `verification_uri` and `user_code` on another device.
10
+
11
+ ### Import an existing token
12
+
13
+ ```bash
14
+ tdm auth import --token "auth-token=XXXX"
15
+ # or
16
+ tdm auth import --token-file /secure/path/token.txt
17
+ ```
18
+
19
+ ### Import cookies
20
+
21
+ ```bash
22
+ tdm auth import-cookie --cookie "auth-token=XXXX; other=YYY"
23
+ # or
24
+ tdm auth import-cookie --cookie-file /secure/path/cookies.txt
25
+ ```
26
+
27
+ ### Validate
28
+
29
+ ```bash
30
+ tdm auth validate
31
+ ```
32
+
@@ -0,0 +1,73 @@
1
+ # Drops validation playbook
2
+
3
+ This playbook helps confirm that the CLI actually advances and claims Twitch Drops as intended, without opening a browser stream.
4
+
5
+ ## Prerequisites
6
+
7
+ - A Twitch account (test account recommended).
8
+ - CLI installed and built: `npm run build`.
9
+ - Auth completed: `tdm auth login --no-open` (or paste token when prompted).
10
+
11
+ ## 1. Configure for a single game
12
+
13
+ - Choose an active Drops campaign with a **short first drop** (e.g. 15–30 minutes) so you can see progress quickly.
14
+ - In config (e.g. `~/.config/tdm/config.json` or project `tdm.config.json`), set:
15
+ - `priority`: `["<Game Name>"]` (exact game name from the campaign).
16
+ - `priorityMode`: `"priority_only"` so only that game is mined.
17
+ - `exclude`: `[]` (or leave default).
18
+
19
+ ## 2. Dry run (no network writes)
20
+
21
+ - Run with dry-run and verbose to see intended actions only:
22
+ ```bash
23
+ tdm run --dry-run --verbose
24
+ ```
25
+ - Confirm logs show:
26
+ - Inventory fetch and campaign list.
27
+ - Wanted games = your priority game.
28
+ - Channel fetch and selected channel.
29
+ - “Would send watch” (no real spade POST).
30
+ - “Would claim” for any claimable drop (no real ClaimDrop GQL).
31
+ - Stop with Ctrl+C.
32
+
33
+ ## 3. Live run and Twitch Inventory check
34
+
35
+ - In a browser, open [Twitch Drops Inventory](https://www.twitch.tv/drops/inventory) and log in with the same account.
36
+ - Note the current “minutes watched” (and “Claim” button if the drop is ready) for the target campaign.
37
+ - Start the miner:
38
+ ```bash
39
+ tdm run --verbose
40
+ ```
41
+ - Let it run for at least one watch interval (about 1 minute). You should see “Watch tick sent for channel …” in the logs.
42
+ - Refresh the Twitch Inventory page: “minutes watched” for the active drop should increase (may take 1–2 minutes to reflect).
43
+ - If the drop becomes claimable, the CLI should auto-claim; check logs for “Claimed drop” and confirm the drop shows as claimed in the Inventory.
44
+
45
+ ## 4. Compare with Python miner (optional)
46
+
47
+ - Using the same Twitch account and same priority game:
48
+ - Run the Python TwitchDropsMiner and note progression/claim time.
49
+ - Run the CLI with the same config and note progression/claim time.
50
+ - Progression and claim times should be comparable (allow for Twitch-side variance).
51
+
52
+ ## 5. Status command
53
+
54
+ - While the miner is running (or after it has run), in another terminal:
55
+ ```bash
56
+ tdm status
57
+ tdm status --json
58
+ ```
59
+ - Confirm `state` (e.g. WATCHING or MAINTENANCE) and `activeDrop` (e.g. “Game Name: Drop Name”) look correct.
60
+
61
+ ## Success criteria
62
+
63
+ - With a test account and active drops campaign:
64
+ - `tdm run` increases “minutes watched” for the targeted drop on Twitch Inventory without opening a stream in the browser.
65
+ - Drops are claimed automatically when eligible (or a manual step is documented).
66
+ - `tdm run --dry-run --verbose` logs intended watch and claim actions without performing spade/claim network calls.
67
+ - `tdm status` shows a sensible state and active drop.
68
+
69
+ ## Troubleshooting
70
+
71
+ - **No channels / “No channel candidates”**: Ensure the game has live streams with Drops enabled; try a different game or relax `priorityMode`.
72
+ - **Watch tick failed**: Spade URL extraction or auth may be failing; run with `--verbose` and check logs.
73
+ - **Minutes not updating**: Twitch can delay updates; wait 2–3 minutes and refresh the Inventory page. Ensure you’re watching a channel that has Drops for that campaign.
@@ -0,0 +1,15 @@
1
+ ## Linux install (headless)
2
+
3
+ - **Prereqs**: `node >= 20`, outbound HTTPS/WSS to Twitch.
4
+ - Install globally:
5
+
6
+ ```bash
7
+ npm install -g twitchdropsminer-cli
8
+ ```
9
+
10
+ - Verify environment:
11
+
12
+ ```bash
13
+ tdm doctor
14
+ ```
15
+
@@ -0,0 +1,23 @@
1
+ ## Running as a service (systemd)
2
+
3
+ ### Install user-level service
4
+
5
+ ```bash
6
+ tdm service install --user --autostart
7
+ tdm service start
8
+ ```
9
+
10
+ ### Check status
11
+
12
+ ```bash
13
+ tdm service status
14
+ ```
15
+
16
+ ### Logs (journalctl)
17
+
18
+ Use standard `journalctl` commands, e.g.:
19
+
20
+ ```bash
21
+ journalctl --user -u tdm.service -f
22
+ ```
23
+
@@ -0,0 +1,13 @@
1
+ ## systemd hardening notes
2
+
3
+ Recommended service-level settings:
4
+
5
+ - `NoNewPrivileges=true`
6
+ - `PrivateTmp=true`
7
+ - `Restart=on-failure`
8
+ - `RestartSec=5`
9
+ - `After=network-online.target`
10
+ - `Wants=network-online.target`
11
+
12
+ Use user-level units for least privilege unless you explicitly need a system unit.
13
+
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "twitchdropsminer-cli",
3
+ "version": "0.1.0",
4
+ "description": "Headless CLI rewrite of DevilXD/TwitchDropsMiner for mining Twitch drops on servers.",
5
+ "bin": {
6
+ "tdm": "dist/cli/index.js"
7
+ },
8
+ "main": "dist/cli/index.js",
9
+ "files": [
10
+ "dist",
11
+ "docs/ops",
12
+ "resources/systemd"
13
+ ],
14
+ "type": "module",
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "prepublishOnly": "npm run build",
18
+ "dev": "ts-node src/cli/index.ts",
19
+ "lint": "echo \"no linter configured\"",
20
+ "test": "npm run build && node --test dist/tests/index.js"
21
+ },
22
+ "engines": {
23
+ "node": ">=20.0.0"
24
+ },
25
+ "author": "",
26
+ "license": "MIT",
27
+ "dependencies": {
28
+ "@commander-js/extra-typings": "^12.1.0",
29
+ "pino": "^9.0.0",
30
+ "tough-cookie": "^5.0.0",
31
+ "undici": "^6.0.0",
32
+ "ws": "^8.17.0",
33
+ "zod": "^3.24.0"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^22.0.0",
37
+ "@types/ws": "^8.18.1",
38
+ "ts-node": "^10.9.2",
39
+ "typescript": "^5.7.0"
40
+ }
41
+ }
@@ -0,0 +1,17 @@
1
+ [Unit]
2
+ Description=Twitch Drops Miner CLI
3
+ After=network-online.target
4
+ Wants=network-online.target
5
+
6
+ [Service]
7
+ Type=simple
8
+ ExecStart={{NODE_PATH}} {{TDM_BIN}} run
9
+ Restart=on-failure
10
+ RestartSec=5
11
+ NoNewPrivileges=true
12
+ PrivateTmp=true
13
+ Environment=TDM_LOG_LEVEL=info
14
+
15
+ [Install]
16
+ WantedBy=default.target
17
+