@serverkgg/bridge 0.1.7 → 0.1.9

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.
@@ -1,5 +1,16 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import { steamcmdArgv, steamcmdFailureText, steamcmdScript, steamcmdUpdateArgs } from "./steamcmd";
2
+ import type { BridgeContext, BridgeExecResult } from "../context";
3
+ import type { BridgeValues } from "../core";
4
+ import {
5
+ createSteamcmd,
6
+ SteamcmdFailure,
7
+ steamcmdArgv,
8
+ steamcmdFailureOf,
9
+ steamcmdFailureText,
10
+ steamcmdRetryable,
11
+ steamcmdScript,
12
+ steamcmdUpdateArgs,
13
+ } from "./steamcmd";
3
14
  import { SteamcmdLoginKind, steamcmdLoginArgs } from "./steamcmdLogin";
4
15
  import { SteamcmdPlatform } from "./steamcmdPlatform";
5
16
 
@@ -8,6 +19,86 @@ const OPTIONS = {
8
19
  label: "palworld",
9
20
  };
10
21
 
22
+ const MANIFEST = [
23
+ '"AppState"',
24
+ "{",
25
+ '\t"appid"\t\t"2394010"',
26
+ '\t"buildid"\t\t"19582821"',
27
+ "}",
28
+ ].join("\n");
29
+
30
+ const SUCCESS: BridgeExecResult = {
31
+ stdout: "Success! App '2394010' fully installed.",
32
+ stderr: "",
33
+ code: 0,
34
+ };
35
+
36
+ interface Logged {
37
+ message: string;
38
+ fields?: BridgeValues;
39
+ }
40
+
41
+ const resultOf = (stdout: string): BridgeExecResult => {
42
+ return {
43
+ stdout,
44
+ stderr: "",
45
+ code: 8,
46
+ };
47
+ };
48
+
49
+ const contextWith = (answers: BridgeExecResult[], files: Record<string, string>) => {
50
+ const commands: string[][] = [];
51
+ const logs: Logged[] = [];
52
+
53
+ const log = Object.assign(
54
+ (message: string, fields?: BridgeValues) => {
55
+ logs.push({
56
+ message,
57
+ fields,
58
+ });
59
+ },
60
+ {
61
+ warn(message: string, fields?: BridgeValues) {
62
+ logs.push({
63
+ message,
64
+ fields,
65
+ });
66
+ },
67
+ },
68
+ );
69
+
70
+ const context = {
71
+ async exec(command: string[]) {
72
+ commands.push(command);
73
+
74
+ if (command[0] === "pwd") {
75
+ return {
76
+ stdout: "/home/container\n",
77
+ stderr: "",
78
+ code: 0,
79
+ };
80
+ }
81
+
82
+ return answers.shift() ?? SUCCESS;
83
+ },
84
+ files: {
85
+ async exists(path: string) {
86
+ return path in files;
87
+ },
88
+ async read(path: string) {
89
+ return files[path] ?? "";
90
+ },
91
+ },
92
+ log,
93
+ } as unknown as BridgeContext;
94
+
95
+ return {
96
+ context,
97
+ commands,
98
+ logs,
99
+ };
100
+ };
101
+
11
102
  describe("steamcmdLoginArgs", () => {
12
103
  it("logs in anonymously by default", () => {
13
104
  expect(steamcmdLoginArgs()).toEqual([
@@ -85,12 +176,26 @@ describe("steamcmdArgv", () => {
85
176
  });
86
177
 
87
178
  describe("steamcmdUpdateArgs", () => {
179
+ it("refreshes the app info before the update", () => {
180
+ const args = steamcmdUpdateArgs("258550", {
181
+ validate: false,
182
+ });
183
+
184
+ expect(args.slice(0, 2)).toEqual([
185
+ "+app_info_update",
186
+ "1",
187
+ ]);
188
+ expect(args.indexOf("+app_info_update")).toBeLessThan(args.indexOf("+app_update"));
189
+ });
190
+
88
191
  it("updates without validating by default", () => {
89
192
  expect(
90
193
  steamcmdUpdateArgs("258550", {
91
194
  validate: false,
92
195
  }),
93
196
  ).toEqual([
197
+ "+app_info_update",
198
+ "1",
94
199
  "+app_update",
95
200
  "258550",
96
201
  ]);
@@ -102,6 +207,8 @@ describe("steamcmdUpdateArgs", () => {
102
207
  validate: true,
103
208
  }),
104
209
  ).toEqual([
210
+ "+app_info_update",
211
+ "1",
105
212
  "+app_update",
106
213
  "258550",
107
214
  "validate",
@@ -116,6 +223,8 @@ describe("steamcmdUpdateArgs", () => {
116
223
  branchPassword: "shh",
117
224
  }),
118
225
  ).toEqual([
226
+ "+app_info_update",
227
+ "1",
119
228
  "+app_update",
120
229
  "90",
121
230
  "-beta",
@@ -133,6 +242,8 @@ describe("steamcmdUpdateArgs", () => {
133
242
  branchPassword: "shh",
134
243
  }),
135
244
  ).toEqual([
245
+ "+app_info_update",
246
+ "1",
136
247
  "+app_update",
137
248
  "90",
138
249
  ]);
@@ -148,6 +259,8 @@ describe("steamcmdUpdateArgs", () => {
148
259
  ],
149
260
  }),
150
261
  ).toEqual([
262
+ "+app_info_update",
263
+ "1",
151
264
  "+app_update",
152
265
  "376030",
153
266
  "-language",
@@ -156,7 +269,142 @@ describe("steamcmdUpdateArgs", () => {
156
269
  });
157
270
  });
158
271
 
272
+ const FAILURE_CASES = [
273
+ {
274
+ output: "Error! App '2394010' state is 0x202 after update job.",
275
+ failure: SteamcmdFailure.DiskFull,
276
+ },
277
+ {
278
+ output: "Error! App '2394010' state is 0x212 after update job.",
279
+ failure: SteamcmdFailure.DiskFull,
280
+ },
281
+ {
282
+ output: "Invalid platform",
283
+ failure: SteamcmdFailure.InvalidPlatform,
284
+ },
285
+ {
286
+ output: "No subscription",
287
+ failure: SteamcmdFailure.NoSubscription,
288
+ },
289
+ {
290
+ output: "Password check for AppId 896660",
291
+ failure: SteamcmdFailure.BranchPassword,
292
+ },
293
+ {
294
+ output: "Error! State is 0x402 after update job.",
295
+ failure: SteamcmdFailure.Transient,
296
+ },
297
+ {
298
+ output: "Error! State is 0x602 after update job.",
299
+ failure: SteamcmdFailure.Transient,
300
+ },
301
+ {
302
+ output: "Error! App '2394010' state is 0x606 after update job.",
303
+ failure: SteamcmdFailure.Transient,
304
+ },
305
+ {
306
+ output: "Error! App '232250' state is 0x626 after update job.",
307
+ failure: SteamcmdFailure.Transient,
308
+ },
309
+ {
310
+ output: "Error! App '317670' state is 0x6A6 after update job.",
311
+ failure: SteamcmdFailure.Transient,
312
+ },
313
+ {
314
+ output: "Missing configuration",
315
+ failure: SteamcmdFailure.Transient,
316
+ },
317
+ {
318
+ output: "Update state (0x61) downloading, progress: 1.11 (127644881 / 11530459441)",
319
+ failure: SteamcmdFailure.Unknown,
320
+ },
321
+ {
322
+ output: "",
323
+ failure: SteamcmdFailure.Unknown,
324
+ },
325
+ ];
326
+
327
+ describe("steamcmdFailureOf", () => {
328
+ for (const item of FAILURE_CASES) {
329
+ it(`reads ${JSON.stringify(item.output)} as ${item.failure}`, () => {
330
+ expect(steamcmdFailureOf(resultOf(item.output))).toBe(item.failure);
331
+ });
332
+ }
333
+
334
+ it("reads the code from stderr too", () => {
335
+ expect(
336
+ steamcmdFailureOf({
337
+ stdout: "",
338
+ stderr: "Error! App '2394010' state is 0x212 after update job.",
339
+ code: 8,
340
+ }),
341
+ ).toBe(SteamcmdFailure.DiskFull);
342
+ });
343
+
344
+ it("puts a full disk ahead of a transient code in the same output", () => {
345
+ expect(
346
+ steamcmdFailureOf(
347
+ resultOf(
348
+ [
349
+ "Error! State is 0x602 after update job.",
350
+ "Error! App '2394010' state is 0x202 after update job.",
351
+ ].join("\n"),
352
+ ),
353
+ ),
354
+ ).toBe(SteamcmdFailure.DiskFull);
355
+ });
356
+ });
357
+
358
+ const RETRY_CASES = [
359
+ {
360
+ failure: SteamcmdFailure.DiskFull,
361
+ retryable: false,
362
+ },
363
+ {
364
+ failure: SteamcmdFailure.InvalidPlatform,
365
+ retryable: false,
366
+ },
367
+ {
368
+ failure: SteamcmdFailure.NoSubscription,
369
+ retryable: false,
370
+ },
371
+ {
372
+ failure: SteamcmdFailure.BranchPassword,
373
+ retryable: false,
374
+ },
375
+ {
376
+ failure: SteamcmdFailure.Transient,
377
+ retryable: true,
378
+ },
379
+ {
380
+ failure: SteamcmdFailure.Unknown,
381
+ retryable: true,
382
+ },
383
+ ];
384
+
385
+ describe("steamcmdRetryable", () => {
386
+ for (const item of RETRY_CASES) {
387
+ it(`${item.retryable ? "retries" : "does not retry"} a ${item.failure} failure`, () => {
388
+ expect(steamcmdRetryable(item.failure)).toBe(item.retryable);
389
+ });
390
+ }
391
+ });
392
+
159
393
  describe("steamcmdFailureText", () => {
394
+ it("tells the owner to free space or move up a plan when the disk is full", () => {
395
+ expect(steamcmdFailureText(resultOf("Error! App '258550' state is 0x202 after update job."), "rust")).toEqual({
396
+ ar: "امتلأت مساحة سيرفرك، فتعذّر تنزيل rust من Steam. احذف الملفات التي لا تحتاجها من صفحة الملفات أو انتقل إلى باقة أكبر، ثم جرب مرة ثانية.",
397
+ en: "Your server is out of space, so we couldn't download rust from Steam. Delete files you don't need on the Files page or move to a larger plan, then try again.",
398
+ });
399
+ });
400
+
401
+ it("says the branch password is wrong and where to fix it", () => {
402
+ expect(steamcmdFailureText(resultOf("Password check for AppId 896660"), "valheim")).toEqual({
403
+ ar: "كلمة مرور الفرع على Steam غير صحيحة، فتعذّر تنزيل valheim. صحّحها من الإعدادات.",
404
+ en: "The Steam branch password is wrong, so we couldn't download valheim. Fix it in the settings.",
405
+ });
406
+ });
407
+
160
408
  it("says the download account does not own the app when steam answers no subscription", () => {
161
409
  expect(
162
410
  steamcmdFailureText(
@@ -186,3 +434,121 @@ describe("steamcmdFailureText", () => {
186
434
  });
187
435
  });
188
436
  });
437
+
438
+ describe("createSteamcmd", () => {
439
+ it("stops at the first failure a retry cannot fix", async () => {
440
+ const { context, commands } = contextWith(
441
+ [
442
+ resultOf("Error! App '2394010' state is 0x202 after update job."),
443
+ ],
444
+ {},
445
+ );
446
+ const steamcmd = createSteamcmd(context, OPTIONS);
447
+
448
+ await expect(
449
+ steamcmd.run([
450
+ "+app_update",
451
+ "2394010",
452
+ ]),
453
+ ).rejects.toThrow(
454
+ "Your server is out of space, so we couldn't download palworld from Steam. Delete files you don't need on the Files page or move to a larger plan, then try again.",
455
+ );
456
+ expect(commands.filter((command) => command[0] !== "pwd")).toHaveLength(1);
457
+ });
458
+
459
+ it("clears the cached app info, updates, then logs the installed build", async () => {
460
+ const { context, commands, logs } = contextWith([], {
461
+ ".steamcmd": "",
462
+ ".steam": "",
463
+ "steamapps/appmanifest_2394010.acf": MANIFEST,
464
+ });
465
+
466
+ await createSteamcmd(context, OPTIONS).update({
467
+ validate: false,
468
+ });
469
+
470
+ expect(commands).toEqual([
471
+ [
472
+ "find",
473
+ ".steamcmd",
474
+ ".steam",
475
+ "-type",
476
+ "f",
477
+ "-name",
478
+ "appinfo.vdf",
479
+ "-delete",
480
+ ],
481
+ [
482
+ "pwd",
483
+ ],
484
+ [
485
+ "./.steamcmd/steamcmd.sh",
486
+ "+force_install_dir",
487
+ "/home/container",
488
+ "+login",
489
+ "anonymous",
490
+ "+app_info_update",
491
+ "1",
492
+ "+app_update",
493
+ "2394010",
494
+ "+quit",
495
+ ],
496
+ ]);
497
+ expect(logs).toEqual([
498
+ {
499
+ message: "steamcmd finished the update",
500
+ fields: {
501
+ app: "2394010",
502
+ build: "19582821",
503
+ },
504
+ },
505
+ ]);
506
+ });
507
+
508
+ it("skips clearing the app info when no steam directory is there yet", async () => {
509
+ const { context, commands, logs } = contextWith([], {});
510
+
511
+ await createSteamcmd(context, OPTIONS).update({
512
+ validate: false,
513
+ });
514
+
515
+ expect(commands.map((command) => command[0])).toEqual([
516
+ "pwd",
517
+ "./.steamcmd/steamcmd.sh",
518
+ ]);
519
+ expect(logs).toEqual([
520
+ {
521
+ message: "steamcmd finished the update",
522
+ fields: {
523
+ app: "2394010",
524
+ build: null,
525
+ },
526
+ },
527
+ ]);
528
+ });
529
+
530
+ it("updates anyway when the app info cannot be cleared", async () => {
531
+ const { context, commands, logs } = contextWith(
532
+ [
533
+ resultOf("find: permission denied"),
534
+ ],
535
+ {
536
+ ".steamcmd": "",
537
+ },
538
+ );
539
+
540
+ await createSteamcmd(context, OPTIONS).update({
541
+ validate: false,
542
+ });
543
+
544
+ expect(commands.map((command) => command[0])).toEqual([
545
+ "find",
546
+ "pwd",
547
+ "./.steamcmd/steamcmd.sh",
548
+ ]);
549
+ expect(logs.map((entry) => entry.message)).toEqual([
550
+ "the cached steam app info could not be cleared, updating anyway",
551
+ "steamcmd finished the update",
552
+ ]);
553
+ });
554
+ });
@@ -16,6 +16,8 @@ const STEAM_CLIENT_DIRECTORY = `${STEAM_DIRECTORY}/sdk64`;
16
16
 
17
17
  const SENTRY_FILE = ".sentry.b64";
18
18
 
19
+ const APP_INFO_FILE = "appinfo.vdf";
20
+
19
21
  const UPDATE_TIMEOUT_MS = 3_600_000;
20
22
 
21
23
  const BOOTSTRAP_TIMEOUT_MS = 300_000;
@@ -26,8 +28,43 @@ const ATTEMPTS = 3;
26
28
 
27
29
  const RETRY_DELAY_MS = 5000;
28
30
 
31
+ const STATE_CODE = /\b0x[0-9a-f]+\b/gi;
32
+
33
+ const DISK_FULL_STATES = [
34
+ "0x202",
35
+ "0x212",
36
+ ];
37
+
38
+ const TRANSIENT_STATES = [
39
+ "0x402",
40
+ "0x602",
41
+ "0x606",
42
+ "0x626",
43
+ "0x6a6",
44
+ ];
45
+
29
46
  const NO_SUBSCRIPTION = "No subscription";
30
47
 
48
+ const BRANCH_PASSWORD = "Password check for AppId";
49
+
50
+ const INVALID_PLATFORM = /invalid platform/i;
51
+
52
+ const MISSING_CONFIGURATION = /missing configuration/i;
53
+
54
+ export enum SteamcmdFailure {
55
+ DiskFull = "disk-full",
56
+ InvalidPlatform = "invalid-platform",
57
+ NoSubscription = "no-subscription",
58
+ BranchPassword = "branch-password",
59
+ Transient = "transient",
60
+ Unknown = "unknown",
61
+ }
62
+
63
+ const RETRYABLE_FAILURES = new Set([
64
+ SteamcmdFailure.Transient,
65
+ SteamcmdFailure.Unknown,
66
+ ]);
67
+
31
68
  export interface SteamcmdOptions {
32
69
  appId: string;
33
70
  label: string;
@@ -80,6 +117,8 @@ export const steamcmdArgv = (options: SteamcmdOptions, root: string, args: strin
80
117
 
81
118
  export const steamcmdUpdateArgs = (appId: string, update: SteamcmdUpdateOptions) => {
82
119
  const args = [
120
+ "+app_info_update",
121
+ "1",
83
122
  "+app_update",
84
123
  appId,
85
124
  ];
@@ -103,18 +142,67 @@ export const steamcmdUpdateArgs = (appId: string, update: SteamcmdUpdateOptions)
103
142
  return args;
104
143
  };
105
144
 
106
- export const steamcmdFailureText = (result: BridgeExecResult, label: string): LocalizedText => {
107
- if (`${result.stdout}\n${result.stderr}`.includes(NO_SUBSCRIPTION)) {
108
- return {
109
- ar: `رفض Steam تنزيل ${label} لأن حساب التنزيل لا يملكه. تواصل مع الدعم.`,
110
- en: `Steam refused to download ${label} because the download account doesn't own it. Contact support.`,
111
- };
145
+ export const steamcmdFailureOf = (result: BridgeExecResult): SteamcmdFailure => {
146
+ const output = `${result.stdout}\n${result.stderr}`;
147
+ const states = new Set(Array.from(output.matchAll(STATE_CODE), (match) => match[0].toLowerCase()));
148
+
149
+ if (DISK_FULL_STATES.some((state) => states.has(state))) {
150
+ return SteamcmdFailure.DiskFull;
112
151
  }
113
152
 
114
- return {
115
- ar: `تعذّر تنزيل ${label} من Steam، جرب مرة ثانية.`,
116
- en: `We couldn't download ${label} from Steam. Try again.`,
117
- };
153
+ if (INVALID_PLATFORM.test(output)) {
154
+ return SteamcmdFailure.InvalidPlatform;
155
+ }
156
+
157
+ if (output.includes(NO_SUBSCRIPTION)) {
158
+ return SteamcmdFailure.NoSubscription;
159
+ }
160
+
161
+ if (output.includes(BRANCH_PASSWORD)) {
162
+ return SteamcmdFailure.BranchPassword;
163
+ }
164
+
165
+ if (TRANSIENT_STATES.some((state) => states.has(state)) || MISSING_CONFIGURATION.test(output)) {
166
+ return SteamcmdFailure.Transient;
167
+ }
168
+
169
+ return SteamcmdFailure.Unknown;
170
+ };
171
+
172
+ export const steamcmdRetryable = (failure: SteamcmdFailure) => {
173
+ return RETRYABLE_FAILURES.has(failure);
174
+ };
175
+
176
+ export const steamcmdFailureText = (result: BridgeExecResult, label: string): LocalizedText => {
177
+ switch (steamcmdFailureOf(result)) {
178
+ case SteamcmdFailure.DiskFull: {
179
+ return {
180
+ ar: `امتلأت مساحة سيرفرك، فتعذّر تنزيل ${label} من Steam. احذف الملفات التي لا تحتاجها من صفحة الملفات أو انتقل إلى باقة أكبر، ثم جرب مرة ثانية.`,
181
+ en: `Your server is out of space, so we couldn't download ${label} from Steam. Delete files you don't need on the Files page or move to a larger plan, then try again.`,
182
+ };
183
+ }
184
+
185
+ case SteamcmdFailure.NoSubscription: {
186
+ return {
187
+ ar: `رفض Steam تنزيل ${label} لأن حساب التنزيل لا يملكه. تواصل مع الدعم.`,
188
+ en: `Steam refused to download ${label} because the download account doesn't own it. Contact support.`,
189
+ };
190
+ }
191
+
192
+ case SteamcmdFailure.BranchPassword: {
193
+ return {
194
+ ar: `كلمة مرور الفرع على Steam غير صحيحة، فتعذّر تنزيل ${label}. صحّحها من الإعدادات.`,
195
+ en: `The Steam branch password is wrong, so we couldn't download ${label}. Fix it in the settings.`,
196
+ };
197
+ }
198
+
199
+ default: {
200
+ return {
201
+ ar: `تعذّر تنزيل ${label} من Steam، جرب مرة ثانية.`,
202
+ en: `We couldn't download ${label} from Steam. Try again.`,
203
+ };
204
+ }
205
+ }
118
206
  };
119
207
 
120
208
  const installRoot = async (context: BridgeContext) => {
@@ -197,15 +285,20 @@ export const createSteamcmd = (context: BridgeContext, options: SteamcmdOptions)
197
285
 
198
286
  failure = result;
199
287
 
200
- if (attempt < attempts) {
201
- context.log("steamcmd did not finish, trying again", {
202
- attempt,
203
- of: attempts,
204
- code: result.code,
205
- });
288
+ const kind = steamcmdFailureOf(result);
206
289
 
207
- await Bun.sleep(RETRY_DELAY_MS);
290
+ if (attempt === attempts || !steamcmdRetryable(kind)) {
291
+ break;
208
292
  }
293
+
294
+ context.log("steamcmd did not finish, trying again", {
295
+ attempt,
296
+ of: attempts,
297
+ code: result.code,
298
+ failure: kind,
299
+ });
300
+
301
+ await Bun.sleep(RETRY_DELAY_MS);
209
302
  }
210
303
 
211
304
  if (!failure) {
@@ -213,10 +306,45 @@ export const createSteamcmd = (context: BridgeContext, options: SteamcmdOptions)
213
306
  }
214
307
 
215
308
  throw new BridgeUserError(steamcmdFailureText(failure, options.label), {
216
- cause: new Error(`steamcmd exited with code ${failure.code} — ${execDetail(failure)}`),
309
+ cause: new Error(
310
+ `steamcmd exited with code ${failure.code} (${steamcmdFailureOf(failure)}) — ${execDetail(failure)}`,
311
+ ),
217
312
  });
218
313
  };
219
314
 
315
+ const clearAppInfo = async () => {
316
+ const roots: string[] = [];
317
+
318
+ for (const path of [
319
+ directory,
320
+ STEAM_DIRECTORY,
321
+ ]) {
322
+ if (await context.files.exists(path)) {
323
+ roots.push(path);
324
+ }
325
+ }
326
+
327
+ if (roots.length === 0) {
328
+ return;
329
+ }
330
+
331
+ const result = await context.exec([
332
+ "find",
333
+ ...roots,
334
+ "-type",
335
+ "f",
336
+ "-name",
337
+ APP_INFO_FILE,
338
+ "-delete",
339
+ ]);
340
+
341
+ if (result.code !== 0) {
342
+ context.log.warn("the cached steam app info could not be cleared, updating anyway", {
343
+ code: result.code,
344
+ });
345
+ }
346
+ };
347
+
220
348
  return {
221
349
  directory,
222
350
  script,
@@ -271,7 +399,15 @@ export const createSteamcmd = (context: BridgeContext, options: SteamcmdOptions)
271
399
  run,
272
400
 
273
401
  async update(update) {
402
+ await clearAppInfo();
274
403
  await run(steamcmdUpdateArgs(options.appId, update));
404
+
405
+ const build = await installedBuildId(context, options.appId);
406
+
407
+ context.log("steamcmd finished the update", {
408
+ app: options.appId,
409
+ build,
410
+ });
275
411
  },
276
412
 
277
413
  async buildId() {