@caleb-collar/steamcmd 1.0.0-alpha.1
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/CHANGELOG.md +141 -0
- package/LICENSE +21 -0
- package/README.md +385 -0
- package/bin/steamcmd +35 -0
- package/package.json +71 -0
- package/src/download.js +220 -0
- package/src/env.js +66 -0
- package/src/install.js +356 -0
- package/src/steamcmd.js +404 -0
- package/src/steamcmd.mjs +28 -0
- package/types/steamcmd.d.ts +466 -0
package/src/download.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module steamcmd/download
|
|
3
|
+
* @description Downloads and extracts SteamCMD for the current platform
|
|
4
|
+
* @private
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const https = require('https')
|
|
8
|
+
const fs = require('fs')
|
|
9
|
+
const tar = require('tar')
|
|
10
|
+
const unzip = require('unzipper')
|
|
11
|
+
const { EventEmitter } = require('events')
|
|
12
|
+
|
|
13
|
+
const env = require('./env')
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* SteamCMD download URLs by platform
|
|
17
|
+
*/
|
|
18
|
+
const DOWNLOAD_URLS = {
|
|
19
|
+
darwin:
|
|
20
|
+
'https://steamcdn-a.akamaihd.net/client/installer/steamcmd_osx.tar.gz',
|
|
21
|
+
linux:
|
|
22
|
+
'https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz',
|
|
23
|
+
win32: 'https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Custom error class for download failures
|
|
28
|
+
*/
|
|
29
|
+
class DownloadError extends Error {
|
|
30
|
+
constructor (message, code) {
|
|
31
|
+
super(message)
|
|
32
|
+
this.name = 'DownloadError'
|
|
33
|
+
this.code = code
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Download and extract SteamCMD for the current platform
|
|
39
|
+
* @param {Object} [options] - Download options
|
|
40
|
+
* @param {Function} [options.onProgress] - Progress callback(progress) with { phase, percent, bytesDownloaded, totalBytes }
|
|
41
|
+
* @param {Function} [callback] - Optional callback(err). If omitted, returns a Promise.
|
|
42
|
+
* @returns {Promise<void>|undefined} Promise if no callback provided
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* // With progress callback
|
|
46
|
+
* await download({
|
|
47
|
+
* onProgress: (progress) => {
|
|
48
|
+
* console.log(`${progress.phase}: ${progress.percent}%`);
|
|
49
|
+
* }
|
|
50
|
+
* });
|
|
51
|
+
*/
|
|
52
|
+
function download (options, callback) {
|
|
53
|
+
// Handle legacy signature: download(callback)
|
|
54
|
+
if (typeof options === 'function') {
|
|
55
|
+
callback = options
|
|
56
|
+
options = {}
|
|
57
|
+
}
|
|
58
|
+
options = options || {}
|
|
59
|
+
|
|
60
|
+
// Support Promise-based usage
|
|
61
|
+
if (typeof callback !== 'function') {
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
download(options, (err) => {
|
|
64
|
+
if (err) reject(err)
|
|
65
|
+
else resolve()
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const onProgress =
|
|
71
|
+
typeof options.onProgress === 'function' ? options.onProgress : () => {}
|
|
72
|
+
|
|
73
|
+
const platform = env.platform()
|
|
74
|
+
const url = DOWNLOAD_URLS[platform]
|
|
75
|
+
const destDir = env.directory()
|
|
76
|
+
|
|
77
|
+
if (!url) {
|
|
78
|
+
callback(
|
|
79
|
+
new DownloadError(
|
|
80
|
+
`Unsupported platform: ${platform}`,
|
|
81
|
+
'UNSUPPORTED_PLATFORM'
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Ensure destination directory exists
|
|
88
|
+
try {
|
|
89
|
+
fs.mkdirSync(destDir, { recursive: true })
|
|
90
|
+
} catch (err) {
|
|
91
|
+
callback(
|
|
92
|
+
new DownloadError(
|
|
93
|
+
`Failed to create directory ${destDir}: ${err.message}`,
|
|
94
|
+
'DIRECTORY_ERROR'
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
onProgress({
|
|
101
|
+
phase: 'starting',
|
|
102
|
+
percent: 0,
|
|
103
|
+
bytesDownloaded: 0,
|
|
104
|
+
totalBytes: 0
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
https
|
|
108
|
+
.get(url, (res) => {
|
|
109
|
+
if (res.statusCode !== 200) {
|
|
110
|
+
callback(
|
|
111
|
+
new DownloadError(
|
|
112
|
+
`Failed to download SteamCMD: HTTP ${res.statusCode}`,
|
|
113
|
+
'HTTP_ERROR'
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const totalBytes = parseInt(res.headers['content-length'], 10) || 0
|
|
120
|
+
let bytesDownloaded = 0
|
|
121
|
+
|
|
122
|
+
res.on('data', (chunk) => {
|
|
123
|
+
bytesDownloaded += chunk.length
|
|
124
|
+
const percent =
|
|
125
|
+
totalBytes > 0 ? Math.round((bytesDownloaded / totalBytes) * 100) : 0
|
|
126
|
+
onProgress({
|
|
127
|
+
phase: 'downloading',
|
|
128
|
+
percent,
|
|
129
|
+
bytesDownloaded,
|
|
130
|
+
totalBytes
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
if (platform === 'darwin' || platform === 'linux') {
|
|
135
|
+
res
|
|
136
|
+
.pipe(tar.x({ cwd: destDir }))
|
|
137
|
+
.on('error', (err) => {
|
|
138
|
+
callback(
|
|
139
|
+
new DownloadError(
|
|
140
|
+
`Failed to extract tar archive: ${err.message}`,
|
|
141
|
+
'EXTRACT_ERROR'
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
})
|
|
145
|
+
.on('finish', () => {
|
|
146
|
+
onProgress({
|
|
147
|
+
phase: 'complete',
|
|
148
|
+
percent: 100,
|
|
149
|
+
bytesDownloaded,
|
|
150
|
+
totalBytes
|
|
151
|
+
})
|
|
152
|
+
callback(null)
|
|
153
|
+
})
|
|
154
|
+
} else if (platform === 'win32') {
|
|
155
|
+
res
|
|
156
|
+
.pipe(unzip.Extract({ path: destDir }))
|
|
157
|
+
.on('error', (err) => {
|
|
158
|
+
callback(
|
|
159
|
+
new DownloadError(
|
|
160
|
+
`Failed to extract zip archive: ${err.message}`,
|
|
161
|
+
'EXTRACT_ERROR'
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
})
|
|
165
|
+
.on('close', () => {
|
|
166
|
+
onProgress({
|
|
167
|
+
phase: 'complete',
|
|
168
|
+
percent: 100,
|
|
169
|
+
bytesDownloaded,
|
|
170
|
+
totalBytes
|
|
171
|
+
})
|
|
172
|
+
callback(null)
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
.on('error', (err) => {
|
|
177
|
+
callback(
|
|
178
|
+
new DownloadError(`Network error: ${err.message}`, 'NETWORK_ERROR')
|
|
179
|
+
)
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Download SteamCMD with EventEmitter-based progress
|
|
185
|
+
* @param {Object} [options] - Download options
|
|
186
|
+
* @returns {EventEmitter} Emitter that fires 'progress', 'error', and 'complete' events
|
|
187
|
+
*
|
|
188
|
+
* @example
|
|
189
|
+
* const emitter = downloadWithProgress();
|
|
190
|
+
* emitter.on('progress', (p) => console.log(`${p.percent}%`));
|
|
191
|
+
* emitter.on('complete', () => console.log('Done!'));
|
|
192
|
+
* emitter.on('error', (err) => console.error(err));
|
|
193
|
+
*/
|
|
194
|
+
function downloadWithProgress (options) {
|
|
195
|
+
const emitter = new EventEmitter()
|
|
196
|
+
|
|
197
|
+
// Run download in next tick to allow event binding
|
|
198
|
+
process.nextTick(() => {
|
|
199
|
+
download(
|
|
200
|
+
{
|
|
201
|
+
...options,
|
|
202
|
+
onProgress: (progress) => emitter.emit('progress', progress)
|
|
203
|
+
},
|
|
204
|
+
(err) => {
|
|
205
|
+
if (err) {
|
|
206
|
+
emitter.emit('error', err)
|
|
207
|
+
} else {
|
|
208
|
+
emitter.emit('complete')
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
return emitter
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
module.exports = download
|
|
218
|
+
module.exports.downloadWithProgress = downloadWithProgress
|
|
219
|
+
module.exports.DownloadError = DownloadError
|
|
220
|
+
module.exports.DOWNLOAD_URLS = DOWNLOAD_URLS
|
package/src/env.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module steamcmd/env
|
|
3
|
+
* @description Platform detection and path resolution for SteamCMD
|
|
4
|
+
* @private
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const os = require('os')
|
|
8
|
+
const path = require('path')
|
|
9
|
+
const envPaths = require('env-paths')
|
|
10
|
+
|
|
11
|
+
const paths = envPaths('steamcmd', { suffix: '' })
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Supported platforms for SteamCMD
|
|
15
|
+
*/
|
|
16
|
+
const SUPPORTED_PLATFORMS = ['linux', 'darwin', 'win32']
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get the SteamCMD installation directory
|
|
20
|
+
* @returns {string} Path to the SteamCMD directory
|
|
21
|
+
*/
|
|
22
|
+
function directory () {
|
|
23
|
+
return paths.data
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get the current platform
|
|
28
|
+
* @returns {string} The current OS platform
|
|
29
|
+
*/
|
|
30
|
+
function platform () {
|
|
31
|
+
return os.platform()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check if the current platform is supported
|
|
36
|
+
* @returns {boolean} True if platform is supported
|
|
37
|
+
*/
|
|
38
|
+
function isPlatformSupported () {
|
|
39
|
+
return SUPPORTED_PLATFORMS.includes(platform())
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get the path to the SteamCMD executable
|
|
44
|
+
* @returns {string|null} Path to executable or null if unsupported platform
|
|
45
|
+
*/
|
|
46
|
+
function executable () {
|
|
47
|
+
const plat = platform()
|
|
48
|
+
|
|
49
|
+
if (plat === 'linux' || plat === 'darwin') {
|
|
50
|
+
return path.resolve(directory(), 'steamcmd.sh')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (plat === 'win32') {
|
|
54
|
+
return path.resolve(directory(), 'steamcmd.exe')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = {
|
|
61
|
+
directory,
|
|
62
|
+
executable,
|
|
63
|
+
platform,
|
|
64
|
+
isPlatformSupported,
|
|
65
|
+
SUPPORTED_PLATFORMS
|
|
66
|
+
}
|
package/src/install.js
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module steamcmd/install
|
|
3
|
+
* @description Spawns SteamCMD processes to install applications and workshop items
|
|
4
|
+
* @private
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const childProcess = require('child_process')
|
|
8
|
+
const { EventEmitter } = require('events')
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Custom error class for installation failures
|
|
12
|
+
* @extends Error
|
|
13
|
+
*/
|
|
14
|
+
class InstallError extends Error {
|
|
15
|
+
constructor (message, code, exitCode) {
|
|
16
|
+
super(message)
|
|
17
|
+
this.name = 'InstallError'
|
|
18
|
+
this.code = code
|
|
19
|
+
this.exitCode = exitCode
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Validate installation options
|
|
25
|
+
* @param {Object} options - Installation options
|
|
26
|
+
* @throws {InstallError} If options are invalid
|
|
27
|
+
*/
|
|
28
|
+
function validateOptions (options) {
|
|
29
|
+
if (!options || typeof options !== 'object') {
|
|
30
|
+
throw new InstallError('Options must be an object', 'INVALID_OPTIONS')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (options.applicationId !== undefined) {
|
|
34
|
+
const appId = Number(options.applicationId)
|
|
35
|
+
if (isNaN(appId) || appId <= 0 || !Number.isInteger(appId)) {
|
|
36
|
+
throw new InstallError(
|
|
37
|
+
'applicationId must be a positive integer',
|
|
38
|
+
'INVALID_APP_ID'
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (options.workshopId !== undefined) {
|
|
44
|
+
if (!options.applicationId) {
|
|
45
|
+
throw new InstallError(
|
|
46
|
+
'workshopId requires applicationId to be specified',
|
|
47
|
+
'MISSING_APP_ID'
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
const workshopId = Number(options.workshopId)
|
|
51
|
+
if (isNaN(workshopId) || workshopId <= 0 || !Number.isInteger(workshopId)) {
|
|
52
|
+
throw new InstallError(
|
|
53
|
+
'workshopId must be a positive integer',
|
|
54
|
+
'INVALID_WORKSHOP_ID'
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (options.platform !== undefined) {
|
|
60
|
+
const validPlatforms = ['windows', 'macos', 'linux']
|
|
61
|
+
if (!validPlatforms.includes(options.platform)) {
|
|
62
|
+
throw new InstallError(
|
|
63
|
+
`platform must be one of: ${validPlatforms.join(', ')}`,
|
|
64
|
+
'INVALID_PLATFORM'
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (options.password && !options.username) {
|
|
70
|
+
throw new InstallError(
|
|
71
|
+
'password requires username to be specified',
|
|
72
|
+
'MISSING_USERNAME'
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (options.steamGuardCode && !options.username) {
|
|
77
|
+
throw new InstallError(
|
|
78
|
+
'steamGuardCode requires username to be specified',
|
|
79
|
+
'MISSING_USERNAME'
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build SteamCMD command line arguments
|
|
86
|
+
* @param {Object} options - Installation options
|
|
87
|
+
* @returns {string[]} Array of command line arguments
|
|
88
|
+
*/
|
|
89
|
+
function createArguments (options) {
|
|
90
|
+
const args = []
|
|
91
|
+
|
|
92
|
+
// Force platform type for download
|
|
93
|
+
if (options.platform) {
|
|
94
|
+
args.push('+@sSteamCmdForcePlatformType ' + options.platform)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Use supplied password
|
|
98
|
+
args.push('+@NoPromptForPassword 1')
|
|
99
|
+
|
|
100
|
+
// Quit on fail
|
|
101
|
+
args.push('+@ShutdownOnFailedCommand 1')
|
|
102
|
+
|
|
103
|
+
if (options.steamGuardCode) {
|
|
104
|
+
args.push('+set_steam_guard_code ' + options.steamGuardCode)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Authentication
|
|
108
|
+
if (options.username && options.password) {
|
|
109
|
+
args.push('+login ' + options.username + ' ' + options.password)
|
|
110
|
+
} else if (options.username) {
|
|
111
|
+
args.push('+login ' + options.username)
|
|
112
|
+
} else {
|
|
113
|
+
args.push('+login anonymous')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Installation directory
|
|
117
|
+
if (options.path) {
|
|
118
|
+
args.push('+force_install_dir "' + options.path + '"')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// App id to install and/or validate
|
|
122
|
+
if (options.applicationId && !options.workshopId) {
|
|
123
|
+
args.push('+app_update ' + options.applicationId + ' validate')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Workshop id to install and/or validate
|
|
127
|
+
if (options.applicationId && options.workshopId) {
|
|
128
|
+
args.push(
|
|
129
|
+
'+workshop_download_item ' +
|
|
130
|
+
options.applicationId +
|
|
131
|
+
' ' +
|
|
132
|
+
options.workshopId
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Quit when done
|
|
137
|
+
args.push('+quit')
|
|
138
|
+
|
|
139
|
+
return args
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Parse SteamCMD output for progress information
|
|
144
|
+
* @param {string} data - Raw output from SteamCMD
|
|
145
|
+
* @returns {Object|null} Parsed progress info or null if not progress data
|
|
146
|
+
*/
|
|
147
|
+
function parseProgress (data) {
|
|
148
|
+
const str = data.toString()
|
|
149
|
+
|
|
150
|
+
// Match update/download progress: "Update state (0x61) downloading, progress: 45.23 (1234567890 / 2732853760)"
|
|
151
|
+
const updateMatch = str.match(
|
|
152
|
+
/Update state \(0x[\da-f]+\) (\w+), progress: ([\d.]+) \((\d+) \/ (\d+)\)/i
|
|
153
|
+
)
|
|
154
|
+
if (updateMatch) {
|
|
155
|
+
return {
|
|
156
|
+
phase: updateMatch[1].toLowerCase(),
|
|
157
|
+
percent: Math.round(parseFloat(updateMatch[2])),
|
|
158
|
+
bytesDownloaded: parseInt(updateMatch[3], 10),
|
|
159
|
+
totalBytes: parseInt(updateMatch[4], 10)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Match validation progress: "Validating: 45%"
|
|
164
|
+
const validateMatch = str.match(/Validating[^\d]*(\d+)%/i)
|
|
165
|
+
if (validateMatch) {
|
|
166
|
+
return {
|
|
167
|
+
phase: 'validating',
|
|
168
|
+
percent: parseInt(validateMatch[1], 10),
|
|
169
|
+
bytesDownloaded: 0,
|
|
170
|
+
totalBytes: 0
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Match download progress: "[#### ] 45%"
|
|
175
|
+
const percentMatch = str.match(/\[(#+\s*)\]\s*(\d+)%/i)
|
|
176
|
+
if (percentMatch) {
|
|
177
|
+
return {
|
|
178
|
+
phase: 'downloading',
|
|
179
|
+
percent: parseInt(percentMatch[2], 10),
|
|
180
|
+
bytesDownloaded: 0,
|
|
181
|
+
totalBytes: 0
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Run SteamCMD with the given options
|
|
190
|
+
* @param {string} steamCmdPath - Path to SteamCMD executable
|
|
191
|
+
* @param {Object} options - Installation options
|
|
192
|
+
* @param {Function} [options.onProgress] - Progress callback(progress) with { phase, percent, bytesDownloaded, totalBytes }
|
|
193
|
+
* @param {Function} [options.onOutput] - Output callback(data, type) where type is 'stdout' or 'stderr'
|
|
194
|
+
* @param {Function} [callback] - Optional callback(err). If omitted, returns a Promise.
|
|
195
|
+
* @returns {Promise<void>|undefined} Promise if no callback provided
|
|
196
|
+
*
|
|
197
|
+
* @example
|
|
198
|
+
* // With progress callback
|
|
199
|
+
* await install(execPath, {
|
|
200
|
+
* applicationId: 740,
|
|
201
|
+
* onProgress: (p) => console.log(`${p.phase}: ${p.percent}%`),
|
|
202
|
+
* onOutput: (data, type) => console.log(`[${type}] ${data}`)
|
|
203
|
+
* });
|
|
204
|
+
*/
|
|
205
|
+
function install (steamCmdPath, options, callback) {
|
|
206
|
+
// Support Promise-based usage
|
|
207
|
+
if (typeof callback !== 'function') {
|
|
208
|
+
return new Promise((resolve, reject) => {
|
|
209
|
+
install(steamCmdPath, options, (err) => {
|
|
210
|
+
if (err) reject(err)
|
|
211
|
+
else resolve()
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Validate options
|
|
217
|
+
try {
|
|
218
|
+
validateOptions(options)
|
|
219
|
+
} catch (err) {
|
|
220
|
+
callback(err)
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Validate steamCmdPath
|
|
225
|
+
if (!steamCmdPath || typeof steamCmdPath !== 'string') {
|
|
226
|
+
callback(
|
|
227
|
+
new InstallError(
|
|
228
|
+
'steamCmdPath must be a non-empty string',
|
|
229
|
+
'INVALID_PATH'
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const onProgress =
|
|
236
|
+
typeof options.onProgress === 'function' ? options.onProgress : () => {}
|
|
237
|
+
const onOutput =
|
|
238
|
+
typeof options.onOutput === 'function' ? options.onOutput : null
|
|
239
|
+
|
|
240
|
+
const proc = childProcess.execFile(steamCmdPath, createArguments(options))
|
|
241
|
+
|
|
242
|
+
let stdoutData = ''
|
|
243
|
+
let stderrData = ''
|
|
244
|
+
|
|
245
|
+
onProgress({
|
|
246
|
+
phase: 'starting',
|
|
247
|
+
percent: 0,
|
|
248
|
+
bytesDownloaded: 0,
|
|
249
|
+
totalBytes: 0
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
proc.stdout.on('data', (data) => {
|
|
253
|
+
stdoutData += data
|
|
254
|
+
if (onOutput) {
|
|
255
|
+
onOutput(data.toString(), 'stdout')
|
|
256
|
+
} else {
|
|
257
|
+
console.log('stdout: ' + data)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Parse progress from output
|
|
261
|
+
const progress = parseProgress(data)
|
|
262
|
+
if (progress) {
|
|
263
|
+
onProgress(progress)
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
proc.stderr.on('data', (data) => {
|
|
268
|
+
stderrData += data
|
|
269
|
+
if (onOutput) {
|
|
270
|
+
onOutput(data.toString(), 'stderr')
|
|
271
|
+
} else {
|
|
272
|
+
console.log('stderr: ' + data)
|
|
273
|
+
}
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
proc.on('error', (err) => {
|
|
277
|
+
callback(
|
|
278
|
+
new InstallError(
|
|
279
|
+
`Failed to spawn SteamCMD: ${err.message}`,
|
|
280
|
+
'SPAWN_ERROR'
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
proc.on('close', (code) => {
|
|
286
|
+
if (onOutput) {
|
|
287
|
+
onOutput(`Process exited with code ${code}\n`, 'stdout')
|
|
288
|
+
} else {
|
|
289
|
+
console.log('child process exited with code ' + code)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (code > 0) {
|
|
293
|
+
const err = new InstallError(
|
|
294
|
+
`SteamCMD exited with code ${code}`,
|
|
295
|
+
'EXIT_ERROR',
|
|
296
|
+
code
|
|
297
|
+
)
|
|
298
|
+
err.stdout = stdoutData
|
|
299
|
+
err.stderr = stderrData
|
|
300
|
+
callback(err)
|
|
301
|
+
} else {
|
|
302
|
+
onProgress({
|
|
303
|
+
phase: 'complete',
|
|
304
|
+
percent: 100,
|
|
305
|
+
bytesDownloaded: 0,
|
|
306
|
+
totalBytes: 0
|
|
307
|
+
})
|
|
308
|
+
callback(null)
|
|
309
|
+
}
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Run SteamCMD with EventEmitter-based progress
|
|
315
|
+
* @param {string} steamCmdPath - Path to SteamCMD executable
|
|
316
|
+
* @param {Object} options - Installation options
|
|
317
|
+
* @returns {EventEmitter} Emitter that fires 'progress', 'output', 'error', and 'complete' events
|
|
318
|
+
*
|
|
319
|
+
* @example
|
|
320
|
+
* const emitter = installWithProgress(execPath, { applicationId: 740 });
|
|
321
|
+
* emitter.on('progress', (p) => console.log(`${p.percent}%`));
|
|
322
|
+
* emitter.on('output', (data, type) => console.log(`[${type}] ${data}`));
|
|
323
|
+
* emitter.on('complete', () => console.log('Done!'));
|
|
324
|
+
* emitter.on('error', (err) => console.error(err));
|
|
325
|
+
*/
|
|
326
|
+
function installWithProgress (steamCmdPath, options) {
|
|
327
|
+
const emitter = new EventEmitter()
|
|
328
|
+
|
|
329
|
+
// Run install in next tick to allow event binding
|
|
330
|
+
process.nextTick(() => {
|
|
331
|
+
install(
|
|
332
|
+
steamCmdPath,
|
|
333
|
+
{
|
|
334
|
+
...options,
|
|
335
|
+
onProgress: (progress) => emitter.emit('progress', progress),
|
|
336
|
+
onOutput: (data, type) => emitter.emit('output', data, type)
|
|
337
|
+
},
|
|
338
|
+
(err) => {
|
|
339
|
+
if (err) {
|
|
340
|
+
emitter.emit('error', err)
|
|
341
|
+
} else {
|
|
342
|
+
emitter.emit('complete')
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
)
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
return emitter
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
module.exports = install
|
|
352
|
+
module.exports.installWithProgress = installWithProgress
|
|
353
|
+
module.exports.createArguments = createArguments
|
|
354
|
+
module.exports.validateOptions = validateOptions
|
|
355
|
+
module.exports.parseProgress = parseProgress
|
|
356
|
+
module.exports.InstallError = InstallError
|