@irrg/itchio-hoard 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/LICENSE ADDED
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Robb Irrgang
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its contributors
17
+ may be used to endorse or promote products derived from this software
18
+ without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # itchio-hoard
2
+
3
+ TypeScript port of [irrg/itchio](https://github.com/irrg/itchio). Downloads your itch.io library.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm install
9
+ ```
10
+
11
+ ## CLI
12
+
13
+ ```bash
14
+ # download full library
15
+ pnpm itchio-hoard -- -k YOUR_API_KEY
16
+
17
+ # with concurrency (max 8)
18
+ pnpm itchio-hoard -- -k YOUR_API_KEY -j 4
19
+
20
+ # platform filter: windows, linux, osx, android
21
+ pnpm itchio-hoard -- -k YOUR_API_KEY -p osx
22
+
23
+ # use display names for folder structure instead of URL slugs
24
+ pnpm itchio-hoard -- -k YOUR_API_KEY --human-folders
25
+ ```
26
+
27
+ Get an API key at https://itch.io/user/settings/api-keys.
28
+
29
+ Failed downloads are logged to `errors.txt` in the working directory.
30
+
31
+ ## Library
32
+
33
+ ```typescript
34
+ import { Library } from './src/index.js';
35
+
36
+ const lib = new Library(apiKey, 4);
37
+ await lib.loadOwnedGames(); // lib.games: Game[]
38
+
39
+ // download everything
40
+ await lib.downloadLibrary('osx'); // platform optional
41
+
42
+ // or one game at a time
43
+ const game = lib.games[0];
44
+ await game.loadDownloads(apiKey); // game.downloads: Upload[]
45
+ await game.doDownload(game.downloads[0], apiKey);
46
+ ```
47
+
48
+ ## See also
49
+
50
+ Part of the hoard family — [humblebundle-hoard](https://github.com/irrg/humblebundle-hoard), [drivethru-hoard](https://github.com/irrg/drivethru-hoard), [bundleofholding-hoard](https://github.com/irrg/bundleofholding-hoard).
51
+
52
+ ## License
53
+
54
+ Copyright (c) 2026, Robb Irrgang
55
+
56
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
57
+
58
+ 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
59
+ 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
60
+ 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
61
+
62
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { intro, text, password, outro, isCancel, cancel } from '@clack/prompts';
4
+ import { loginAPI, Library } from '../src/index.js';
5
+ const argv = process.argv.slice(2);
6
+ const { values: args } = parseArgs({
7
+ args: argv[0] === '--' ? argv.slice(1) : argv,
8
+ options: {
9
+ key: { type: 'string', short: 'k' },
10
+ platform: { type: 'string', short: 'p' },
11
+ human: { type: 'boolean' },
12
+ jobs: { type: 'string', short: 'j' },
13
+ collections: { type: 'boolean' },
14
+ collection: { type: 'string', short: 'c' },
15
+ bundles: { type: 'boolean' },
16
+ bundle: { type: 'string', short: 'b' },
17
+ output: { type: 'string', short: 'o' },
18
+ filter: { type: 'string', short: 'f', multiple: true },
19
+ 'dry-run': { type: 'boolean' },
20
+ help: { type: 'boolean', short: 'h' },
21
+ },
22
+ strict: true,
23
+ });
24
+ if (args.help) {
25
+ console.log(`Usage: itchio-hoard [options]
26
+
27
+ Options:
28
+ -k, --key <key> API key (prompts for credentials if omitted)
29
+ -p, --platform <name> Filter by platform: windows, linux, osx, android
30
+ --human Use game titles for folder names instead of URL slugs
31
+ -j, --jobs <n> Concurrent downloads (default: 4, max: 8)
32
+ --collections List your itch.io collections and exit
33
+ -c, --collection <id> Download all games in a collection by ID
34
+ --bundles List your purchased bundles and exit
35
+ -b, --bundle <id> Download all games in a bundle by ID
36
+ -o, --output <dir> Output directory (default: downloads)
37
+ -f, --filter <term> Filter games by name (case-insensitive substring); repeat for multiple
38
+ --dry-run Show what would be downloaded without downloading
39
+ -h, --help Show this help`);
40
+ process.exit(0);
41
+ }
42
+ let token = args.key ?? '';
43
+ if (!token) {
44
+ intro('itchcraft');
45
+ const user = await text({ message: 'Username:' });
46
+ if (isCancel(user)) {
47
+ cancel();
48
+ process.exit(1);
49
+ }
50
+ const pass = await password({ message: 'Password:' });
51
+ if (isCancel(pass)) {
52
+ cancel();
53
+ process.exit(1);
54
+ }
55
+ token = await loginAPI(user, pass);
56
+ }
57
+ const jobs = args.jobs != null ? parseInt(args.jobs, 10) : 4;
58
+ if (isNaN(jobs) || jobs < 1) {
59
+ console.error(`Invalid --jobs value: "${args.jobs}". Must be a positive integer.`);
60
+ process.exit(1);
61
+ }
62
+ const lib = new Library(token, jobs, args.human ?? false, args.output ?? 'downloads', args['dry-run'] ?? false, args.filter ?? []);
63
+ if (args.collections) {
64
+ const profile = await lib.getProfile();
65
+ if (profile) {
66
+ console.log(`Profile: ${profile.display_name ?? profile.username}`);
67
+ }
68
+ const collections = await lib.loadCollections();
69
+ if (collections.length === 0) {
70
+ console.log('No collections found.');
71
+ }
72
+ else {
73
+ for (const c of collections) {
74
+ console.log(` [${c.id}] ${c.title} (${c.games_count ?? '?'} games)`);
75
+ }
76
+ }
77
+ process.exit(0);
78
+ }
79
+ if (args.collection != null) {
80
+ const collectionId = parseInt(args.collection, 10);
81
+ if (isNaN(collectionId) || collectionId < 1) {
82
+ console.error(`Invalid --collection value: "${args.collection}". Must be a positive integer.`);
83
+ process.exit(1);
84
+ }
85
+ await lib.loadCollection(collectionId);
86
+ await lib.downloadLibrary(args.platform);
87
+ outro('Done.');
88
+ process.exit(0);
89
+ }
90
+ if (args.bundles) {
91
+ const profile = await lib.getProfile();
92
+ if (profile) {
93
+ console.log(`Profile: ${profile.display_name ?? profile.username}`);
94
+ }
95
+ const bundles = await lib.loadBundles();
96
+ if (bundles.length === 0) {
97
+ console.log('No bundles found.');
98
+ }
99
+ else {
100
+ for (const bk of bundles) {
101
+ console.log(` [${bk.bundle.id}] ${bk.bundle.title} (${bk.bundle.games_count ?? '?'} games)`);
102
+ }
103
+ }
104
+ process.exit(0);
105
+ }
106
+ if (args.bundle != null) {
107
+ const bundleId = parseInt(args.bundle, 10);
108
+ if (isNaN(bundleId) || bundleId < 1) {
109
+ console.error(`Invalid --bundle value: "${args.bundle}". Must be a positive integer.`);
110
+ process.exit(1);
111
+ }
112
+ await lib.loadBundle(bundleId);
113
+ await lib.downloadLibrary(args.platform);
114
+ outro('Done.');
115
+ process.exit(0);
116
+ }
117
+ await lib.loadOwnedGames();
118
+ await lib.downloadLibrary(args.platform);
119
+ outro('Done.');
@@ -0,0 +1,43 @@
1
+ export interface GameData {
2
+ title: string;
3
+ user?: {
4
+ username: string;
5
+ display_name?: string;
6
+ };
7
+ url: string;
8
+ id: number;
9
+ [key: string]: unknown;
10
+ }
11
+ export interface OwnedKeyData {
12
+ id?: number;
13
+ game_id?: number;
14
+ game: GameData;
15
+ [key: string]: unknown;
16
+ }
17
+ export interface Upload {
18
+ id: number;
19
+ filename?: string;
20
+ display_name?: string;
21
+ traits?: string[];
22
+ md5_hash?: string;
23
+ [key: string]: unknown;
24
+ }
25
+ export declare class Game {
26
+ data: GameData;
27
+ name: string;
28
+ publisher: string;
29
+ link: string;
30
+ id: number | false;
31
+ gameId: number;
32
+ gameSlug: string;
33
+ publisherSlug: string;
34
+ dir: string;
35
+ outputDir: string;
36
+ downloads: Upload[];
37
+ dryRun: boolean;
38
+ constructor(data: OwnedKeyData, humanFolders?: boolean, outputDir?: string, dryRun?: boolean);
39
+ loadDownloads(token: string): Promise<void>;
40
+ download(token: string, platform?: string): Promise<void>;
41
+ doDownload(d: Upload, token: string): Promise<boolean>;
42
+ private _logError;
43
+ }
@@ -0,0 +1,197 @@
1
+ import { existsSync } from 'fs';
2
+ import { writeFile, readFile, mkdir, rename, appendFile } from 'fs/promises';
3
+ import path from 'path';
4
+ import { cleanPath, download, fetchWithRetry, md5sum, NoDownloadError } from './utils.js';
5
+ export class Game {
6
+ data;
7
+ name;
8
+ publisher;
9
+ link;
10
+ id;
11
+ gameId;
12
+ gameSlug;
13
+ publisherSlug;
14
+ dir;
15
+ outputDir;
16
+ downloads;
17
+ dryRun;
18
+ constructor(data, humanFolders = false, outputDir = 'downloads', dryRun = false) {
19
+ this.data = data.game;
20
+ this.name = this.data.title;
21
+ this.link = this.data.url;
22
+ const matches = this.link.match(/https:\/\/(.+)\.itch\.io\/(.+)/);
23
+ if (!matches)
24
+ throw new Error(`Cannot parse game URL: ${this.link}`);
25
+ this.publisher = this.data.user?.username ?? matches[1];
26
+ if ('game_id' in data && data.game_id != null) {
27
+ this.id = data.id;
28
+ this.gameId = data.game_id;
29
+ }
30
+ else {
31
+ this.id = false;
32
+ this.gameId = this.data.id;
33
+ }
34
+ if (humanFolders) {
35
+ this.gameSlug = cleanPath(this.data.title);
36
+ this.publisherSlug = cleanPath(this.data.user?.display_name ?? this.data.user?.username ?? matches[1]);
37
+ }
38
+ else {
39
+ this.publisherSlug = matches[1];
40
+ this.gameSlug = matches[2];
41
+ }
42
+ this.outputDir = outputDir;
43
+ this.dir = path.join(outputDir, cleanPath(this.publisherSlug), cleanPath(this.gameSlug));
44
+ this.downloads = [];
45
+ this.dryRun = dryRun;
46
+ }
47
+ async loadDownloads(token) {
48
+ this.downloads = [];
49
+ const url = this.id
50
+ ? `https://api.itch.io/games/${this.gameId}/uploads?download_key_id=${this.id}`
51
+ : `https://api.itch.io/games/${this.gameId}/uploads`;
52
+ const r = await fetchWithRetry(url, { headers: { Authorization: token } });
53
+ let j;
54
+ try {
55
+ j = (await r.json());
56
+ }
57
+ catch {
58
+ console.log(`Failed to load downloads for ${this.name} (HTTP ${r.status}), skipping`);
59
+ return;
60
+ }
61
+ this.downloads = Array.isArray(j.uploads) ? j.uploads : [];
62
+ }
63
+ async download(token, platform) {
64
+ console.log('Downloading', this.name);
65
+ await this.loadDownloads(token);
66
+ const eligible = this.downloads.filter((d) => {
67
+ if (platform != null && Array.isArray(d.traits)) {
68
+ const platformTraits = d.traits.filter((t) => t.startsWith('p_'));
69
+ if (platformTraits.length > 0 && !platformTraits.includes(`p_${platform}`)) {
70
+ console.log(`Skipping ${this.name} - ${d.filename ?? d.id} (${platformTraits.join(', ')})`);
71
+ return false;
72
+ }
73
+ }
74
+ return true;
75
+ });
76
+ if (eligible.length === 0)
77
+ return;
78
+ await mkdir(this.dir, { recursive: true });
79
+ let wrote = 0;
80
+ for (const d of eligible) {
81
+ if (await this.doDownload(d, token))
82
+ wrote++;
83
+ }
84
+ if (wrote === 0)
85
+ return;
86
+ const manifestPath = this.dir + '.json';
87
+ await writeFile(manifestPath, JSON.stringify({
88
+ name: this.name,
89
+ publisher: this.publisher,
90
+ link: this.link,
91
+ itch_id: this.id,
92
+ game_id: this.gameId,
93
+ itch_data: this.data,
94
+ }, null, 2));
95
+ }
96
+ async doDownload(d, token) {
97
+ const rawFilename = d.filename ?? d.display_name ?? String(d.id);
98
+ const filename = cleanPath(rawFilename);
99
+ const outFile = path.join(this.dir, filename);
100
+ const md5Hash = d.md5_hash;
101
+ if (this.dryRun) {
102
+ console.log(`Dry run: ${this.name} - ${filename}`);
103
+ return false;
104
+ }
105
+ console.log(`Downloading ${filename}`);
106
+ if (existsSync(outFile)) {
107
+ console.log(`File already exists: ${filename}`);
108
+ if (!md5Hash) {
109
+ console.log(`Skipping ${this.name} - ${filename}`);
110
+ return true;
111
+ }
112
+ const md5File = withSuffix(outFile, '.md5');
113
+ if (existsSync(md5File)) {
114
+ const storedMd5 = (await readFile(md5File, 'utf8')).trim();
115
+ if (storedMd5 === md5Hash) {
116
+ console.log(`Skipping ${this.name} - ${filename}`);
117
+ return true;
118
+ }
119
+ console.log(`Checksum mismatch: ${filename}`);
120
+ }
121
+ else {
122
+ const computed = await md5sum(outFile);
123
+ if (computed === md5Hash) {
124
+ console.log(`Skipping ${this.name} - ${filename}`);
125
+ await writeFile(md5File, md5Hash);
126
+ return true;
127
+ }
128
+ }
129
+ const oldDir = path.join(this.dir, 'old');
130
+ await mkdir(oldDir, { recursive: true });
131
+ console.log(`Moving ${filename} to old/`);
132
+ const timestamp = new Date().toISOString().split('T')[0];
133
+ await rename(outFile, path.join(oldDir, `${timestamp}-${filename}`));
134
+ }
135
+ // Get download session UUID
136
+ const sessionResp = await fetchWithRetry(`https://api.itch.io/games/${this.gameId}/download-sessions`, {
137
+ method: 'POST',
138
+ headers: { Authorization: token },
139
+ });
140
+ let sessionJson;
141
+ try {
142
+ sessionJson = (await sessionResp.json());
143
+ }
144
+ catch {
145
+ console.log(`Failed to start download session for ${this.name} (HTTP ${sessionResp.status}), skipping ${filename}`);
146
+ return false;
147
+ }
148
+ if (!sessionJson.uuid) {
149
+ console.log(`No session UUID for ${this.name} (HTTP ${sessionResp.status}), skipping ${filename}`);
150
+ return false;
151
+ }
152
+ const downloadUrl = this.id
153
+ ? `https://api.itch.io/uploads/${d.id}/download?api_key=${token}&download_key_id=${this.id}&uuid=${sessionJson.uuid}`
154
+ : `https://api.itch.io/uploads/${d.id}/download?api_key=${token}&uuid=${sessionJson.uuid}`;
155
+ try {
156
+ await download(downloadUrl, this.dir, this.name, filename);
157
+ }
158
+ catch (e) {
159
+ if (e instanceof NoDownloadError) {
160
+ console.log(`HTTP response is not a download, skipping`);
161
+ await this._logError(outFile, filename, downloadUrl, 'Missing content-disposition header — skipped, please download manually');
162
+ return false;
163
+ }
164
+ if (e instanceof Error) {
165
+ console.log(`Download failed: ${this.name} - ${filename}`);
166
+ const code = e.code ?? 'unknown';
167
+ await this._logError(outFile, filename, downloadUrl, `Code: ${code}, reason: ${e.message} — skipped, please download manually`);
168
+ return false;
169
+ }
170
+ throw e;
171
+ }
172
+ if (md5Hash) {
173
+ const computed = await md5sum(outFile);
174
+ await writeFile(withSuffix(outFile, '.md5'), computed);
175
+ if (computed !== md5Hash) {
176
+ console.log(`Failed to verify ${filename}`);
177
+ }
178
+ }
179
+ return true;
180
+ }
181
+ async _logError(outFile, filename, requestUrl, detail) {
182
+ const safeUrl = requestUrl.replace(/api_key=[^&]+/, 'api_key=REDACTED');
183
+ await appendFile(path.join(this.outputDir, 'errors.txt'), [
184
+ ` Cannot download game/asset: ${this.gameSlug}`,
185
+ ` Publisher Name: ${this.publisherSlug}`,
186
+ ` Path: ${outFile}`,
187
+ ` File: ${filename}`,
188
+ ` Request URL: ${safeUrl}`,
189
+ ` ${detail}`,
190
+ ` ---------------------------------------------------------\n`,
191
+ ].join('\n'));
192
+ }
193
+ }
194
+ function withSuffix(filePath, newExt) {
195
+ const ext = path.extname(filePath);
196
+ return ext ? filePath.slice(0, -ext.length) + newExt : filePath + newExt;
197
+ }
@@ -0,0 +1,6 @@
1
+ export { NoDownloadError, download, fetchWithRetry, cleanPath, md5sum, runConcurrently, } from './utils.js';
2
+ export { Game } from './game.js';
3
+ export type { GameData, OwnedKeyData, Upload } from './game.js';
4
+ export { Library } from './library.js';
5
+ export type { UserProfile, Collection, BundleKey } from './library.js';
6
+ export { loginAPI } from './login.js';
@@ -0,0 +1,4 @@
1
+ export { NoDownloadError, download, fetchWithRetry, cleanPath, md5sum, runConcurrently, } from './utils.js';
2
+ export { Game } from './game.js';
3
+ export { Library } from './library.js';
4
+ export { loginAPI } from './login.js';
@@ -0,0 +1,46 @@
1
+ import { Game } from './game.js';
2
+ export interface UserProfile {
3
+ id: number;
4
+ username: string;
5
+ display_name?: string;
6
+ url?: string;
7
+ [key: string]: unknown;
8
+ }
9
+ export interface Collection {
10
+ id: number;
11
+ title: string;
12
+ games_count?: number;
13
+ [key: string]: unknown;
14
+ }
15
+ export interface BundleKey {
16
+ id: number;
17
+ bundle_id: number;
18
+ purchase_id?: number;
19
+ created_at?: string;
20
+ bundle: {
21
+ id: number;
22
+ title: string;
23
+ url?: string;
24
+ games_count?: number;
25
+ [key: string]: unknown;
26
+ };
27
+ [key: string]: unknown;
28
+ }
29
+ export declare class Library {
30
+ token: string;
31
+ games: Game[];
32
+ jobs: number;
33
+ humanFolders: boolean;
34
+ outputDir: string;
35
+ dryRun: boolean;
36
+ filters: string[];
37
+ constructor(token: string, jobs?: number, humanFolders?: boolean, outputDir?: string, dryRun?: boolean, filters?: string[]);
38
+ loadGamePage(page: number): Promise<number>;
39
+ loadOwnedGames(): Promise<void>;
40
+ getProfile(): Promise<UserProfile | null>;
41
+ loadCollections(): Promise<Collection[]>;
42
+ loadCollection(id: number): Promise<void>;
43
+ loadBundles(): Promise<BundleKey[]>;
44
+ loadBundle(id: number): Promise<void>;
45
+ downloadLibrary(platform?: string): Promise<void>;
46
+ }
@@ -0,0 +1,155 @@
1
+ import { Game } from './game.js';
2
+ import { NoDownloadError, fetchWithRetry, runConcurrently } from './utils.js';
3
+ const MAX_JOBS = 8;
4
+ export class Library {
5
+ token;
6
+ games;
7
+ jobs;
8
+ humanFolders;
9
+ outputDir;
10
+ dryRun;
11
+ filters;
12
+ constructor(token, jobs = 4, humanFolders = false, outputDir = 'downloads', dryRun = false, filters = []) {
13
+ this.token = token;
14
+ this.games = [];
15
+ this.jobs = Math.min(jobs, MAX_JOBS);
16
+ this.humanFolders = humanFolders;
17
+ this.outputDir = outputDir;
18
+ this.dryRun = dryRun;
19
+ this.filters = filters.map((f) => f.toLowerCase());
20
+ }
21
+ async loadGamePage(page) {
22
+ console.log(`Loading page ${page}`);
23
+ const r = await fetchWithRetry(`https://api.itch.io/profile/owned-keys?page=${page}`, {
24
+ headers: { Authorization: this.token },
25
+ });
26
+ let j;
27
+ try {
28
+ j = (await r.json());
29
+ }
30
+ catch {
31
+ console.log(`Failed to load page ${page} (HTTP ${r.status}), stopping pagination`);
32
+ return 0;
33
+ }
34
+ if (!Array.isArray(j.owned_keys) || j.owned_keys.length === 0)
35
+ return 0;
36
+ for (const s of j.owned_keys) {
37
+ this.games.push(new Game(s, this.humanFolders, this.outputDir, this.dryRun));
38
+ }
39
+ return j.owned_keys.length;
40
+ }
41
+ async loadOwnedGames() {
42
+ let page = 1;
43
+ while (true) {
44
+ const n = await this.loadGamePage(page);
45
+ if (n === 0)
46
+ break;
47
+ page++;
48
+ }
49
+ }
50
+ async getProfile() {
51
+ const r = await fetchWithRetry('https://api.itch.io/profile', {
52
+ headers: { Authorization: this.token },
53
+ });
54
+ try {
55
+ const j = (await r.json());
56
+ return j.user ?? null;
57
+ }
58
+ catch {
59
+ console.log(`Failed to load profile (HTTP ${r.status})`);
60
+ return null;
61
+ }
62
+ }
63
+ async loadCollections() {
64
+ const r = await fetchWithRetry('https://api.itch.io/profile/collections', {
65
+ headers: { Authorization: this.token },
66
+ });
67
+ try {
68
+ const j = (await r.json());
69
+ return Array.isArray(j.collections) ? j.collections : [];
70
+ }
71
+ catch {
72
+ console.log(`Failed to load collections (HTTP ${r.status})`);
73
+ return [];
74
+ }
75
+ }
76
+ async loadCollection(id) {
77
+ let page = 1;
78
+ while (true) {
79
+ const r = await fetchWithRetry(`https://api.itch.io/collections/${id}/collection-games?page=${page}`, { headers: { Authorization: this.token } });
80
+ let j;
81
+ try {
82
+ j = (await r.json());
83
+ }
84
+ catch {
85
+ console.log(`Failed to load collection ${id} page ${page} (HTTP ${r.status}), stopping`);
86
+ break;
87
+ }
88
+ if (!Array.isArray(j.collection_games) || j.collection_games.length === 0)
89
+ break;
90
+ for (const item of j.collection_games) {
91
+ this.games.push(new Game({ game: item.game }, this.humanFolders, this.outputDir, this.dryRun));
92
+ }
93
+ page++;
94
+ }
95
+ }
96
+ async loadBundles() {
97
+ const r = await fetchWithRetry('https://api.itch.io/profile/owned-bundles', {
98
+ headers: { Authorization: this.token },
99
+ });
100
+ try {
101
+ const j = (await r.json());
102
+ return Array.isArray(j.bundle_keys) ? j.bundle_keys : [];
103
+ }
104
+ catch {
105
+ console.log(`Failed to load bundles (HTTP ${r.status})`);
106
+ return [];
107
+ }
108
+ }
109
+ async loadBundle(id) {
110
+ let page = 1;
111
+ while (true) {
112
+ const r = await fetchWithRetry(`https://api.itch.io/bundles/${id}/bundle-games?page=${page}`, { headers: { Authorization: this.token } });
113
+ let j;
114
+ try {
115
+ j = (await r.json());
116
+ }
117
+ catch {
118
+ console.log(`Failed to load bundle ${id} page ${page} (HTTP ${r.status}), stopping`);
119
+ break;
120
+ }
121
+ if (!Array.isArray(j.bundle_games) || j.bundle_games.length === 0)
122
+ break;
123
+ for (const item of j.bundle_games) {
124
+ this.games.push(new Game({ game: item.game }, this.humanFolders, this.outputDir, this.dryRun));
125
+ }
126
+ page++;
127
+ }
128
+ }
129
+ async downloadLibrary(platform) {
130
+ const games = this.filters.length
131
+ ? this.games.filter((g) => this.filters.some((f) => g.name.toLowerCase().includes(f)))
132
+ : this.games;
133
+ const total = games.length;
134
+ let downloaded = 0;
135
+ let errors = 0;
136
+ const tasks = games.map((g) => async () => {
137
+ try {
138
+ await g.download(this.token, platform);
139
+ downloaded++;
140
+ console.log(`Downloaded ${g.name} (${downloaded} of ${total})`);
141
+ }
142
+ catch (e) {
143
+ if (e instanceof NoDownloadError) {
144
+ console.log(String(e));
145
+ errors++;
146
+ }
147
+ else {
148
+ throw e;
149
+ }
150
+ }
151
+ });
152
+ await runConcurrently(tasks, this.jobs);
153
+ console.log(`Downloaded ${downloaded} games, ${errors} errors`);
154
+ }
155
+ }
@@ -0,0 +1,6 @@
1
+ export interface WebSession {
2
+ get(url: string): Promise<Response>;
3
+ post(url: string, data: Record<string, string>): Promise<Response>;
4
+ }
5
+ export declare function loginAPI(user: string, password: string): Promise<string>;
6
+ export declare function loginWeb(user: string, password: string): Promise<WebSession>;
@@ -0,0 +1,87 @@
1
+ const WARNING = 'Will print the response text (Please be careful as ' +
2
+ 'this may contain personal data or allow others to login to your account):';
3
+ export async function loginAPI(user, password) {
4
+ const body = new URLSearchParams({ username: user, password, source: 'desktop' });
5
+ const r = await fetch('https://api.itch.io/login', {
6
+ method: 'POST',
7
+ body,
8
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
9
+ });
10
+ if (r.status !== 200) {
11
+ console.log(`Error: ${r.status} is not 200`);
12
+ console.log(WARNING);
13
+ console.log(await r.text());
14
+ throw new Error('LoginAPI failed');
15
+ }
16
+ const t = (await r.json());
17
+ if (!t.success || !t.key?.key) {
18
+ console.log('Error: authentication failed');
19
+ console.log(WARNING);
20
+ throw new Error('LoginAPI: authentication failed');
21
+ }
22
+ return t.key.key;
23
+ }
24
+ export async function loginWeb(user, password) {
25
+ const cookies = new Map();
26
+ function buildCookieHeader() {
27
+ return [...cookies.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
28
+ }
29
+ function storeCookies(response) {
30
+ const setCookie = response.headers.get('set-cookie');
31
+ if (!setCookie)
32
+ return;
33
+ for (const part of setCookie.split(',')) {
34
+ const nameVal = part.trim().split(';')[0];
35
+ const eq = nameVal.indexOf('=');
36
+ if (eq !== -1) {
37
+ cookies.set(nameVal.slice(0, eq).trim(), nameVal.slice(eq + 1).trim());
38
+ }
39
+ }
40
+ }
41
+ // GET login page to obtain CSRF token
42
+ const loginPage = await fetch('https://itch.io/login', {
43
+ headers: { Cookie: buildCookieHeader() },
44
+ redirect: 'follow',
45
+ });
46
+ storeCookies(loginPage);
47
+ const html = await loginPage.text();
48
+ const csrfMatch = html.match(/name="csrf_token"\s+value="([^"]+)"/);
49
+ if (!csrfMatch)
50
+ throw new Error('Could not find CSRF token on login page');
51
+ const csrfToken = csrfMatch[1];
52
+ // POST credentials
53
+ const body = new URLSearchParams({ username: user, password, csrf_token: csrfToken });
54
+ const postResp = await fetch('https://itch.io/login', {
55
+ method: 'POST',
56
+ headers: {
57
+ 'Content-Type': 'application/x-www-form-urlencoded',
58
+ Cookie: buildCookieHeader(),
59
+ },
60
+ body,
61
+ redirect: 'follow',
62
+ });
63
+ storeCookies(postResp);
64
+ if (postResp.status !== 200)
65
+ throw new Error('LoginWeb: POST failed');
66
+ const session = {
67
+ async get(url) {
68
+ const r = await fetch(url, { headers: { Cookie: buildCookieHeader() } });
69
+ storeCookies(r);
70
+ return r;
71
+ },
72
+ async post(url, data) {
73
+ const formBody = new URLSearchParams(data);
74
+ const r = await fetch(url, {
75
+ method: 'POST',
76
+ headers: {
77
+ 'Content-Type': 'application/x-www-form-urlencoded',
78
+ Cookie: buildCookieHeader(),
79
+ },
80
+ body: formBody,
81
+ });
82
+ storeCookies(r);
83
+ return r;
84
+ },
85
+ };
86
+ return session;
87
+ }
@@ -0,0 +1,8 @@
1
+ export declare class NoDownloadError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ export declare function fetchWithRetry(url: string, options?: RequestInit, retries?: number): Promise<Response>;
5
+ export declare function download(url: string, dir: string, name: string, filename: string): Promise<void>;
6
+ export declare function cleanPath(p: string): string;
7
+ export declare function md5sum(filePath: string): Promise<string>;
8
+ export declare function runConcurrently(tasks: Array<() => Promise<void>>, limit: number): Promise<void>;
@@ -0,0 +1,68 @@
1
+ import { createHash } from 'crypto';
2
+ import { createReadStream, createWriteStream } from 'fs';
3
+ import path from 'path';
4
+ import { Readable } from 'stream';
5
+ import { pipeline } from 'stream/promises';
6
+ export class NoDownloadError extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = 'NoDownloadError';
10
+ }
11
+ }
12
+ export async function fetchWithRetry(url, options, retries = 3) {
13
+ let delay = 1000;
14
+ for (let attempt = 0;; attempt++) {
15
+ const r = await fetch(url, options);
16
+ if (r.status !== 429 || attempt >= retries)
17
+ return r;
18
+ const retryAfter = r.headers.get('retry-after');
19
+ const wait = retryAfter ? parseInt(retryAfter, 10) * 1000 : delay;
20
+ console.log(`Rate limited, retrying in ${wait / 1000}s...`);
21
+ await new Promise((res) => setTimeout(res, wait));
22
+ delay *= 2;
23
+ }
24
+ }
25
+ export async function download(url, dir, name, filename) {
26
+ console.log(`Downloading ${name} - ${filename}`);
27
+ const response = await fetch(url);
28
+ if (!response.headers.get('content-disposition')) {
29
+ throw new NoDownloadError('Http response is not a download, skipping');
30
+ }
31
+ const outPath = path.join(dir, filename);
32
+ await pipeline(Readable.fromWeb(response.body), createWriteStream(outPath));
33
+ console.log(`Downloaded ${filename}`);
34
+ }
35
+ export function cleanPath(p) {
36
+ let clean = p.replace(/[<>:|?*"/\\]/g, '-');
37
+ clean = clean.replace(/(.)[.]\1+$/, '-');
38
+ return clean;
39
+ }
40
+ export async function md5sum(filePath) {
41
+ return new Promise((resolve, reject) => {
42
+ const hash = createHash('md5');
43
+ const stream = createReadStream(filePath);
44
+ stream.on('data', (chunk) => hash.update(chunk));
45
+ stream.on('end', () => resolve(hash.digest('hex')));
46
+ stream.on('error', reject);
47
+ });
48
+ }
49
+ export async function runConcurrently(tasks, limit) {
50
+ const executing = new Set();
51
+ let firstError = null;
52
+ for (const task of tasks) {
53
+ const p = task()
54
+ .catch((e) => {
55
+ if (!firstError)
56
+ firstError = { value: e };
57
+ })
58
+ .finally(() => executing.delete(p));
59
+ executing.add(p);
60
+ if (executing.size >= limit) {
61
+ await Promise.race(executing);
62
+ }
63
+ }
64
+ // drain all in-flight tasks before propagating any error
65
+ await Promise.all(executing);
66
+ if (firstError)
67
+ throw firstError.value;
68
+ }
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@irrg/itchio-hoard",
3
+ "version": "0.1.0",
4
+ "license": "BSD-3-Clause",
5
+ "type": "module",
6
+ "main": "./dist/src/index.js",
7
+ "types": "./dist/src/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/src/index.js",
11
+ "types": "./dist/src/index.d.ts"
12
+ }
13
+ },
14
+ "bin": {
15
+ "itchio-hoard": "dist/bin/index.js"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsc",
24
+ "prepublishOnly": "tsc",
25
+ "test": "vitest run",
26
+ "test:watch": "vitest",
27
+ "lint": "oxlint src tests bin",
28
+ "fmt": "oxfmt src tests bin",
29
+ "fmt:check": "oxfmt --check src tests bin",
30
+ "itchio-hoard": "node --import tsx/esm bin/index.ts"
31
+ },
32
+ "dependencies": {
33
+ "@clack/prompts": "^0.9.0"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^22.0.0",
37
+ "oxfmt": "^0.53.0",
38
+ "oxlint": "^1.68.0",
39
+ "tsx": "^4.19.0",
40
+ "typescript": "^5.6.0",
41
+ "vitest": "^4.1.8"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "pnpm": {
47
+ "onlyBuiltDependencies": [
48
+ "esbuild"
49
+ ]
50
+ }
51
+ }