@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.
@@ -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