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,25 @@
1
+ import { Command } from "@commander-js/extra-typings";
2
+ import { loadAuthState } from "../../state/authStore.js";
3
+ import { EXIT_AUTH_MISSING, EXIT_OK } from "../contracts/exitCodes.js";
4
+ export const healthcheckCommand = new Command("healthcheck")
5
+ .description("Return a simple machine-readable health status for monitoring")
6
+ .option("--json", "Output health as JSON", false)
7
+ .action(async (opts) => {
8
+ const auth = loadAuthState();
9
+ const healthy = !!auth && (!!auth.accessToken || !!auth.cookiesHeader);
10
+ if (opts.json) {
11
+ // eslint-disable-next-line no-console
12
+ console.log(JSON.stringify({
13
+ status: healthy ? "ok" : "degraded",
14
+ auth: {
15
+ hasToken: !!auth?.accessToken,
16
+ hasCookies: !!auth?.cookiesHeader
17
+ }
18
+ }, null, 2));
19
+ }
20
+ else {
21
+ // eslint-disable-next-line no-console
22
+ console.log(healthy ? "OK" : "DEGRADED");
23
+ }
24
+ process.exitCode = healthy ? EXIT_OK : EXIT_AUTH_MISSING;
25
+ });
@@ -0,0 +1,14 @@
1
+ import { Command } from "@commander-js/extra-typings";
2
+ import { spawn } from "node:child_process";
3
+ export const logsCommand = new Command("logs")
4
+ .description("Follow service logs via journalctl")
5
+ .option("--name <name>", "Service unit name", "tdm.service")
6
+ .option("--user", "Use user journal", true)
7
+ .option("--follow", "Follow logs", true)
8
+ .action(async (opts) => {
9
+ const args = [...(opts.user ? ["--user"] : []), "-u", opts.name, ...(opts.follow ? ["-f"] : [])];
10
+ await new Promise((resolve) => {
11
+ const proc = spawn("journalctl", args, { stdio: "inherit" });
12
+ proc.on("close", () => resolve());
13
+ });
14
+ });
@@ -0,0 +1,20 @@
1
+ import { Command } from "@commander-js/extra-typings";
2
+ import { Miner } from "../../core/miner.js";
3
+ import { logger } from "../../core/runtime.js";
4
+ export const runCommand = new Command("run")
5
+ .description("Run the Twitch drops miner")
6
+ .option("-v, --verbose", "Enable verbose logging")
7
+ .option("--dry-run", "Log intended actions only; no spade POST or claim GQL")
8
+ .action(async (opts) => {
9
+ if (opts.verbose) {
10
+ process.env.TDM_LOG_LEVEL = "debug";
11
+ }
12
+ const miner = new Miner();
13
+ if (opts.verbose) {
14
+ logger.info("Starting TwitchDropsMiner CLI in verbose mode.");
15
+ }
16
+ if (opts.dryRun) {
17
+ logger.info("Dry-run mode: no spade or claim network writes.");
18
+ }
19
+ await miner.run({ dryRun: opts.dryRun ?? false });
20
+ });
@@ -0,0 +1,85 @@
1
+ import { Command } from "@commander-js/extra-typings";
2
+ import { writeUnitFile, systemctl } from "../../ops/systemd.js";
3
+ import { EXIT_OK, EXIT_SERVICE_ERROR } from "../contracts/exitCodes.js";
4
+ export const serviceCommand = new Command("service")
5
+ .description("Manage TwitchDropsMiner CLI as a systemd service")
6
+ .option("--user", "Use user-level systemd unit (recommended)", true)
7
+ .option("--system", "Use system-level systemd unit (requires root)", false);
8
+ serviceCommand
9
+ .command("install")
10
+ .description("Install the systemd unit")
11
+ .option("--name <name>", "Service unit name", "tdm.service")
12
+ .option("--autostart", "Enable service at boot", true)
13
+ .action(async (opts, cmd) => {
14
+ const parent = cmd.parent;
15
+ const userUnit = parent.getOptionValue("user") && !parent.getOptionValue("system");
16
+ const unitPath = writeUnitFile({
17
+ userUnit,
18
+ autostart: opts.autostart,
19
+ serviceName: opts.name
20
+ });
21
+ // eslint-disable-next-line no-console
22
+ console.log(`Installed unit at: ${unitPath}`);
23
+ if (opts.autostart) {
24
+ const code = await systemctl(["enable", opts.name], userUnit);
25
+ if (code !== 0) {
26
+ process.exitCode = EXIT_SERVICE_ERROR;
27
+ return;
28
+ }
29
+ }
30
+ process.exitCode = EXIT_OK;
31
+ });
32
+ serviceCommand
33
+ .command("start")
34
+ .description("Start the service")
35
+ .option("--name <name>", "Service unit name", "tdm.service")
36
+ .action(async (opts, cmd) => {
37
+ const parent = cmd.parent;
38
+ const userUnit = parent.getOptionValue("user") && !parent.getOptionValue("system");
39
+ const code = await systemctl(["start", opts.name], userUnit);
40
+ process.exitCode = code === 0 ? EXIT_OK : EXIT_SERVICE_ERROR;
41
+ });
42
+ serviceCommand
43
+ .command("stop")
44
+ .description("Stop the service")
45
+ .option("--name <name>", "Service unit name", "tdm.service")
46
+ .action(async (opts, cmd) => {
47
+ const parent = cmd.parent;
48
+ const userUnit = parent.getOptionValue("user") && !parent.getOptionValue("system");
49
+ const code = await systemctl(["stop", opts.name], userUnit);
50
+ process.exitCode = code === 0 ? EXIT_OK : EXIT_SERVICE_ERROR;
51
+ });
52
+ serviceCommand
53
+ .command("restart")
54
+ .description("Restart the service")
55
+ .option("--name <name>", "Service unit name", "tdm.service")
56
+ .action(async (opts, cmd) => {
57
+ const parent = cmd.parent;
58
+ const userUnit = parent.getOptionValue("user") && !parent.getOptionValue("system");
59
+ const code = await systemctl(["restart", opts.name], userUnit);
60
+ process.exitCode = code === 0 ? EXIT_OK : EXIT_SERVICE_ERROR;
61
+ });
62
+ serviceCommand
63
+ .command("status")
64
+ .description("Show service status")
65
+ .option("--name <name>", "Service unit name", "tdm.service")
66
+ .action(async (opts, cmd) => {
67
+ const parent = cmd.parent;
68
+ const userUnit = parent.getOptionValue("user") && !parent.getOptionValue("system");
69
+ const code = await systemctl(["status", opts.name], userUnit);
70
+ process.exitCode = code === 0 ? EXIT_OK : EXIT_SERVICE_ERROR;
71
+ });
72
+ serviceCommand
73
+ .command("uninstall")
74
+ .description("Disable and remove the systemd unit")
75
+ .option("--name <name>", "Service unit name", "tdm.service")
76
+ .action(async (opts, cmd) => {
77
+ const parent = cmd.parent;
78
+ const userUnit = parent.getOptionValue("user") && !parent.getOptionValue("system");
79
+ // Best-effort disable; ignore errors.
80
+ await systemctl(["disable", opts.name], userUnit);
81
+ // Unit file removal is left to the user to avoid accidental deletions outside home.
82
+ // eslint-disable-next-line no-console
83
+ console.log("Service disabled. To fully remove the unit file, delete it from your systemd directory.");
84
+ process.exitCode = EXIT_OK;
85
+ });
@@ -0,0 +1,29 @@
1
+ import { Command } from "@commander-js/extra-typings";
2
+ import { loadSessionState } from "../../state/sessionState.js";
3
+ export const statusCommand = new Command("status")
4
+ .description("Show current miner status")
5
+ .option("--json", "Output status as JSON")
6
+ .action(async (opts) => {
7
+ const session = loadSessionState();
8
+ const rawState = session?.state ?? "UNKNOWN";
9
+ const highLevel = rawState === "IDLE" && session?.watchedChannelName
10
+ ? "WATCHING"
11
+ : rawState !== "IDLE" && rawState !== "EXIT"
12
+ ? "MAINTENANCE"
13
+ : rawState;
14
+ const status = {
15
+ running: rawState !== "EXIT",
16
+ state: highLevel,
17
+ rawState,
18
+ watchedChannel: session?.watchedChannelName ?? null,
19
+ activeDrop: session?.activeDropId ?? null
20
+ };
21
+ if (opts.json) {
22
+ // eslint-disable-next-line no-console
23
+ console.log(JSON.stringify(status));
24
+ }
25
+ else {
26
+ // eslint-disable-next-line no-console
27
+ console.log(`State=${status.state}, channel=${status.watchedChannel ?? "-"}, activeDrop=${status.activeDrop ?? "-"}`);
28
+ }
29
+ });
@@ -0,0 +1,7 @@
1
+ export const EXIT_OK = 0;
2
+ export const EXIT_GENERIC_ERROR = 1;
3
+ export const EXIT_CONFIG_ERROR = 2;
4
+ export const EXIT_AUTH_MISSING = 3;
5
+ export const EXIT_AUTH_INVALID = 4;
6
+ export const EXIT_ENV_UNSUPPORTED = 5;
7
+ export const EXIT_SERVICE_ERROR = 6;
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "@commander-js/extra-typings";
3
+ import { runCommand } from "./commands/run.js";
4
+ import { authCommand } from "./commands/auth.js";
5
+ import { statusCommand } from "./commands/status.js";
6
+ import { configCommand } from "./commands/config.js";
7
+ import { gamesCommand } from "./commands/games.js";
8
+ import { doctorCommand } from "./commands/doctor.js";
9
+ import { healthcheckCommand } from "./commands/healthcheck.js";
10
+ import { serviceCommand } from "./commands/service.js";
11
+ import { logsCommand } from "./commands/logs.js";
12
+ import { ensureSingleInstanceLock } from "../core/runtime.js";
13
+ const program = new Command();
14
+ program
15
+ .name("tdm")
16
+ .description("Twitch Drops Miner CLI (headless)")
17
+ .version("0.1.0");
18
+ program.hook("preAction", (_thisCommand, actionCommand) => {
19
+ if (actionCommand.name() === "run") {
20
+ ensureSingleInstanceLock();
21
+ }
22
+ });
23
+ program.addCommand(runCommand);
24
+ program.addCommand(authCommand);
25
+ program.addCommand(statusCommand);
26
+ program.addCommand(configCommand);
27
+ program.addCommand(gamesCommand);
28
+ program.addCommand(doctorCommand);
29
+ program.addCommand(healthcheckCommand);
30
+ program.addCommand(serviceCommand);
31
+ program.addCommand(logsCommand);
32
+ program.parseAsync().catch((err) => {
33
+ // eslint-disable-next-line no-console
34
+ console.error("Fatal error:", err);
35
+ process.exitCode = 1;
36
+ });
@@ -0,0 +1,16 @@
1
+ import { z } from "zod";
2
+ export const PriorityModeSchema = z.enum(["priority_only", "ending_soonest", "low_avbl_first"]);
3
+ export const ConfigSchema = z.object({
4
+ proxy: z.string().default(""),
5
+ language: z.string().default("English"),
6
+ darkMode: z.boolean().default(false),
7
+ exclude: z.array(z.string()).default([]),
8
+ priority: z.array(z.string()).default([]),
9
+ autostartTray: z.boolean().default(false),
10
+ connectionQuality: z.number().int().min(1).max(6).default(1),
11
+ trayNotifications: z.boolean().default(true),
12
+ enableBadgesEmotes: z.boolean().default(false),
13
+ availableDropsCheck: z.boolean().default(false),
14
+ priorityMode: PriorityModeSchema.default("priority_only")
15
+ });
16
+ export const DEFAULT_CONFIG = ConfigSchema.parse({});
@@ -0,0 +1,29 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { ConfigSchema, DEFAULT_CONFIG } from "./schema.js";
5
+ function configDir() {
6
+ const dir = path.join(os.homedir(), ".config", "tdm");
7
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
8
+ return dir;
9
+ }
10
+ export function configPath() {
11
+ return path.join(configDir(), "config.json");
12
+ }
13
+ export function loadConfig() {
14
+ const p = configPath();
15
+ if (!fs.existsSync(p)) {
16
+ return DEFAULT_CONFIG;
17
+ }
18
+ try {
19
+ const raw = fs.readFileSync(p, "utf8");
20
+ return ConfigSchema.parse(JSON.parse(raw));
21
+ }
22
+ catch {
23
+ return DEFAULT_CONFIG;
24
+ }
25
+ }
26
+ export function saveConfig(config) {
27
+ const p = configPath();
28
+ fs.writeFileSync(p, JSON.stringify(ConfigSchema.parse(config), null, 2), { mode: 0o600 });
29
+ }
@@ -0,0 +1,105 @@
1
+ import { GQL_OPERATIONS } from "../integrations/gqlOperations.js";
2
+ import { gqlRequest } from "../integrations/gqlClient.js";
3
+ import { sortChannelCandidates, canWatchChannel } from "../domain/channel.js";
4
+ import { logger } from "./runtime.js";
5
+ export const MAX_CHANNELS = 100;
6
+ /**
7
+ * Parse Twitch GQL DirectoryPage_Game response into Channel list.
8
+ * Expects structure: data.game.streams.edges[].node with broadcaster(s), viewersCount, game.
9
+ */
10
+ export function parseGameDirectoryResponse(response, gameName, aclBased) {
11
+ const data = response?.data;
12
+ const game = data?.game;
13
+ const streams = game?.streams;
14
+ const edges = streams?.edges ?? [];
15
+ const channels = [];
16
+ for (const edge of edges) {
17
+ const node = edge?.node;
18
+ if (!node)
19
+ continue;
20
+ let broadcasters = Array.isArray(node.broadcasters) ? node.broadcasters : [];
21
+ if (broadcasters.length === 0 && node.broadcaster != null) {
22
+ broadcasters = [node.broadcaster];
23
+ }
24
+ const broadcaster = broadcasters[0];
25
+ if (!broadcaster)
26
+ continue;
27
+ const id = String(broadcaster.id ?? broadcaster.login ?? "");
28
+ const login = String(broadcaster.login ?? broadcaster.displayName ?? id);
29
+ if (!id || !login)
30
+ continue;
31
+ const viewers = Number(node.viewersCount ?? node.viewerCount ?? 0);
32
+ const gameNode = node.game;
33
+ const displayGame = gameNode
34
+ ? String(gameNode.displayName ?? gameNode.name ?? gameName)
35
+ : gameName;
36
+ const streamId = node.id != null ? String(node.id) : undefined;
37
+ channels.push({
38
+ id,
39
+ login,
40
+ online: true,
41
+ viewers: Number.isFinite(viewers) ? viewers : 0,
42
+ gameName: displayGame,
43
+ dropsEnabled: node.isDropsEnabled === false ? false : true,
44
+ aclBased,
45
+ streamId
46
+ });
47
+ }
48
+ return channels;
49
+ }
50
+ /**
51
+ * Build ACL-based channel IDs from campaigns (campaign allowlist / tags if present in GQL).
52
+ * For now campaigns do not expose channel allowlist in our GQL shape; return empty set.
53
+ */
54
+ export function getAclChannelIdsFromCampaigns(_campaigns) {
55
+ const ids = new Set();
56
+ // When GQL provides campaign channel allowlist, add those ids here.
57
+ return ids;
58
+ }
59
+ /**
60
+ * Fetch channels for wanted games via GameDirectory GQL, merge and cap to maxChannels.
61
+ * ACL channels (from campaign allowlist) are marked and preferred in sorting elsewhere.
62
+ */
63
+ /**
64
+ * Resolve game name to Twitch directory slug (from campaigns when available).
65
+ */
66
+ function gameNameToSlug(gameName, campaigns) {
67
+ const c = campaigns.find((camp) => camp.gameName === gameName);
68
+ return c ? c.gameSlug : gameName.toLowerCase().replace(/\s+/g, "-");
69
+ }
70
+ export async function fetchChannelsForWantedGames(token, options) {
71
+ const { wantedGames, campaigns, maxChannels = MAX_CHANNELS } = options;
72
+ const aclIds = getAclChannelIdsFromCampaigns(campaigns);
73
+ const byId = new Map();
74
+ for (const gameName of wantedGames) {
75
+ const slug = gameNameToSlug(gameName, campaigns);
76
+ const response = await gqlRequest(GQL_OPERATIONS.GameDirectory, token, {
77
+ slug,
78
+ limit: 30,
79
+ sortTypeIsRecency: false,
80
+ includeCostreaming: false
81
+ });
82
+ const resp = response;
83
+ const gqlErrors = resp?.errors;
84
+ if (gqlErrors?.length) {
85
+ logger.warn({ gameName, slug, gqlErrors }, "GameDirectory GQL errors");
86
+ }
87
+ const aclBased = false;
88
+ const list = parseGameDirectoryResponse(response, gameName, aclBased);
89
+ for (const ch of list) {
90
+ const existing = byId.get(ch.id);
91
+ const acl = aclIds.has(ch.id);
92
+ const merged = {
93
+ ...ch,
94
+ aclBased: existing?.aclBased ?? acl
95
+ };
96
+ if (!existing || merged.viewers > existing.viewers) {
97
+ byId.set(ch.id, merged);
98
+ }
99
+ }
100
+ }
101
+ let result = Array.from(byId.values());
102
+ result = result.filter((ch) => canWatchChannel(ch, wantedGames));
103
+ result = sortChannelCandidates(result, wantedGames);
104
+ return result.slice(0, maxChannels);
105
+ }
@@ -0,0 +1,12 @@
1
+ export const TWITCH_OAUTH_DEVICE_URL = "https://id.twitch.tv/oauth2/device";
2
+ export const TWITCH_OAUTH_TOKEN_URL = "https://id.twitch.tv/oauth2/token";
3
+ export const TWITCH_OAUTH_VALIDATE_URL = "https://id.twitch.tv/oauth2/validate";
4
+ export const TWITCH_GQL_URL = "https://gql.twitch.tv/gql";
5
+ export const TWITCH_PUBSUB_URL = "wss://pubsub-edge.twitch.tv/v1";
6
+ export const WATCH_INTERVAL_MS = 59_000;
7
+ export const PING_INTERVAL_MS = 180_000;
8
+ export const PING_TIMEOUT_MS = 10_000;
9
+ export const MAX_WEBSOCKETS = 8;
10
+ export const WS_TOPICS_LIMIT = 50;
11
+ export const TWITCH_ANDROID_CLIENT_ID = "kd1unb4b3q4t58fwlpcbzcbnm76a8fp";
12
+ export const TWITCH_ANDROID_USER_AGENT = "Dalvik/2.1.0 (Linux; Android 16; SM-S911B Build/TP1A.220624.014) tv.twitch.android.app/25.3.0/2503006";
@@ -0,0 +1,15 @@
1
+ export class MaintenanceScheduler {
2
+ timer = null;
3
+ start(intervalMs, callback) {
4
+ this.stop();
5
+ this.timer = setInterval(() => {
6
+ void callback();
7
+ }, intervalMs);
8
+ }
9
+ stop() {
10
+ if (this.timer) {
11
+ clearInterval(this.timer);
12
+ this.timer = null;
13
+ }
14
+ }
15
+ }