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.
- package/README.md +153 -0
- package/dist/auth/cookieImport.js +7 -0
- package/dist/auth/deviceAuth.js +61 -0
- package/dist/auth/sessionManager.js +30 -0
- package/dist/auth/tokenImport.js +21 -0
- package/dist/auth/validate.js +29 -0
- package/dist/cli/commands/auth.js +172 -0
- package/dist/cli/commands/config.js +51 -0
- package/dist/cli/commands/doctor.js +49 -0
- package/dist/cli/commands/games.js +79 -0
- package/dist/cli/commands/healthcheck.js +25 -0
- package/dist/cli/commands/logs.js +14 -0
- package/dist/cli/commands/run.js +20 -0
- package/dist/cli/commands/service.js +85 -0
- package/dist/cli/commands/status.js +29 -0
- package/dist/cli/contracts/exitCodes.js +7 -0
- package/dist/cli/index.js +36 -0
- package/dist/config/schema.js +16 -0
- package/dist/config/store.js +29 -0
- package/dist/core/channelService.js +105 -0
- package/dist/core/constants.js +12 -0
- package/dist/core/maintenance.js +15 -0
- package/dist/core/miner.js +366 -0
- package/dist/core/runtime.js +34 -0
- package/dist/core/stateMachine.js +9 -0
- package/dist/core/watchLoop.js +26 -0
- package/dist/domain/channel.js +31 -0
- package/dist/domain/inventory.js +370 -0
- package/dist/integrations/gqlClient.js +13 -0
- package/dist/integrations/gqlOperations.js +42 -0
- package/dist/integrations/httpClient.js +37 -0
- package/dist/integrations/twitchPubSub.js +126 -0
- package/dist/integrations/twitchSpade.js +112 -0
- package/dist/ops/systemd.js +63 -0
- package/dist/state/authStore.js +38 -0
- package/dist/state/cookieStore.js +21 -0
- package/dist/state/sessionState.js +26 -0
- package/dist/tests/index.js +7 -0
- package/dist/tests/integration/configStore.test.js +8 -0
- package/dist/tests/parity/stateMachineFlow.test.js +14 -0
- package/dist/tests/unit/channel.test.js +73 -0
- package/dist/tests/unit/channelService.test.js +41 -0
- package/dist/tests/unit/dropsDomain.test.js +57 -0
- package/dist/tests/unit/tokenImport.test.js +13 -0
- package/dist/tests/unit/twitchSpade.test.js +24 -0
- package/docs/ops/authentication.md +32 -0
- package/docs/ops/drops-validation.md +73 -0
- package/docs/ops/linux-install.md +15 -0
- package/docs/ops/service-management.md +23 -0
- package/docs/ops/systemd-hardening.md +13 -0
- package/package.json +41 -0
- 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,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
|
+
});
|