@louisraetz/steamidled 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 louisraetz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,154 @@
1
+ # steamidled 🎮💤
2
+
3
+ Steam game idler CLI and daemon. Rack up playtime while you touch grass. Run up to 32 games simultaneously, collect trading cards, and finally look like you have no life (in a good way).
4
+
5
+ ## ✨ Why does this exist?
6
+
7
+ This is a hobby project, born from a weekend of vibe coding and questionable life choices. I wanted more hours in games I'll never actually play, and here we are.
8
+
9
+ Built with love, caffeine, and Claude. No regrets. 🤖☕
10
+
11
+ ## 🚀 Features
12
+
13
+ - **📱 QR Code Login** — Scan with Steam mobile app
14
+ - **🔐 Traditional Login** — Username/password with Steam Guard (for the old school folks)
15
+ - **🎯 Idle up to 32 games** — Because Steam said that's the limit and who are we to argue
16
+ - **📊 Real-time tracking** — Watch numbers go up. Dopamine achieved.
17
+ - **⭐ Favorites system** — Star your favorites so you can pretend you're organized
18
+ - **⏸️ Auto-pause** — Automatically pauses when you actually play a game (rare occurrence)
19
+ - **💾 Persistent sessions** — Remembers your login so you don't have to
20
+
21
+ ## 📦 Installation
22
+
23
+ ### npm
24
+
25
+ ```bash
26
+ npm install -g @louisraetz/steamidled
27
+ ```
28
+
29
+ ### Homebrew 🍺
30
+
31
+ ```bash
32
+ brew tap louisraetz/steamidled
33
+ brew install steamidled
34
+ ```
35
+
36
+ ## 🎮 Usage
37
+
38
+ ```bash
39
+ steamidled # Interactive mode
40
+ steamidled --headless # Headless mode (auto-start favorites)
41
+ ```
42
+
43
+ ### Interactive mode
44
+
45
+ 1. **Pick your login method** — QR code is chef's kiss 👨‍🍳💋
46
+ 2. **Select your games** — Go wild, pick all of them, I won't judge
47
+ 3. **Watch the hours roll in** — This is what peak productivity looks like
48
+
49
+ ### Headless mode
50
+
51
+ Automatically logs in and starts idling your favorited games. Perfect for running as a service. Requires running interactively first to log in and set up favorites.
52
+
53
+ ## ⌨️ Controls
54
+
55
+ ### Game Selection
56
+
57
+ | Key | What it does |
58
+ |-----|--------------|
59
+ | `↑` `↓` | Navigate (you got this) |
60
+ | `Space` | Toggle game on/off |
61
+ | `F` | Favorite a game ⭐ |
62
+ | `S` | Start all favorites |
63
+ | `Enter` | Let's gooo 🚀 |
64
+
65
+ ### While Idling
66
+
67
+ | Key | What it does |
68
+ |-----|--------------|
69
+ | `E` | Edit your selection (changed your mind?) |
70
+ | `Q` | Quit gracefully (like a gentleman) |
71
+ | `Ctrl+C` | Rage quit |
72
+
73
+ ## 🐧 Running 24/7 on Linux
74
+
75
+ Want to idle games while you sleep? Same. Run it as a systemd service with `--headless` mode.
76
+
77
+ ### Prerequisites
78
+
79
+ 1. Run the tool interactively once to log in and set up favorites
80
+ 2. Find where it lives: `which steamidled`
81
+
82
+ ### Create the Service
83
+
84
+ ```bash
85
+ sudo nano /etc/systemd/system/steamidled.service
86
+ ```
87
+
88
+ ```ini
89
+ [Unit]
90
+ Description=steamidled
91
+ After=network-online.target
92
+ Wants=network-online.target
93
+
94
+ [Service]
95
+ Type=simple
96
+ User=YOUR_USERNAME
97
+ ExecStart=/usr/bin/steamidled --headless
98
+ Restart=on-failure
99
+ RestartSec=10
100
+
101
+ [Install]
102
+ WantedBy=multi-user.target
103
+ ```
104
+
105
+ Replace `YOUR_USERNAME` with your username.
106
+
107
+ ### Fire it up
108
+
109
+ ```bash
110
+ sudo systemctl daemon-reload
111
+ sudo systemctl enable steamidled
112
+ sudo systemctl start steamidled
113
+ ```
114
+
115
+ ### Useful commands
116
+
117
+ ```bash
118
+ sudo systemctl status steamidled # Is it alive?
119
+ journalctl -u steamidled -f # What's it thinking?
120
+ sudo systemctl stop steamidled # Take a break
121
+ sudo systemctl restart steamidled # Turn it off and on again
122
+ ```
123
+
124
+ ## 📁 Where's my stuff?
125
+
126
+ Everything lives in `~/.steam-idler/`:
127
+
128
+ ```
129
+ ~/.steam-idler/
130
+ ├── credentials.json # Your login token (keep it secret 🤫)
131
+ └── favorites-{username}.json # Your favorite games
132
+ ```
133
+
134
+ ## 📋 Requirements
135
+
136
+ - Node.js 18+ (we're modern here)
137
+ - A Steam account with games (shocking, I know)
138
+ - Steam mobile app (optional, but makes login ✨fancy✨)
139
+
140
+ ## 🤝 Contributing
141
+
142
+ Found a bug? Have an idea? PRs welcome! This is a hobby project so I might be slow, but I appreciate you. 💙
143
+
144
+ ## ⚠️ Disclaimer
145
+
146
+ This is just a fun side project. Use responsibly. I'm not responsible if Valve gets mad at you or whatever. Probably don't idle 10,000 hours in a game you've never launched. Or do. I'm not your mom.
147
+
148
+ ## 📄 License
149
+
150
+ MIT — Do whatever you want with it ✌️
151
+
152
+ ---
153
+
154
+ *Made with mass vibe coding energy* 🌊✨
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env node
2
+ import * as readline from 'node:readline';
3
+ import { SteamClient } from './steam/client.js';
4
+ import { loginWithQR, loginWithCredentials, loginWithStoredCredentials, hasStoredLogin, } from './steam/auth.js';
5
+ import { promptAuthMethod } from './ui/prompts.js';
6
+ import { selectGames } from './ui/gameSelector.js';
7
+ import { showWelcome, showIdlingStatus, showLoginSuccess, showLoginError, showGoodbye, showError, } from './ui/display.js';
8
+ import ora from 'ora';
9
+ const client = new SteamClient();
10
+ let isIdling = false;
11
+ let idlingGames = [];
12
+ let pausedGames = [];
13
+ let allGames = [];
14
+ let startTime = null;
15
+ let updateInterval = null;
16
+ let isInEditMode = false;
17
+ let accountName = '';
18
+ // Main entry point that orchestrates the application flow
19
+ async function main() {
20
+ showWelcome();
21
+ process.on('SIGINT', handleShutdown);
22
+ process.on('SIGTERM', handleShutdown);
23
+ const loginResult = await handleLogin();
24
+ if (!loginResult.success) {
25
+ showLoginError(loginResult.error || 'Unknown error');
26
+ process.exit(1);
27
+ }
28
+ accountName = loginResult.accountName;
29
+ showLoginSuccess(accountName);
30
+ const spinner = ora('Fetching your game library...').start();
31
+ try {
32
+ allGames = await client.getOwnedGames();
33
+ spinner.succeed(`Found ${allGames.length} games in your library`);
34
+ }
35
+ catch (err) {
36
+ spinner.fail('Failed to fetch games');
37
+ showError(err.message);
38
+ process.exit(1);
39
+ }
40
+ if (allGames.length === 0) {
41
+ showError('No games found in your library');
42
+ process.exit(1);
43
+ }
44
+ const { selectedGames, quickStart } = await selectGames(allGames, accountName);
45
+ if (selectedGames.length === 0) {
46
+ showError('No games selected');
47
+ process.exit(1);
48
+ }
49
+ startIdling(selectedGames);
50
+ await new Promise(() => {
51
+ });
52
+ }
53
+ // Handles authentication method selection and login
54
+ async function handleLogin() {
55
+ const hasStored = hasStoredLogin();
56
+ const method = await promptAuthMethod(hasStored);
57
+ switch (method) {
58
+ case 'stored':
59
+ return loginWithStoredCredentials(client);
60
+ case 'qr':
61
+ return loginWithQR(client);
62
+ case 'credentials':
63
+ return loginWithCredentials(client);
64
+ default:
65
+ return { success: false, error: 'Invalid login method' };
66
+ }
67
+ }
68
+ // Sets up raw mode keyboard input handling for edit and quit commands
69
+ function setupKeyboardInput() {
70
+ if (process.stdin.isTTY) {
71
+ readline.emitKeypressEvents(process.stdin);
72
+ process.stdin.setRawMode(true);
73
+ process.stdin.resume();
74
+ process.stdin.on('keypress', async (str, key) => {
75
+ if (key && key.ctrl && key.name === 'c') {
76
+ handleShutdown();
77
+ return;
78
+ }
79
+ if (isInEditMode) {
80
+ return;
81
+ }
82
+ if (str === 'e' || str === 'E') {
83
+ await enterEditMode();
84
+ return;
85
+ }
86
+ if (str === 'q' || str === 'Q') {
87
+ handleShutdown();
88
+ return;
89
+ }
90
+ });
91
+ }
92
+ }
93
+ // Creates an IdlingGame object from a GameSelection
94
+ function createIdlingGame(game, steamGame) {
95
+ const initialPlaytime = steamGame?.playtime_forever ?? 0;
96
+ return {
97
+ appid: game.appid,
98
+ name: game.name,
99
+ initialPlaytime,
100
+ startTime: new Date(),
101
+ accumulatedMs: 0,
102
+ };
103
+ }
104
+ // Converts IdlingGame array back to GameSelection array for the selector
105
+ function getGameCurrentSelection() {
106
+ return [...idlingGames, ...pausedGames].map((g) => ({
107
+ appid: g.appid,
108
+ name: g.name,
109
+ }));
110
+ }
111
+ // Handles edit mode for changing game selection while idling
112
+ async function enterEditMode() {
113
+ isInEditMode = true;
114
+ if (updateInterval) {
115
+ clearInterval(updateInterval);
116
+ updateInterval = null;
117
+ }
118
+ const now = new Date();
119
+ for (const game of idlingGames) {
120
+ game.accumulatedMs += now.getTime() - game.startTime.getTime();
121
+ }
122
+ console.clear();
123
+ const currentSelection = getGameCurrentSelection();
124
+ const { selectedGames: newSelection } = await selectGames(allGames, accountName, currentSelection);
125
+ if (newSelection.length === 0) {
126
+ showError('No games selected - keeping current selection');
127
+ for (const game of idlingGames) {
128
+ game.startTime = new Date();
129
+ }
130
+ }
131
+ else {
132
+ const oldAppIds = new Set([...idlingGames, ...pausedGames].map((g) => g.appid));
133
+ const newAppIds = new Set(newSelection.map((g) => g.appid));
134
+ const keptIdlingGames = [];
135
+ const keptPausedGames = [];
136
+ for (const game of idlingGames) {
137
+ if (newAppIds.has(game.appid)) {
138
+ game.startTime = new Date();
139
+ keptIdlingGames.push(game);
140
+ }
141
+ }
142
+ for (const game of pausedGames) {
143
+ if (newAppIds.has(game.appid)) {
144
+ keptPausedGames.push(game);
145
+ }
146
+ }
147
+ for (const selection of newSelection) {
148
+ if (!oldAppIds.has(selection.appid)) {
149
+ const steamGame = allGames.find((g) => g.appid === selection.appid);
150
+ keptIdlingGames.push(createIdlingGame(selection, steamGame));
151
+ }
152
+ }
153
+ idlingGames = keptIdlingGames;
154
+ pausedGames = keptPausedGames;
155
+ const appIds = idlingGames.map((g) => g.appid);
156
+ client.setGamesPlaying(appIds);
157
+ }
158
+ if (process.stdin.isTTY) {
159
+ readline.emitKeypressEvents(process.stdin);
160
+ process.stdin.setRawMode(true);
161
+ process.stdin.resume();
162
+ }
163
+ isInEditMode = false;
164
+ if (startTime) {
165
+ showIdlingStatus(idlingGames, pausedGames);
166
+ updateInterval = setInterval(() => {
167
+ if (startTime && !isInEditMode) {
168
+ showIdlingStatus(idlingGames, pausedGames);
169
+ }
170
+ }, 60000);
171
+ }
172
+ }
173
+ // Handles state changes when user plays or stops playing a game on Steam
174
+ function handlePlayingState(blocked, playingApp) {
175
+ if (isInEditMode)
176
+ return;
177
+ if (blocked) {
178
+ const gameIndex = idlingGames.findIndex((g) => g.appid === playingApp);
179
+ if (gameIndex !== -1) {
180
+ const game = idlingGames[gameIndex];
181
+ const now = new Date();
182
+ game.accumulatedMs += now.getTime() - game.startTime.getTime();
183
+ const [pausedGame] = idlingGames.splice(gameIndex, 1);
184
+ pausedGames.push(pausedGame);
185
+ const appIds = idlingGames.map((g) => g.appid);
186
+ client.setGamesPlaying(appIds);
187
+ showIdlingStatus(idlingGames, pausedGames);
188
+ }
189
+ }
190
+ else {
191
+ if (pausedGames.length > 0) {
192
+ const now = new Date();
193
+ for (const game of pausedGames) {
194
+ game.startTime = now;
195
+ }
196
+ idlingGames.push(...pausedGames);
197
+ pausedGames = [];
198
+ const appIds = idlingGames.map((g) => g.appid);
199
+ client.setGamesPlaying(appIds);
200
+ showIdlingStatus(idlingGames, pausedGames);
201
+ }
202
+ }
203
+ }
204
+ // Initializes the idling session and starts the display loop
205
+ function startIdling(games) {
206
+ isIdling = true;
207
+ startTime = new Date();
208
+ idlingGames = games.map((game) => {
209
+ const steamGame = allGames.find((g) => g.appid === game.appid);
210
+ return createIdlingGame(game, steamGame);
211
+ });
212
+ pausedGames = [];
213
+ const appIds = games.map((g) => g.appid);
214
+ client.setGamesPlaying(appIds);
215
+ setupKeyboardInput();
216
+ showIdlingStatus(idlingGames, pausedGames);
217
+ updateInterval = setInterval(() => {
218
+ if (startTime && !isInEditMode) {
219
+ showIdlingStatus(idlingGames, pausedGames);
220
+ }
221
+ }, 60000);
222
+ client.onPlayingState(handlePlayingState);
223
+ client.onDisconnected((eresult, msg) => {
224
+ console.log(`\nDisconnected from Steam: ${msg} (${eresult})`);
225
+ handleShutdown();
226
+ });
227
+ client.onError((err) => {
228
+ console.log(`\nSteam error: ${err.message}`);
229
+ handleShutdown();
230
+ });
231
+ }
232
+ // Graceful shutdown handler that saves state and logs out
233
+ function handleShutdown() {
234
+ console.log('\n');
235
+ if (updateInterval) {
236
+ clearInterval(updateInterval);
237
+ updateInterval = null;
238
+ }
239
+ if (isIdling) {
240
+ client.stopPlaying();
241
+ isIdling = false;
242
+ }
243
+ if (client.isLoggedIn) {
244
+ client.logout();
245
+ }
246
+ showGoodbye();
247
+ process.exit(0);
248
+ }
249
+ main().catch((err) => {
250
+ showError(err.message);
251
+ process.exit(1);
252
+ });
@@ -0,0 +1,8 @@
1
+ import type { SteamClient } from './client.js';
2
+ import { clearCredentials } from '../storage/credentials.js';
3
+ import type { LoginResult } from '../types/index.js';
4
+ export declare function loginWithQR(client: SteamClient): Promise<LoginResult>;
5
+ export declare function loginWithCredentials(client: SteamClient): Promise<LoginResult>;
6
+ export declare function loginWithStoredCredentials(client: SteamClient): Promise<LoginResult>;
7
+ export declare function hasStoredLogin(): boolean;
8
+ export { clearCredentials };
@@ -0,0 +1,142 @@
1
+ import { LoginSession, EAuthTokenPlatformType, EAuthSessionGuardType } from 'steam-session';
2
+ import qrcode from 'qrcode-terminal';
3
+ import { input, password as passwordPrompt } from '@inquirer/prompts';
4
+ import chalk from 'chalk';
5
+ import ora from 'ora';
6
+ import { saveCredentials, loadCredentials, clearCredentials, hasStoredCredentials, } from '../storage/credentials.js';
7
+ // Authenticates via QR code scanned with the Steam mobile app
8
+ export async function loginWithQR(client) {
9
+ const session = new LoginSession(EAuthTokenPlatformType.SteamClient);
10
+ return new Promise((resolve) => {
11
+ session.on('authenticated', async () => {
12
+ const refreshToken = session.refreshToken;
13
+ const accountName = session.accountName;
14
+ if (!refreshToken || !accountName) {
15
+ resolve({ success: false, error: 'Failed to get credentials from QR login' });
16
+ return;
17
+ }
18
+ saveCredentials({
19
+ accountName,
20
+ refreshToken,
21
+ });
22
+ try {
23
+ await client.loginWithRefreshToken(refreshToken, accountName);
24
+ resolve({ success: true, accountName });
25
+ }
26
+ catch (err) {
27
+ resolve({ success: false, error: err.message });
28
+ }
29
+ });
30
+ session.on('timeout', () => {
31
+ resolve({ success: false, error: 'QR code timed out' });
32
+ });
33
+ session.on('error', (err) => {
34
+ resolve({ success: false, error: err.message });
35
+ });
36
+ session.startWithQR().then((result) => {
37
+ if (result.qrChallengeUrl) {
38
+ console.log(chalk.cyan('\nScan this QR code with your Steam mobile app:\n'));
39
+ qrcode.generate(result.qrChallengeUrl);
40
+ console.log(chalk.gray('\nWaiting for approval...\n'));
41
+ }
42
+ });
43
+ });
44
+ }
45
+ // Authenticates via username and password with Steam Guard support
46
+ export async function loginWithCredentials(client) {
47
+ const session = new LoginSession(EAuthTokenPlatformType.SteamClient);
48
+ const accountName = await input({
49
+ message: 'Steam username:',
50
+ });
51
+ const password = await passwordPrompt({
52
+ message: 'Steam password:',
53
+ mask: '*',
54
+ });
55
+ const spinner = ora('Logging in...').start();
56
+ return new Promise((resolve) => {
57
+ session.on('authenticated', async () => {
58
+ spinner.stop();
59
+ const refreshToken = session.refreshToken;
60
+ if (!refreshToken) {
61
+ resolve({ success: false, error: 'Failed to get refresh token' });
62
+ return;
63
+ }
64
+ saveCredentials({
65
+ accountName,
66
+ refreshToken,
67
+ });
68
+ try {
69
+ await client.loginWithRefreshToken(refreshToken, accountName);
70
+ resolve({ success: true, accountName });
71
+ }
72
+ catch (err) {
73
+ resolve({ success: false, error: err.message });
74
+ }
75
+ });
76
+ session.on('timeout', () => {
77
+ spinner.stop();
78
+ resolve({ success: false, error: 'Login timed out' });
79
+ });
80
+ session.on('error', (err) => {
81
+ spinner.stop();
82
+ resolve({ success: false, error: err.message });
83
+ });
84
+ session.on('steamGuardMachineToken', () => {
85
+ });
86
+ session.startWithCredentials({ accountName, password }).then(async (result) => {
87
+ if (result.actionRequired) {
88
+ spinner.stop();
89
+ for (const action of result.validActions || []) {
90
+ if (action.type === EAuthSessionGuardType.EmailCode ||
91
+ action.type === EAuthSessionGuardType.DeviceCode) {
92
+ const guardType = action.type === EAuthSessionGuardType.EmailCode ? 'email' : 'mobile app';
93
+ console.log(chalk.yellow(`\nSteam Guard code required (${guardType})`));
94
+ const code = await input({
95
+ message: 'Enter Steam Guard code:',
96
+ });
97
+ spinner.start('Verifying code...');
98
+ try {
99
+ await session.submitSteamGuardCode(code);
100
+ }
101
+ catch (err) {
102
+ spinner.stop();
103
+ resolve({ success: false, error: err.message });
104
+ }
105
+ break;
106
+ }
107
+ if (action.type === EAuthSessionGuardType.DeviceConfirmation) {
108
+ console.log(chalk.yellow('\nPlease confirm the login request on your Steam mobile app...'));
109
+ spinner.start('Waiting for confirmation...');
110
+ break;
111
+ }
112
+ }
113
+ }
114
+ }).catch((err) => {
115
+ spinner.stop();
116
+ resolve({ success: false, error: err.message });
117
+ });
118
+ });
119
+ }
120
+ // Logs in using a previously saved refresh token
121
+ export async function loginWithStoredCredentials(client) {
122
+ const credentials = loadCredentials();
123
+ if (!credentials) {
124
+ return { success: false, error: 'No stored credentials' };
125
+ }
126
+ const spinner = ora(`Logging in as ${chalk.cyan(credentials.accountName)}...`).start();
127
+ try {
128
+ await client.loginWithRefreshToken(credentials.refreshToken, credentials.accountName);
129
+ spinner.succeed(`Logged in as ${chalk.cyan(credentials.accountName)}`);
130
+ return { success: true, accountName: credentials.accountName };
131
+ }
132
+ catch (err) {
133
+ spinner.fail('Stored credentials expired');
134
+ clearCredentials();
135
+ return { success: false, error: 'Stored credentials expired' };
136
+ }
137
+ }
138
+ // Checks if stored login credentials exist
139
+ export function hasStoredLogin() {
140
+ return hasStoredCredentials();
141
+ }
142
+ export { clearCredentials };
@@ -0,0 +1,22 @@
1
+ import SteamUser from 'steam-user';
2
+ import type { SteamGame } from '../types/index.js';
3
+ export declare class SteamClient {
4
+ private client;
5
+ private _isLoggedIn;
6
+ private _accountName;
7
+ constructor();
8
+ get isLoggedIn(): boolean;
9
+ get accountName(): string | null;
10
+ get steamUser(): SteamUser;
11
+ loginWithRefreshToken(refreshToken: string, accountName?: string): Promise<void>;
12
+ loginWithCredentials(accountName: string, password: string): Promise<{
13
+ refreshToken: string;
14
+ }>;
15
+ getOwnedGames(): Promise<SteamGame[]>;
16
+ setGamesPlaying(appIds: number[]): void;
17
+ stopPlaying(): void;
18
+ logout(): void;
19
+ onDisconnected(callback: (eresult: number, msg: string) => void): void;
20
+ onError(callback: (err: Error) => void): void;
21
+ onPlayingState(callback: (blocked: boolean, playingApp: number) => void): void;
22
+ }