@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 +29 -0
- package/README.md +62 -0
- package/dist/bin/index.d.ts +2 -0
- package/dist/bin/index.js +119 -0
- package/dist/src/game.d.ts +43 -0
- package/dist/src/game.js +197 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.js +4 -0
- package/dist/src/library.d.ts +46 -0
- package/dist/src/library.js +155 -0
- package/dist/src/login.d.ts +6 -0
- package/dist/src/login.js +87 -0
- package/dist/src/utils.d.ts +8 -0
- package/dist/src/utils.js +68 -0
- package/package.json +51 -0
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,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
|
+
}
|
package/dist/src/game.js
ADDED
|
@@ -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,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
|
+
}
|