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
package/README.md ADDED
@@ -0,0 +1,153 @@
1
+ # TwitchDropsMiner-CLI
2
+
3
+ Headless, npm-installable CLI rewrite of [TwitchDropsMiner](https://github.com/DevilXD/TwitchDropsMiner) for Linux server operation.
4
+
5
+ **Based on:** [DevilXD/TwitchDropsMiner](https://github.com/DevilXD/TwitchDropsMiner) — the original Python/GUI app that AFK mines timed Twitch drops with automatic claiming and channel switching. This CLI reimplements the same behavior (GQL, spade, PubSub, priority lists) for headless and server use.
6
+
7
+ ## Install
8
+
9
+ **Global install from npm** (recommended; puts `tdm` on your PATH):
10
+
11
+ ```bash
12
+ npm install -g twitchdropsminer-cli
13
+ tdm doctor
14
+ ```
15
+
16
+ **Alternative: global install from GitHub** (equivalent CLI, installs directly from this repo):
17
+
18
+ ```bash
19
+ npm install -g github:vocino/TwitchDropsMiner-CLI
20
+ tdm doctor
21
+ ```
22
+
23
+ **Run from project** (no global install): from the repo root run `npm install`, `npm run build`, then use `npx tdm`:
24
+
25
+ ```bash
26
+ npm install && npm run build
27
+ npx tdm run --dry-run --verbose
28
+ npx tdm status --json
29
+ ```
30
+
31
+ ## First-time setup
32
+
33
+ 1. **Log in** (headless-friendly device code; no browser on the server):
34
+
35
+ ```bash
36
+ tdm auth login --no-open
37
+ ```
38
+ Visit the printed URL on another device, enter the code, then:
39
+
40
+ ```bash
41
+ tdm auth validate
42
+ ```
43
+
44
+ 2. **Choose which games to mine** – the miner only watches games you list. List campaigns Twitch shows for your account:
45
+
46
+ ```bash
47
+ tdm games
48
+ ```
49
+ Copy the exact **game name** from the list (first column). Add one to your priority list:
50
+
51
+ ```bash
52
+ tdm games --add "Exact Game Name"
53
+ ```
54
+ Or set the full list manually (config file path: `tdm config path`):
55
+
56
+ ```bash
57
+ tdm config set priority '["Game One", "Game Two"]'
58
+ ```
59
+
60
+ 3. **Run the miner**:
61
+
62
+ ```bash
63
+ tdm run
64
+ tdm run --verbose
65
+ ```
66
+
67
+ **Config file:** `~/.config/tdm/config.json` (or run `tdm config path` to print it). The file is created on first use; you can edit it directly or use `tdm config set <key> <value>` and `tdm config get`.
68
+
69
+ ## Headless authentication
70
+
71
+ ```bash
72
+ tdm auth login --no-open
73
+ tdm auth validate
74
+ ```
75
+
76
+ Alternative imports:
77
+
78
+ ```bash
79
+ tdm auth import --token-file /secure/path/token.txt
80
+ tdm auth import-cookie --cookie-file /secure/path/cookies.txt
81
+ ```
82
+
83
+ ## Choosing which games to mine
84
+
85
+ - **List available games** (from Twitch, for your account):
86
+
87
+ ```bash
88
+ tdm games
89
+ tdm games --json
90
+ ```
91
+
92
+ - **Add a game to your priority list:** `tdm games --add "Exact Game Name"` (uses exact name from `tdm games`).
93
+
94
+ - **Set priority manually:** `tdm config set priority '["Game A", "Game B"]'`. Use exact game names from `tdm games`.
95
+
96
+ - **Config location:** `tdm config path` prints the path (e.g. `~/.config/tdm/config.json`). Options: `priority`, `exclude`, `priorityMode` (`priority_only` | `ending_soonest` | `low_avbl_first`), `enableBadgesEmotes`. See `docs/ops/drops-validation.md`.
97
+
98
+ If you never set `priority`, the miner will have no “wanted games” and will not watch any channel. Link game accounts at [twitch.tv/drops/campaigns](https://www.twitch.tv/drops/campaigns) so more games appear in `tdm games`.
99
+
100
+ ## Run miner
101
+
102
+ ```bash
103
+ tdm run
104
+ tdm run --verbose
105
+ tdm run --dry-run --verbose # log actions only; no spade/claim network writes
106
+ ```
107
+
108
+ ### Stopping the miner
109
+
110
+ Stop it gracefully so the lock file is removed automatically:
111
+
112
+ - **In a terminal:** **Ctrl+C** (Windows or Linux/macOS). The miner handles SIGINT, shuts down, and exits; the lock is cleared on exit.
113
+ - **As a systemd service:** `tdm service stop` or `systemctl --user stop tdm` (sends SIGTERM; same clean shutdown).
114
+
115
+ You only need to [remove the lock file manually](#troubleshooting) if the process was **force-killed** (e.g. kill -9), **crashed**, or the machine lost power—cases where the process never got to run its exit handler.
116
+
117
+ ### How it mines drops
118
+
119
+ 1. **Inventory** – Fetches your in-progress campaigns and drop state via Twitch GQL.
120
+ 2. **Wanted games** – From config `priority`, `exclude`, and `priorityMode` (e.g. `priority_only`, `ending_soonest`).
121
+ 3. **Channels** – Fetches live channels per game (GameDirectory GQL), filters by drops-enabled and wanted game, orders by priority and viewers.
122
+ 4. **Watch simulation** – Sends “minute-watched” beacons to Twitch’s spade endpoint for the selected channel (no video stream).
123
+ 5. **Progress** – PubSub user-drop-events and optional CurrentDrop GQL keep drop minutes in sync; stream-state topics trigger channel refresh.
124
+ 6. **Claims** – Eligible drops are claimed automatically via ClaimDrop GQL (24h post-campaign window).
125
+ 7. **Maintenance** – Hourly inventory refresh and campaign time triggers (start/end) drive channel cleanup and re-fetch.
126
+
127
+ ## Service mode
128
+
129
+ ```bash
130
+ tdm service install --user --autostart
131
+ tdm service start
132
+ tdm service status
133
+ tdm logs --follow
134
+ ```
135
+
136
+ More ops docs:
137
+
138
+ - `docs/ops/linux-install.md`
139
+ - `docs/ops/authentication.md`
140
+ - `docs/ops/service-management.md`
141
+ - `docs/ops/systemd-hardening.md`
142
+ - `docs/ops/drops-validation.md` – validate drops progression and claims
143
+
144
+ ### Troubleshooting
145
+
146
+ - **"Another tdm instance appears to be running"** – Only one miner can run at a time (lock file). If the previous run was **force-killed**, **crashed**, or didn’t exit cleanly, remove the lock and try again:
147
+ **Windows:** delete `%USERPROFILE%\.local\state\tdm\lock.file`
148
+ **Linux/macOS:** `rm -f ~/.local/state/tdm/lock.file`
149
+
150
+ ## Credits
151
+
152
+ This CLI is based on [**TwitchDropsMiner**](https://github.com/DevilXD/TwitchDropsMiner) by [DevilXD](https://github.com/DevilXD) — the original desktop app that mines Twitch drops without streaming video. TwitchDropsMiner-CLI reimplements its behavior (inventory, spade beacons, PubSub, game priority, auto-claim) for headless and server use.
153
+
@@ -0,0 +1,7 @@
1
+ export function normalizeCookieHeader(input) {
2
+ const header = input.trim();
3
+ if (!header) {
4
+ throw new Error("Empty cookie header.");
5
+ }
6
+ return { rawHeader: header };
7
+ }
@@ -0,0 +1,61 @@
1
+ import { TWITCH_ANDROID_CLIENT_ID, TWITCH_ANDROID_USER_AGENT, TWITCH_OAUTH_DEVICE_URL, TWITCH_OAUTH_TOKEN_URL } from "../core/constants.js";
2
+ import { request } from "undici";
3
+ function sleep(ms) {
4
+ return new Promise((resolve) => setTimeout(resolve, ms));
5
+ }
6
+ export async function startDeviceAuth() {
7
+ // Twitch's device endpoint expects form-encoded parameters, not JSON.
8
+ const body = new URLSearchParams({
9
+ client_id: TWITCH_ANDROID_CLIENT_ID,
10
+ scope: ""
11
+ }).toString();
12
+ const resp = await request(TWITCH_OAUTH_DEVICE_URL, {
13
+ method: "POST",
14
+ body,
15
+ headers: {
16
+ "Content-Type": "application/x-www-form-urlencoded",
17
+ "Client-Id": TWITCH_ANDROID_CLIENT_ID,
18
+ "User-Agent": TWITCH_ANDROID_USER_AGENT
19
+ }
20
+ });
21
+ const text = await resp.body.text();
22
+ const response = JSON.parse(text);
23
+ return {
24
+ deviceCode: response.device_code,
25
+ userCode: response.user_code,
26
+ verificationUri: response.verification_uri,
27
+ interval: response.interval,
28
+ expiresIn: response.expires_in
29
+ };
30
+ }
31
+ export async function pollDeviceToken(start) {
32
+ const expiresAt = Date.now() + start.expiresIn * 1000;
33
+ while (Date.now() < expiresAt) {
34
+ await sleep(start.interval * 1000);
35
+ try {
36
+ const body = new URLSearchParams({
37
+ client_id: TWITCH_ANDROID_CLIENT_ID,
38
+ device_code: start.deviceCode,
39
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code"
40
+ }).toString();
41
+ const resp = await request(TWITCH_OAUTH_TOKEN_URL, {
42
+ method: "POST",
43
+ body,
44
+ headers: {
45
+ "Content-Type": "application/x-www-form-urlencoded",
46
+ "Client-Id": TWITCH_ANDROID_CLIENT_ID,
47
+ "User-Agent": TWITCH_ANDROID_USER_AGENT
48
+ }
49
+ });
50
+ const text = await resp.body.text();
51
+ const tokenResp = JSON.parse(text);
52
+ if (tokenResp.access_token) {
53
+ return tokenResp.access_token;
54
+ }
55
+ }
56
+ catch {
57
+ // expected while pending authorization
58
+ }
59
+ }
60
+ throw new Error("Device authorization timed out.");
61
+ }
@@ -0,0 +1,30 @@
1
+ import { loadAuthState, saveAuthState } from "../state/authStore.js";
2
+ import { httpJson } from "../integrations/httpClient.js";
3
+ import { TWITCH_OAUTH_VALIDATE_URL } from "../core/constants.js";
4
+ import { parseTokenInput } from "./tokenImport.js";
5
+ export class SessionManager {
6
+ getAccessToken() {
7
+ const state = loadAuthState();
8
+ return state?.accessToken ?? null;
9
+ }
10
+ setAccessToken(rawTokenInput) {
11
+ const token = parseTokenInput(rawTokenInput).accessToken;
12
+ const prev = loadAuthState() ?? { updatedAt: new Date().toISOString() };
13
+ saveAuthState({
14
+ ...prev,
15
+ accessToken: token
16
+ });
17
+ }
18
+ async validateAccessToken(token) {
19
+ const accessToken = token ?? this.getAccessToken();
20
+ if (!accessToken) {
21
+ throw new Error("No access token available.");
22
+ }
23
+ return httpJson("GET", TWITCH_OAUTH_VALIDATE_URL, undefined, {
24
+ retries: 1,
25
+ headers: {
26
+ Authorization: `OAuth ${accessToken}`
27
+ }
28
+ });
29
+ }
30
+ }
@@ -0,0 +1,21 @@
1
+ export function parseTokenInput(input) {
2
+ const trimmed = input.trim();
3
+ if (!trimmed) {
4
+ throw new Error("Empty token input.");
5
+ }
6
+ const eqIndex = trimmed.indexOf("=");
7
+ if (eqIndex === -1) {
8
+ // raw OAuth token
9
+ return { accessToken: trimmed, source: "raw" };
10
+ }
11
+ const key = trimmed.slice(0, eqIndex).trim().toLowerCase();
12
+ const value = trimmed.slice(eqIndex + 1).trim();
13
+ if (!value) {
14
+ throw new Error("Token value is empty.");
15
+ }
16
+ if (key === "auth-token") {
17
+ return { accessToken: value, source: "auth-token" };
18
+ }
19
+ // Unknown key, but still treat as token pair
20
+ return { accessToken: value, source: "raw" };
21
+ }
@@ -0,0 +1,29 @@
1
+ import { loadAuthState } from "../state/authStore.js";
2
+ import { SessionManager } from "./sessionManager.js";
3
+ export function validateAuthLocally() {
4
+ const state = loadAuthState();
5
+ if (!state) {
6
+ return { hasToken: false, hasCookies: false };
7
+ }
8
+ return {
9
+ hasToken: !!state.accessToken,
10
+ hasCookies: !!state.cookiesHeader
11
+ };
12
+ }
13
+ export async function validateAuthRemote() {
14
+ try {
15
+ const session = new SessionManager();
16
+ const validateResponse = await session.validateAccessToken();
17
+ return {
18
+ valid: true,
19
+ userId: validateResponse.user_id,
20
+ clientId: validateResponse.client_id
21
+ };
22
+ }
23
+ catch (err) {
24
+ return {
25
+ valid: false,
26
+ error: err instanceof Error ? err.message : String(err)
27
+ };
28
+ }
29
+ }
@@ -0,0 +1,172 @@
1
+ import { Command } from "@commander-js/extra-typings";
2
+ import fs from "node:fs";
3
+ import { parseTokenInput } from "../../auth/tokenImport.js";
4
+ import { normalizeCookieHeader } from "../../auth/cookieImport.js";
5
+ import { loadAuthState, saveAuthState } from "../../state/authStore.js";
6
+ import { validateAuthLocally, validateAuthRemote } from "../../auth/validate.js";
7
+ import { startDeviceAuth, pollDeviceToken } from "../../auth/deviceAuth.js";
8
+ export const authCommand = new Command("auth").description("Authentication commands");
9
+ const loginCommand = new Command("login")
10
+ .description("Login using device-code flow (headless-friendly)")
11
+ .option("--no-open", "Do not attempt to open a browser, print URL and code only", true)
12
+ .action(async (opts) => {
13
+ const start = await startDeviceAuth();
14
+ // eslint-disable-next-line no-console
15
+ console.log(`verification_uri=${start.verificationUri}`);
16
+ // eslint-disable-next-line no-console
17
+ console.log(`user_code=${start.userCode}`);
18
+ // eslint-disable-next-line no-console
19
+ console.log(`interval=${start.interval}`);
20
+ // eslint-disable-next-line no-console
21
+ console.log(`expires_in=${start.expiresIn}`);
22
+ if (opts.open) {
23
+ // Intentionally no local browser launch for server safety.
24
+ }
25
+ const accessToken = await pollDeviceToken(start);
26
+ const prev = loadAuthState() || { updatedAt: new Date().toISOString() };
27
+ saveAuthState({
28
+ ...prev,
29
+ accessToken
30
+ });
31
+ // eslint-disable-next-line no-console
32
+ console.log("Device authentication completed and token stored.");
33
+ });
34
+ const importTokenCommand = new Command("import")
35
+ .description("Import an existing OAuth token")
36
+ .option("--token <token>", "Raw token or auth-token=<value> pair")
37
+ .option("--token-file <path>", "Path to a file containing the token")
38
+ .action(async (opts) => {
39
+ let raw = opts.token;
40
+ if (!raw && opts.tokenFile) {
41
+ raw = fs.readFileSync(opts.tokenFile, "utf8");
42
+ }
43
+ if (!raw) {
44
+ // eslint-disable-next-line no-console
45
+ console.error("No token provided. Use --token or --token-file.");
46
+ process.exitCode = 1;
47
+ return;
48
+ }
49
+ try {
50
+ const imported = parseTokenInput(raw);
51
+ const prev = loadAuthState() || { updatedAt: new Date().toISOString() };
52
+ saveAuthState({
53
+ ...prev,
54
+ accessToken: imported.accessToken
55
+ });
56
+ // eslint-disable-next-line no-console
57
+ console.log("Token imported successfully.");
58
+ }
59
+ catch (err) {
60
+ // eslint-disable-next-line no-console
61
+ console.error(`Failed to import token: ${err.message}`);
62
+ process.exitCode = 1;
63
+ }
64
+ });
65
+ const importCookieCommand = new Command("import-cookie")
66
+ .description("Import cookies (auth-token and related) from a header string or file")
67
+ .option("--cookie <header>", "Cookie header string")
68
+ .option("--cookie-file <path>", "Path to a Netscape cookie file or header text file")
69
+ .action(async (opts) => {
70
+ let header = opts.cookie;
71
+ if (!header && opts.cookieFile) {
72
+ header = fs.readFileSync(opts.cookieFile, "utf8");
73
+ }
74
+ if (!header) {
75
+ // eslint-disable-next-line no-console
76
+ console.error("No cookies provided. Use --cookie or --cookie-file.");
77
+ process.exitCode = 1;
78
+ return;
79
+ }
80
+ try {
81
+ const imported = normalizeCookieHeader(header);
82
+ const prev = loadAuthState() || { updatedAt: new Date().toISOString() };
83
+ saveAuthState({
84
+ ...prev,
85
+ cookiesHeader: imported.rawHeader
86
+ });
87
+ // eslint-disable-next-line no-console
88
+ console.log("Cookies imported successfully.");
89
+ }
90
+ catch (err) {
91
+ // eslint-disable-next-line no-console
92
+ console.error(`Failed to import cookies: ${err.message}`);
93
+ process.exitCode = 1;
94
+ }
95
+ });
96
+ const exportCommand = new Command("export")
97
+ .description("Export current auth material in a machine-readable format")
98
+ .option("--format <fmt>", "Output format: env|json", "env")
99
+ .option("--show-secrets", "Include raw secrets in output (use with care)", false)
100
+ .action(async (opts) => {
101
+ const state = loadAuthState();
102
+ if (!state) {
103
+ // eslint-disable-next-line no-console
104
+ console.error("No auth state found.");
105
+ process.exitCode = 1;
106
+ return;
107
+ }
108
+ const tokenValue = opts.showSecrets ? state.accessToken ?? "" : "<redacted>";
109
+ const cookieValue = opts.showSecrets ? state.cookiesHeader ?? "" : "<redacted>";
110
+ if (opts.format === "json") {
111
+ // eslint-disable-next-line no-console
112
+ console.log(JSON.stringify({
113
+ accessToken: tokenValue,
114
+ cookiesHeader: cookieValue,
115
+ updatedAt: state.updatedAt
116
+ }, null, 2));
117
+ }
118
+ else {
119
+ // env format
120
+ // eslint-disable-next-line no-console
121
+ console.log(`TDM_ACCESS_TOKEN=${tokenValue}`);
122
+ // eslint-disable-next-line no-console
123
+ console.log(`TDM_COOKIES_HEADER=${cookieValue}`);
124
+ }
125
+ });
126
+ const validateCommand = new Command("validate")
127
+ .description("Validate auth material presence and token validity")
128
+ .option("--local-only", "Only check local presence without remote Twitch validation", false)
129
+ .action(async (opts) => {
130
+ const result = validateAuthLocally();
131
+ if (!result.hasToken && !result.hasCookies) {
132
+ // eslint-disable-next-line no-console
133
+ console.error("No auth material found (token or cookies).");
134
+ process.exitCode = 1;
135
+ return;
136
+ }
137
+ // eslint-disable-next-line no-console
138
+ console.log(`Auth state: token=${result.hasToken ? "present" : "missing"}, cookies=${result.hasCookies ? "present" : "missing"}`);
139
+ if (!opts.localOnly) {
140
+ const remote = await validateAuthRemote();
141
+ if (!remote.valid) {
142
+ // eslint-disable-next-line no-console
143
+ console.error(`Remote token validation failed: ${remote.error ?? "unknown error"}`);
144
+ process.exitCode = 1;
145
+ return;
146
+ }
147
+ // eslint-disable-next-line no-console
148
+ console.log(`Remote token validation OK for user ${remote.userId}.`);
149
+ }
150
+ });
151
+ const logoutCommand = new Command("logout").description("Clear saved auth material").action(() => {
152
+ const state = loadAuthState();
153
+ if (!state) {
154
+ // eslint-disable-next-line no-console
155
+ console.log("No auth state to clear.");
156
+ return;
157
+ }
158
+ // Overwrite with empty values but keep file structure.
159
+ saveAuthState({
160
+ accessToken: undefined,
161
+ cookiesHeader: undefined,
162
+ updatedAt: new Date().toISOString()
163
+ });
164
+ // eslint-disable-next-line no-console
165
+ console.log("Auth state cleared.");
166
+ });
167
+ authCommand.addCommand(loginCommand);
168
+ authCommand.addCommand(importTokenCommand);
169
+ authCommand.addCommand(importCookieCommand);
170
+ authCommand.addCommand(exportCommand);
171
+ authCommand.addCommand(validateCommand);
172
+ authCommand.addCommand(logoutCommand);
@@ -0,0 +1,51 @@
1
+ import { Command } from "@commander-js/extra-typings";
2
+ import { loadConfig, saveConfig, configPath } from "../../config/store.js";
3
+ import { ConfigSchema } from "../../config/schema.js";
4
+ export const configCommand = new Command("config").description("Manage TwitchDropsMiner CLI configuration");
5
+ const getCommand = new Command("get")
6
+ .argument("[key]", "Configuration key")
7
+ .action(async (key) => {
8
+ const cfg = loadConfig();
9
+ if (!key) {
10
+ // eslint-disable-next-line no-console
11
+ console.log(JSON.stringify(cfg, null, 2));
12
+ }
13
+ else {
14
+ const value = cfg[key];
15
+ // eslint-disable-next-line no-console
16
+ console.log(typeof value === "undefined" ? "" : JSON.stringify(value));
17
+ }
18
+ });
19
+ const setCommand = new Command("set")
20
+ .argument("<key>", "Configuration key")
21
+ .argument("<value>", "Configuration value")
22
+ .action(async (key, value) => {
23
+ const cfg = loadConfig();
24
+ let parsedValue = value;
25
+ try {
26
+ parsedValue = JSON.parse(value);
27
+ }
28
+ catch {
29
+ parsedValue = value;
30
+ }
31
+ cfg[key] = parsedValue;
32
+ const validated = ConfigSchema.parse(cfg);
33
+ saveConfig(validated);
34
+ // eslint-disable-next-line no-console
35
+ console.log(`Saved ${key}.`);
36
+ });
37
+ const validateCommand = new Command("validate").action(async () => {
38
+ ConfigSchema.parse(loadConfig());
39
+ // eslint-disable-next-line no-console
40
+ console.log("Config is valid.");
41
+ });
42
+ const pathCommand = new Command("path")
43
+ .description("Print config file path (create dir if missing)")
44
+ .action(() => {
45
+ // eslint-disable-next-line no-console
46
+ console.log(configPath());
47
+ });
48
+ configCommand.addCommand(getCommand);
49
+ configCommand.addCommand(setCommand);
50
+ configCommand.addCommand(validateCommand);
51
+ configCommand.addCommand(pathCommand);
@@ -0,0 +1,49 @@
1
+ import { Command } from "@commander-js/extra-typings";
2
+ import os from "node:os";
3
+ import { request } from "undici";
4
+ import { EXIT_ENV_UNSUPPORTED, EXIT_OK } from "../contracts/exitCodes.js";
5
+ export const doctorCommand = new Command("doctor")
6
+ .description("Run environment checks for TwitchDropsMiner CLI")
7
+ .action(async () => {
8
+ // Basic checks for now; can be extended later.
9
+ const issues = [];
10
+ const platform = os.platform();
11
+ if (platform !== "linux" && platform !== "darwin" && platform !== "win32") {
12
+ issues.push(`Unsupported platform: ${platform}`);
13
+ }
14
+ // Node version check is effectively enforced via package.json engines,
15
+ // but we can still surface it here.
16
+ const [majorStr] = process.versions.node.split(".");
17
+ const major = Number(majorStr);
18
+ if (!Number.isNaN(major) && major < 20) {
19
+ issues.push(`Node.js version ${process.versions.node} is below the required >=20.`);
20
+ }
21
+ if (issues.length > 0) {
22
+ for (const msg of issues) {
23
+ // eslint-disable-next-line no-console
24
+ console.error(msg);
25
+ }
26
+ process.exitCode = EXIT_ENV_UNSUPPORTED;
27
+ return;
28
+ }
29
+ try {
30
+ const res = await request("https://id.twitch.tv/oauth2/validate", { method: "GET" });
31
+ if (res.statusCode >= 500) {
32
+ issues.push(`Twitch endpoint returned ${res.statusCode}.`);
33
+ }
34
+ }
35
+ catch (err) {
36
+ issues.push(`Network reachability check failed: ${err.message}`);
37
+ }
38
+ if (issues.length > 0) {
39
+ for (const msg of issues) {
40
+ // eslint-disable-next-line no-console
41
+ console.error(msg);
42
+ }
43
+ process.exitCode = EXIT_ENV_UNSUPPORTED;
44
+ return;
45
+ }
46
+ // eslint-disable-next-line no-console
47
+ console.log("Environment looks OK for TwitchDropsMiner CLI.");
48
+ process.exitCode = EXIT_OK;
49
+ });
@@ -0,0 +1,79 @@
1
+ import { Command } from "@commander-js/extra-typings";
2
+ import { SessionManager } from "../../auth/sessionManager.js";
3
+ import { GQL_OPERATIONS } from "../../integrations/gqlOperations.js";
4
+ import { gqlRequest } from "../../integrations/gqlClient.js";
5
+ import { buildInventoryFromGqlResponses } from "../../domain/inventory.js";
6
+ import { loadConfig, saveConfig } from "../../config/store.js";
7
+ export const gamesCommand = new Command("games")
8
+ .description("List available drop campaigns/games from Twitch (use these names in priority)")
9
+ .option("--json", "Output as JSON")
10
+ .option("--add <gameName>", "Add a game name to config priority and save")
11
+ .action(async (opts) => {
12
+ const session = new SessionManager();
13
+ const token = session.getAccessToken();
14
+ if (!token) {
15
+ // eslint-disable-next-line no-console
16
+ console.error("Not logged in. Run: tdm auth login --no-open");
17
+ process.exitCode = 1;
18
+ return;
19
+ }
20
+ await session.validateAccessToken(token);
21
+ const [inventoryResponse, campaignsResponse] = await Promise.all([
22
+ gqlRequest(GQL_OPERATIONS.Inventory, token),
23
+ gqlRequest(GQL_OPERATIONS.Campaigns, token)
24
+ ]);
25
+ const cfg = loadConfig();
26
+ const built = buildInventoryFromGqlResponses(inventoryResponse, campaignsResponse, { enableBadgesEmotes: cfg.enableBadgesEmotes });
27
+ const rows = built.campaigns.map((c) => ({
28
+ gameName: c.gameName,
29
+ campaignName: c.name,
30
+ status: c.active ? "active" : c.upcoming ? "upcoming" : "expired",
31
+ eligible: c.eligible
32
+ }));
33
+ if (opts.add) {
34
+ const name = String(opts.add).trim();
35
+ const match = built.campaigns.find((c) => c.gameName === name || c.gameName.toLowerCase() === name.toLowerCase());
36
+ if (!match) {
37
+ // eslint-disable-next-line no-console
38
+ console.error(`No campaign found for "${name}". Use exact game name from list (e.g. tdm games).`);
39
+ process.exitCode = 1;
40
+ return;
41
+ }
42
+ const next = loadConfig();
43
+ const priority = [...next.priority];
44
+ if (!priority.includes(match.gameName)) {
45
+ priority.push(match.gameName);
46
+ saveConfig({ ...next, priority });
47
+ // eslint-disable-next-line no-console
48
+ console.log(`Added "${match.gameName}" to priority. Current priority: ${JSON.stringify(priority)}`);
49
+ }
50
+ else {
51
+ // eslint-disable-next-line no-console
52
+ console.log(`"${match.gameName}" already in priority.`);
53
+ }
54
+ return;
55
+ }
56
+ if (opts.json) {
57
+ // eslint-disable-next-line no-console
58
+ console.log(JSON.stringify(rows, null, 2));
59
+ return;
60
+ }
61
+ if (rows.length === 0) {
62
+ // eslint-disable-next-line no-console
63
+ console.log("No drop campaigns found. Link game accounts at https://www.twitch.tv/drops/campaigns");
64
+ return;
65
+ }
66
+ // eslint-disable-next-line no-console
67
+ console.log("Available games (use exact gameName in: tdm config set priority '[\"Game Name\"]')");
68
+ // eslint-disable-next-line no-console
69
+ console.log("---");
70
+ for (const r of rows) {
71
+ const elig = r.eligible ? "eligible" : "not-eligible";
72
+ // eslint-disable-next-line no-console
73
+ console.log(`${r.gameName}\t${r.status}\t${elig}\t# ${r.campaignName}`);
74
+ }
75
+ // eslint-disable-next-line no-console
76
+ console.log("---");
77
+ // eslint-disable-next-line no-console
78
+ console.log("To mine a game: tdm config set priority '[\"" + rows[0].gameName + "\"]' (or use --add)");
79
+ });