@kevlns/ship-cli 0.0.0-stage → 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/dist/index.mjs ADDED
@@ -0,0 +1,2879 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/version.ts
4
+ var VERSION = "0.1.0";
5
+ var PACKAGE_NAME = "@kevlns/ship-cli";
6
+
7
+ // src/core/suggest.ts
8
+ var SUGGESTIONS = {
9
+ CLI_ARGUMENT_INVALID: {
10
+ suggestion: "check the command and arguments with ship-cli <command> --help"
11
+ },
12
+ PROCESS_START_FAILED: {
13
+ suggestion: "check that the executable exists and can run on this platform"
14
+ },
15
+ PROCESS_TIMEOUT: {
16
+ suggestion: "check connectivity and external-tool logs; retry after resolving the timeout"
17
+ },
18
+ WX_PROJECT_CONFIG_INVALID: {
19
+ suggestion: "fix project.config.json syntax, compileType=game and the configured AppID"
20
+ },
21
+ WX_CONTENT_UNREADABLE: {
22
+ suggestion: "make every packaged file readable and replace directory links with actual build content"
23
+ },
24
+ WX_WORKERS_INVALID: {
25
+ suggestion: "use a valid project-relative workers directory and avoid overlap with other subpackages"
26
+ },
27
+ WX_CI_VERSION_UNSUPPORTED: {
28
+ suggestion: "install the supported SDK: npm i -D miniprogram-ci@2.1.31"
29
+ },
30
+ CFG_CONFIG_NOT_FOUND: {
31
+ suggestion: "run `ship-cli config init` in the game repo (config is searched upward from cwd; use --project <dir> to point elsewhere)"
32
+ },
33
+ CFG_CONFIG_INVALID_JSON: {
34
+ suggestion: "fix the JSON syntax and validate against schemas/ship-config.schema.json; compare with the template via `ship-cli config init` in a scratch dir"
35
+ },
36
+ CFG_CONFIG_EXISTS: {
37
+ suggestion: "edit the existing ship.config.json instead, or pass --force to overwrite it deliberately"
38
+ },
39
+ CFG_CONFIG_PATH_CONFLICT: {
40
+ suggestion: "point steam.buildOutput and steam.contentRoot at separate directories \u2014 steamcmd writes chunk cache (.csm/.csd) into BuildOutput and must never upload it",
41
+ docs: "https://partner.steamgames.com/doc/sdk/uploading"
42
+ },
43
+ CFG_DEPOT_SOURCE_OUTSIDE_CONTENT_ROOT: {
44
+ suggestion: 'make each depot source a subdirectory of steam.contentRoot (use "." for the whole contentRoot)'
45
+ },
46
+ CFG_DEPOT_DUPLICATE_ID: {
47
+ suggestion: "give every depot a unique id; the convention is appId+1, appId+2, ... for base app depots",
48
+ docs: "https://partner.steamgames.com/doc/store/application/depots"
49
+ },
50
+ CFG_SCHEMA_INVALID: {
51
+ suggestion: "fix the listed fields against schemas/ship-config.schema.json (template: `ship-cli config init` in a scratch dir)"
52
+ },
53
+ PAT_ESCAPE: {
54
+ suggestion: "a configured path escaped its allowed base \u2014 remove absolute paths and '..' segments from path fields in ship.config.json"
55
+ },
56
+ STM_STEAMCMD_NOT_FOUND: {
57
+ suggestion: "download https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip into its own directory, then set SHIP_STEAMCMD or steam.steamcmdPath",
58
+ docs: "https://developer.valvesoftware.com/wiki/SteamCMD"
59
+ },
60
+ STM_NO_USERNAME: {
61
+ suggestion: "export SHIP_STEAM_USERNAME for the dedicated build account"
62
+ },
63
+ STM_SETLIVE_DEFAULT: {
64
+ suggestion: "Valve forbids auto set-live on default; push with --set-live <beta-branch> for testing, then set default live manually in App Admin -> Builds (`ship-cli steam open builds`)",
65
+ docs: "https://partner.steamgames.com/doc/store/application/branches"
66
+ },
67
+ STM_SETLIVE_EMPTY_BRANCH: {
68
+ suggestion: "pass a real branch name (e.g. beta) or omit --set-live entirely"
69
+ },
70
+ STM_CONFIG_VDF_INVALID: {
71
+ suggestion: "SHIP_STEAM_CONFIG_VDF must be base64 of a steamcmd config/config.vdf containing InstallConfigStore; re-export it right after one successful interactive login"
72
+ },
73
+ STM_CONFIG_VDF_CONFLICT: {
74
+ suggestion: "the steamcmd dir already holds a different config.vdf \u2014 remove it (or re-export a fresh one); note: logging in with a password again invalidates the old token",
75
+ docs: "https://partner.steamgames.com/doc/sdk/uploading"
76
+ },
77
+ STM_TOTP_INVALID: {
78
+ suggestion: "SHIP_STEAM_TOTP_SECRET must be the base64 shared secret of the build account's Steam Guard mobile authenticator"
79
+ },
80
+ STM_BUILD_FAILED: {
81
+ suggestion: "inspect the logged ERROR lines and the saved log; common causes: backend config not saved yet, build account lacks Edit App Metadata, or corrupted chunk cache (delete the depot's .csm/.csd under BuildOutput and retry)",
82
+ docs: "https://partner.steamgames.com/doc/sdk/uploading"
83
+ },
84
+ STM_BUILD_UNKNOWN_OUTCOME: {
85
+ suggestion: "steamcmd exited 0 without a success signature, so it is treated as failed (fail-closed); open the saved log and check whether a build actually appeared in App Admin -> Builds"
86
+ },
87
+ STM_LOGIN_GUARD_REQUIRED: {
88
+ suggestion: "first logins need a Steam Guard code: set SHIP_STEAM_TOTP / SHIP_STEAM_TOTP_SECRET, or log in once interactively and seed config.vdf via SHIP_STEAM_CONFIG_VDF",
89
+ docs: "https://partner.steamgames.com/doc/sdk/uploading"
90
+ },
91
+ STM_CONTENT_ROOT_MISSING: {
92
+ suggestion: "build the game player first, or fix steam.contentRoot in ship.config.json to point at the built output directory"
93
+ },
94
+ STM_EXECUTABLE_MISSING: {
95
+ suggestion: "fix steam.executable (relative to contentRoot) so `steam test` can find the main exe"
96
+ },
97
+ STM_APPID_FILE_MISMATCH: {
98
+ suggestion: "steam_appid.txt disagrees with steam.appId \u2014 fix one of them (`ship-cli steam test --write-appid` rewrites the file from config)"
99
+ },
100
+ WX_PROJECT_DIR_MISSING: {
101
+ suggestion: "wechat.projectPath must be the converted mini game directory (the one containing game.json); run the engine convertor or fix the path"
102
+ },
103
+ WX_GAME_JSON_MISSING: {
104
+ suggestion: "game.json must exist in wechat.projectPath \u2014 re-run the engine convertor or create it (deviceOrientation + subpackages)"
105
+ },
106
+ WX_GAME_JSON_INVALID: {
107
+ suggestion: "fix game.json: subpackages entries need exactly name + root (roots must be safe relative paths; no duplicates); openDataContext must be a string path",
108
+ docs: "https://developers.weixin.qq.com/minigame/dev/reference/configuration/app.html"
109
+ },
110
+ WX_GAME_ENTRY_MISSING: {
111
+ suggestion: "the main package needs game.js at the project root \u2014 engine convertor output normally provides it; do not hand-create an empty one without real bootstrap code"
112
+ },
113
+ WX_PROJECT_CONFIG_MISSING: {
114
+ suggestion: 'create a minimal project.config.json in the project dir, e.g. {"appid":"<appid>","compileType":"game","libVersion":"latest"} \u2014 the Unity convertor normally emits it (miniprogram-ci requires it)',
115
+ docs: "https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html"
116
+ },
117
+ WX_SUBPACKAGE_ROOT_MISSING: {
118
+ suggestion: "create the subpackage directory with a game.js entry inside, or fix the root path in game.json \u2014 do not create an empty dir just to silence this check"
119
+ },
120
+ WX_SUBPACKAGE_ROOT_OUTSIDE: {
121
+ suggestion: "subpackage roots must stay inside the mini game project directory"
122
+ },
123
+ WX_SUBPACKAGE_NESTED: {
124
+ suggestion: "subpackage roots must not contain each other \u2014 keep them as sibling directories under the project root"
125
+ },
126
+ WX_SUBPACKAGE_OVERLAPS_OPEN_DATA: {
127
+ suggestion: "the openDataContext directory cannot be a subpackage or live inside one (official rule) \u2014 move it outside all subpackage roots",
128
+ docs: "https://developers.weixin.qq.com/minigame/dev/guide/base-ability/subPackage/useSubPackage.html"
129
+ },
130
+ WX_MAIN_OVER_LIMIT: {
131
+ suggestion: "move code/assets into subpackages (wx.loadSubpackage) or serve them from CDN \u2014 see the largest-files list for candidates; these are source estimates; configure compilation through wechat.setting and compare SDK package sizes",
132
+ docs: "https://developers.weixin.qq.com/minigame/dev/guide/base-ability/subPackage/useSubPackage.html"
133
+ },
134
+ WX_INDEPENDENT_OVER_LIMIT: {
135
+ suggestion: "independent subpackages are capped at 4M each \u2014 slim it down, or make it a normal subpackage if it does not need to start without the main package",
136
+ docs: "https://developers.weixin.qq.com/minigame/dev/guide/base-ability/independent-sub-packages.html"
137
+ },
138
+ WX_TOTAL_OVER_LIMIT: {
139
+ suggestion: "total package is capped at 30M \u2014 move engine assets to CDN (the Unity convertor's webgl/ output belongs on CDN, not in the package)",
140
+ docs: "https://developers.weixin.qq.com/minigame/dev/guide/base-ability/code-package.html"
141
+ },
142
+ WX_KEY_NOT_FOUND: {
143
+ suggestion: "download the code-upload key at MP backend -> \u7BA1\u7406 -> \u5F00\u53D1\u7BA1\u7406 -> \u5F00\u53D1\u8BBE\u7F6E -> \u5C0F\u7A0B\u5E8F\u4EE3\u7801\u4E0A\u4F20, then point SHIP_WX_PRIVATE_KEY (or wechat.privateKeyPath) at the file",
144
+ docs: "https://developers.weixin.qq.com/miniprogram/dev/devtools/ci.html"
145
+ },
146
+ WX_KEY_INVALID: {
147
+ suggestion: "the file must be the PEM private key downloaded from the MP backend (keys cannot be re-downloaded; reset if lost)"
148
+ },
149
+ WX_CI_NOT_INSTALLED: {
150
+ suggestion: "install it in the game project: npm i -D miniprogram-ci (ship-cli resolves it from the project's node_modules)",
151
+ docs: "https://developers.weixin.qq.com/miniprogram/dev/devtools/ci.html"
152
+ },
153
+ WX_UPLOAD_FAILED: {
154
+ suggestion: "read the miniprogram-ci error output above; common causes: IP whitelist not configured, key was reset, package rejected by the compiler"
155
+ },
156
+ WX_PREVIEW_FAILED: {
157
+ suggestion: "read the miniprogram-ci output and check wechat.bigPackageSizeSupport for preview-only size support"
158
+ },
159
+ WX_VALIDATION_FAILED: {
160
+ suggestion: "fix every WX_* item in the report above, then re-run `ship-cli wx validate`"
161
+ },
162
+ WX_VERSION_REQUIRED: {
163
+ suggestion: "pass --version <x.y.z>; it becomes the dev-version number shown in the MP backend"
164
+ }
165
+ };
166
+ function suggest(code) {
167
+ return SUGGESTIONS[code];
168
+ }
169
+
170
+ // src/core/errors.ts
171
+ var CliError = class extends Error {
172
+ code;
173
+ exitCode;
174
+ details;
175
+ /** Remediation line, auto-filled from the suggestion registry when omitted. */
176
+ suggestion;
177
+ docs;
178
+ constructor(code, message, options) {
179
+ super(message);
180
+ this.name = "CliError";
181
+ this.code = code;
182
+ this.exitCode = options?.exitCode ?? exitCodeFor(code);
183
+ this.details = options?.details;
184
+ const fix = suggest(code);
185
+ this.suggestion = options?.suggestion ?? fix.suggestion;
186
+ this.docs = options?.docs ?? fix.docs;
187
+ }
188
+ };
189
+ var VALIDATION_REJECT = /* @__PURE__ */ new Set([
190
+ "WX_VALIDATION_FAILED",
191
+ "WX_PROJECT_DIR_MISSING",
192
+ "WX_GAME_JSON_MISSING",
193
+ "WX_GAME_JSON_INVALID",
194
+ "WX_GAME_ENTRY_MISSING",
195
+ "WX_PROJECT_CONFIG_MISSING",
196
+ "WX_SUBPACKAGE_ROOT_MISSING",
197
+ "WX_SUBPACKAGE_ROOT_OUTSIDE",
198
+ "WX_SUBPACKAGE_NESTED",
199
+ "WX_SUBPACKAGE_OVERLAPS_OPEN_DATA",
200
+ "WX_MAIN_OVER_LIMIT",
201
+ "WX_INDEPENDENT_OVER_LIMIT",
202
+ "WX_TOTAL_OVER_LIMIT"
203
+ ]);
204
+ var PREREQUISITE = /* @__PURE__ */ new Set([
205
+ "STM_STEAMCMD_NOT_FOUND",
206
+ "STM_NO_USERNAME",
207
+ "STM_SETLIVE_EMPTY_BRANCH",
208
+ "STM_CONFIG_VDF_INVALID",
209
+ "STM_CONFIG_VDF_CONFLICT",
210
+ "STM_TOTP_INVALID",
211
+ "STM_CONTENT_ROOT_MISSING",
212
+ "STM_EXECUTABLE_MISSING",
213
+ "WX_KEY_NOT_FOUND",
214
+ "WX_KEY_INVALID",
215
+ "WX_CI_NOT_INSTALLED",
216
+ "WX_CI_VERSION_UNSUPPORTED"
217
+ ]);
218
+ var STEAM_RUN_FAILED = /* @__PURE__ */ new Set([
219
+ "STM_BUILD_FAILED",
220
+ "STM_BUILD_UNKNOWN_OUTCOME",
221
+ "STM_LOGIN_GUARD_REQUIRED"
222
+ ]);
223
+ var WX_RUN_FAILED = /* @__PURE__ */ new Set([
224
+ "WX_UPLOAD_FAILED",
225
+ "WX_PREVIEW_FAILED"
226
+ ]);
227
+ function exitCodeFor(code) {
228
+ if (STEAM_RUN_FAILED.has(code)) return 3;
229
+ if (WX_RUN_FAILED.has(code)) return 4;
230
+ if (VALIDATION_REJECT.has(code)) return 1;
231
+ if (PREREQUISITE.has(code)) return 2;
232
+ return 1;
233
+ }
234
+
235
+ // src/core/config.ts
236
+ import path3 from "path";
237
+ import fsp2 from "fs/promises";
238
+
239
+ // src/core/schema.ts
240
+ import fs from "fs";
241
+ import path from "path";
242
+ import { fileURLToPath } from "url";
243
+ import Ajv from "ajv";
244
+ var ajv = new Ajv({ allErrors: true, strict: false });
245
+ function schemasDir() {
246
+ const here = path.dirname(fileURLToPath(import.meta.url));
247
+ const candidates = [path.resolve(here, "..", "schemas"), path.resolve(here, "..", "..", "schemas")];
248
+ for (const candidate of candidates) {
249
+ if (fs.existsSync(candidate)) return candidate;
250
+ }
251
+ return candidates[0];
252
+ }
253
+ var compiled = /* @__PURE__ */ new Map();
254
+ function loadSchemaFile(schemaFile) {
255
+ const file = path.join(schemasDir(), schemaFile);
256
+ const raw = fs.readFileSync(file, "utf8");
257
+ return JSON.parse(raw);
258
+ }
259
+ function validateAgainstSchema(data, schemaFile) {
260
+ let validate = compiled.get(schemaFile);
261
+ if (validate === void 0) {
262
+ validate = ajv.compile(loadSchemaFile(schemaFile));
263
+ compiled.set(schemaFile, validate);
264
+ }
265
+ const ok = validate(data);
266
+ if (ok) return [];
267
+ const issues = [];
268
+ for (const err of validate.errors ?? []) {
269
+ issues.push({ instancePath: err.instancePath || "/", message: err.message ?? "invalid" });
270
+ }
271
+ return issues;
272
+ }
273
+ function assertSchema(documentName, data, schemaFile) {
274
+ const issues = validateAgainstSchema(data, schemaFile);
275
+ if (issues.length > 0) {
276
+ throw new CliError("CFG_SCHEMA_INVALID", `${documentName} failed schema validation`, {
277
+ details: issues.map((issue) => `${issue.instancePath}: ${issue.message}`)
278
+ });
279
+ }
280
+ }
281
+
282
+ // src/core/fsx.ts
283
+ import { spawn } from "child_process";
284
+ import { randomBytes } from "crypto";
285
+ import fs2 from "fs";
286
+ import fsp from "fs/promises";
287
+ import path2 from "path";
288
+ async function pathExists(target) {
289
+ try {
290
+ await fsp.access(target);
291
+ return true;
292
+ } catch {
293
+ return false;
294
+ }
295
+ }
296
+ async function isDirectory(target) {
297
+ try {
298
+ return (await fsp.stat(target)).isDirectory();
299
+ } catch {
300
+ return false;
301
+ }
302
+ }
303
+ async function isFile(target) {
304
+ try {
305
+ return (await fsp.stat(target)).isFile();
306
+ } catch {
307
+ return false;
308
+ }
309
+ }
310
+ function isInsidePath(base, target) {
311
+ const rel = path2.relative(path2.resolve(base), path2.resolve(target));
312
+ return rel === "" || rel !== ".." && !rel.startsWith(`..${path2.sep}`) && !path2.isAbsolute(rel);
313
+ }
314
+ function actualPath(target) {
315
+ let current = path2.resolve(target);
316
+ const tail = [];
317
+ for (; ; ) {
318
+ try {
319
+ return path2.join(fs2.realpathSync.native(current), ...tail);
320
+ } catch (err) {
321
+ if (err.code !== "ENOENT") throw err;
322
+ try {
323
+ if (fs2.lstatSync(current).isSymbolicLink())
324
+ throw new CliError(
325
+ "PAT_ESCAPE",
326
+ `dangling directory link: ${current}`
327
+ );
328
+ } catch (linkErr) {
329
+ if (linkErr.code !== "ENOENT") throw linkErr;
330
+ }
331
+ const parent = path2.dirname(current);
332
+ if (parent === current) throw err;
333
+ tail.unshift(path2.basename(current));
334
+ current = parent;
335
+ }
336
+ }
337
+ }
338
+ function assertRelativePath(segment) {
339
+ if (!segment || [...segment].some((char) => char.charCodeAt(0) < 32) || path2.posix.isAbsolute(segment) || path2.win32.isAbsolute(segment) || /^[A-Za-z]:/.test(segment) || segment.split(/[/\\]/).includes("..")) {
340
+ throw new CliError(
341
+ "PAT_ESCAPE",
342
+ `expected a safe relative path: ${segment}`
343
+ );
344
+ }
345
+ }
346
+ function safeJoin(base, segment) {
347
+ assertRelativePath(segment);
348
+ const resolved = path2.resolve(base, segment.replace(/[/\\]/g, path2.sep));
349
+ if (!isInsidePath(base, resolved) || !isInsidePath(actualPath(base), actualPath(resolved))) {
350
+ throw new CliError(
351
+ "PAT_ESCAPE",
352
+ `resolved path escapes the allowed base: ${resolved} (base: ${path2.resolve(base)})`
353
+ );
354
+ }
355
+ return resolved;
356
+ }
357
+ async function atomicWriteFile(target, data) {
358
+ try {
359
+ if ((await fsp.lstat(target)).isSymbolicLink())
360
+ throw new CliError(
361
+ "PAT_ESCAPE",
362
+ `refusing to replace a linked file: ${target}`
363
+ );
364
+ } catch (err) {
365
+ if (err.code !== "ENOENT") throw err;
366
+ }
367
+ await fsp.mkdir(path2.dirname(target), { recursive: true });
368
+ const tmp = path2.join(
369
+ path2.dirname(target),
370
+ `.${path2.basename(target)}.${randomBytes(6).toString("hex")}.tmp`
371
+ );
372
+ await fsp.writeFile(tmp, data);
373
+ try {
374
+ await fsp.rename(tmp, target);
375
+ } catch (err) {
376
+ await fsp.rm(tmp, { force: true });
377
+ throw err;
378
+ }
379
+ }
380
+ function expandHome(input) {
381
+ if (input === "~") return homedir();
382
+ if (input.startsWith("~/") || input.startsWith("~\\")) {
383
+ return path2.join(homedir(), input.slice(2));
384
+ }
385
+ return input;
386
+ }
387
+ function homedir() {
388
+ return process.env.USERPROFILE ?? process.env.HOME ?? "";
389
+ }
390
+ async function openUrl(url) {
391
+ const cmd = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open";
392
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
393
+ await new Promise((resolve, reject) => {
394
+ const child = spawn(cmd, args, {
395
+ stdio: "ignore",
396
+ detached: true,
397
+ windowsHide: true
398
+ });
399
+ child.on("error", reject);
400
+ child.on("spawn", () => {
401
+ child.unref();
402
+ resolve();
403
+ });
404
+ });
405
+ }
406
+
407
+ // src/core/config.ts
408
+ var CONFIG_FILE_NAME = "ship.config.json";
409
+ var DEFAULT_WX_LIMITS = {
410
+ wxMainBytes: 4 * 1024 * 1024,
411
+ wxIndependentSubpackageBytes: 4 * 1024 * 1024,
412
+ wxTotalBytes: 30 * 1024 * 1024
413
+ };
414
+ var CONFIG_TEMPLATE = {
415
+ schemaVersion: 1,
416
+ name: "my-game",
417
+ steam: {
418
+ appId: 1e6,
419
+ contentRoot: "build/steam",
420
+ buildOutput: "build/steam-steamcmd-out",
421
+ executable: "MyGame.exe",
422
+ depots: [
423
+ {
424
+ id: 1000001,
425
+ source: ".",
426
+ exclusions: ["*.pdb", "*_BurstDebugInformation_*"]
427
+ }
428
+ ]
429
+ },
430
+ wechat: {
431
+ appid: "wx0000000000000000",
432
+ projectPath: "build/wx/minigame",
433
+ robot: 1
434
+ }
435
+ };
436
+ async function findConfigFile(startDir) {
437
+ let current = path3.resolve(startDir);
438
+ for (; ; ) {
439
+ const candidate = path3.join(current, CONFIG_FILE_NAME);
440
+ try {
441
+ const stat = await fsp2.stat(candidate);
442
+ if (stat.isFile()) return candidate;
443
+ } catch {
444
+ }
445
+ const parent = path3.dirname(current);
446
+ if (parent === current) return void 0;
447
+ current = parent;
448
+ }
449
+ }
450
+ async function loadConfig(startDir) {
451
+ const file = await findConfigFile(startDir);
452
+ if (file === void 0) {
453
+ throw new CliError(
454
+ "CFG_CONFIG_NOT_FOUND",
455
+ `no ${CONFIG_FILE_NAME} found in or above ${path3.resolve(startDir)}`
456
+ );
457
+ }
458
+ let raw;
459
+ try {
460
+ raw = await fsp2.readFile(file, "utf8");
461
+ } catch (err) {
462
+ throw new CliError(
463
+ "CFG_CONFIG_INVALID_JSON",
464
+ `cannot read ${file}: ${String(err)}`
465
+ );
466
+ }
467
+ let parsed;
468
+ try {
469
+ parsed = JSON.parse(raw);
470
+ } catch (err) {
471
+ throw new CliError(
472
+ "CFG_CONFIG_INVALID_JSON",
473
+ `${file} is not valid JSON: ${String(err)}`
474
+ );
475
+ }
476
+ assertSchema(CONFIG_FILE_NAME, parsed, "ship-config.schema.json");
477
+ const doc = parsed;
478
+ const projectRoot = path3.dirname(file);
479
+ const config = { ...doc, projectRoot };
480
+ if (config.steam?.steamcmdPath !== void 0) config.steam.steamcmdPath = path3.resolve(projectRoot, expandHome(config.steam.steamcmdPath));
481
+ if (config.steam === void 0 && config.wechat === void 0) {
482
+ throw new CliError(
483
+ "CFG_SCHEMA_INVALID",
484
+ `${file}: at least one of "steam" or "wechat" must be configured`
485
+ );
486
+ }
487
+ if (config.steam !== void 0) validateSteamCrossFields(config.steam, file);
488
+ if (config.wechat !== void 0)
489
+ safeJoin(projectRoot, config.wechat.projectPath);
490
+ return config;
491
+ }
492
+ function validateSteamCrossFields(steam, file) {
493
+ const projectRoot = path3.dirname(file);
494
+ const contentRoot = safeJoin(projectRoot, steam.contentRoot);
495
+ const buildOutput = safeJoin(projectRoot, steam.buildOutput);
496
+ if (steam.executable !== void 0) safeJoin(contentRoot, steam.executable);
497
+ const ids = /* @__PURE__ */ new Set();
498
+ for (const depot of steam.depots) {
499
+ if (ids.has(depot.id)) {
500
+ throw new CliError(
501
+ "CFG_DEPOT_DUPLICATE_ID",
502
+ `${file}: duplicate depot id ${depot.id}`
503
+ );
504
+ }
505
+ ids.add(depot.id);
506
+ if (depot.source !== ".") {
507
+ const resolved = safeJoin(contentRoot, depot.source);
508
+ if (!isInsidePath(actualPath(contentRoot), actualPath(resolved))) {
509
+ throw new CliError(
510
+ "CFG_DEPOT_SOURCE_OUTSIDE_CONTENT_ROOT",
511
+ `${file}: depot ${depot.id} source "${depot.source}" resolves outside contentRoot`
512
+ );
513
+ }
514
+ }
515
+ }
516
+ const buildOutputAbs = actualPath(buildOutput);
517
+ const contentRootAbs = actualPath(contentRoot);
518
+ if (isInsidePath(contentRootAbs, buildOutputAbs)) {
519
+ throw new CliError(
520
+ "CFG_CONFIG_PATH_CONFLICT",
521
+ `${file}: buildOutput must not be inside contentRoot (steamcmd requirement; chunk cache would be uploaded)`
522
+ );
523
+ }
524
+ if (isInsidePath(buildOutputAbs, contentRootAbs)) {
525
+ throw new CliError(
526
+ "CFG_CONFIG_PATH_CONFLICT",
527
+ `${file}: contentRoot must not be inside buildOutput (BuildOutput holds logs/cache, not content)`
528
+ );
529
+ }
530
+ }
531
+ function resolveFromProject(config, relative) {
532
+ return safeJoin(config.projectRoot, relative);
533
+ }
534
+ async function writeConfigTemplate(target, force) {
535
+ const exists = await fsp2.stat(target).then(
536
+ () => true,
537
+ () => false
538
+ );
539
+ if (exists && !force) {
540
+ throw new CliError(
541
+ "CFG_CONFIG_EXISTS",
542
+ `${target} already exists (use --force to overwrite)`
543
+ );
544
+ }
545
+ await atomicWriteFile(
546
+ target,
547
+ `${JSON.stringify(CONFIG_TEMPLATE, null, 2)}
548
+ `
549
+ );
550
+ }
551
+
552
+ // src/core/exec.ts
553
+ import { execFile, spawn as spawn2 } from "child_process";
554
+ import { StringDecoder } from "string_decoder";
555
+
556
+ // src/core/redact.ts
557
+ function redactSecrets(text, secrets) {
558
+ let out = text;
559
+ for (const secret of [...new Set(secrets)].sort(
560
+ (a, b) => (b?.length ?? 0) - (a?.length ?? 0)
561
+ )) {
562
+ if (secret !== void 0 && secret.length > 0) {
563
+ out = out.split(secret).join("***");
564
+ }
565
+ }
566
+ return out;
567
+ }
568
+
569
+ // src/core/exec.ts
570
+ var RedactedStream = class {
571
+ constructor(secrets) {
572
+ this.secrets = secrets;
573
+ }
574
+ pending = "";
575
+ decoder = new StringDecoder("utf8");
576
+ push(chunk) {
577
+ this.pending += chunk ? this.decoder.write(chunk) : this.decoder.end();
578
+ let cut = chunk ? Math.max(
579
+ 0,
580
+ this.pending.length - Math.max(0, ...this.secrets.map((s) => s.length - 1))
581
+ ) : this.pending.length;
582
+ for (const secret of this.secrets) {
583
+ for (let at = this.pending.indexOf(secret); at >= 0; at = this.pending.indexOf(secret, at + 1)) {
584
+ if (at < cut && at + secret.length > cut) cut = at;
585
+ }
586
+ }
587
+ const safe = redactSecrets(this.pending.slice(0, cut), this.secrets);
588
+ this.pending = this.pending.slice(cut);
589
+ return safe;
590
+ }
591
+ };
592
+ var runProcess = async function runProcess2(cmd, args, options = {}) {
593
+ const redact = options.redact ?? [];
594
+ return await new Promise((resolve, reject) => {
595
+ const child = spawn2(cmd, args, {
596
+ cwd: options.cwd,
597
+ env: options.env ? { ...process.env, ...options.env } : process.env,
598
+ windowsHide: true
599
+ });
600
+ let stdout = "";
601
+ let stderr = "";
602
+ let finished = false;
603
+ let timedOut = false;
604
+ const streams = {
605
+ stdout: new RedactedStream(redact.filter(Boolean)),
606
+ stderr: new RedactedStream(redact.filter(Boolean))
607
+ };
608
+ const lines = { stdout: "", stderr: "" };
609
+ const timer = options.timeoutMs !== void 0 ? setTimeout(() => {
610
+ timedOut = true;
611
+ if (process.platform === "win32" && child.pid)
612
+ execFile(
613
+ "taskkill",
614
+ ["/PID", String(child.pid), "/T", "/F"],
615
+ { windowsHide: true },
616
+ () => {
617
+ }
618
+ );
619
+ else child.kill("SIGKILL");
620
+ }, options.timeoutMs) : void 0;
621
+ const pump = (chunk, channel) => {
622
+ const safe = streams[channel].push(chunk);
623
+ if (channel === "stdout") stdout += safe;
624
+ else stderr += safe;
625
+ if (options.onLine) {
626
+ lines[channel] += safe;
627
+ const complete = lines[channel].split(/\r?\n/);
628
+ lines[channel] = complete.pop();
629
+ for (const line of complete) if (line) options.onLine(line, channel);
630
+ if (!chunk && lines[channel]) {
631
+ options.onLine(lines[channel], channel);
632
+ lines[channel] = "";
633
+ }
634
+ }
635
+ };
636
+ child.stdout.on("data", (chunk) => pump(chunk, "stdout"));
637
+ child.stderr.on("data", (chunk) => pump(chunk, "stderr"));
638
+ child.on("error", (err) => {
639
+ if (finished) return;
640
+ finished = true;
641
+ if (timer !== void 0) clearTimeout(timer);
642
+ reject(
643
+ new CliError(
644
+ "PROCESS_START_FAILED",
645
+ `cannot start ${cmd}: ${redactSecrets(err.message, redact)}`
646
+ )
647
+ );
648
+ });
649
+ child.on("close", (code) => {
650
+ if (finished) return;
651
+ finished = true;
652
+ if (timer !== void 0) clearTimeout(timer);
653
+ pump(void 0, "stdout");
654
+ pump(void 0, "stderr");
655
+ if (timedOut) {
656
+ reject(
657
+ new CliError(
658
+ "PROCESS_TIMEOUT",
659
+ `process exceeded ${options.timeoutMs}ms`,
660
+ { details: { stdout, stderr } }
661
+ )
662
+ );
663
+ return;
664
+ }
665
+ resolve({ code: code ?? -1, stdout, stderr });
666
+ });
667
+ child.stdin.end();
668
+ });
669
+ };
670
+ function lookupOnPath(executable) {
671
+ const isWin = process.platform === "win32";
672
+ const name = isWin && !/\.(exe|cmd|bat)$/i.test(executable) ? `${executable}.exe` : executable;
673
+ return new Promise((resolve) => {
674
+ execFile(
675
+ isWin ? "where" : "which",
676
+ [name],
677
+ { windowsHide: true },
678
+ (err, stdout) => {
679
+ if (err || !stdout) {
680
+ resolve(void 0);
681
+ return;
682
+ }
683
+ const first = stdout.toString().split(/\r?\n/).find((line) => line.trim().length > 0);
684
+ resolve(first?.trim() || void 0);
685
+ }
686
+ );
687
+ });
688
+ }
689
+
690
+ // src/steam/vdf.ts
691
+ function escapeVdfString(value) {
692
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
693
+ }
694
+ function quoteVdf(value) {
695
+ return `"${escapeVdfString(value)}"`;
696
+ }
697
+ function vdfPath(value) {
698
+ return value.replace(/\\/g, "/");
699
+ }
700
+ function renderAppBuildVdf(input) {
701
+ const lines = [];
702
+ lines.push('"AppBuild"');
703
+ lines.push("{");
704
+ lines.push(` "AppID" "${input.appId}"`);
705
+ lines.push(` "Desc" ${quoteVdf(input.desc)}`);
706
+ lines.push(` "ContentRoot" ${quoteVdf(vdfPath(input.contentRoot) + "/")}`);
707
+ lines.push(` "BuildOutput" ${quoteVdf(vdfPath(input.buildOutput) + "/")}`);
708
+ lines.push(` "SetLive" ${quoteVdf(input.setLive)}`);
709
+ lines.push(` "Preview" "${input.preview ? "1" : "0"}"`);
710
+ lines.push(` "Local" ""`);
711
+ lines.push("");
712
+ lines.push(' "Depots"');
713
+ lines.push(" {");
714
+ for (const depot of input.depots) {
715
+ lines.push(` "${depot.id}" ${quoteVdf(depot.vdf)}`);
716
+ }
717
+ lines.push(" }");
718
+ lines.push("}");
719
+ return `${lines.join("\n")}
720
+ `;
721
+ }
722
+ function renderDepotBuildVdf(input) {
723
+ const localPath = input.source === "." ? "*" : `${vdfPath(input.source)}/*`;
724
+ const lines = [];
725
+ lines.push('"DepotBuild"');
726
+ lines.push("{");
727
+ lines.push(` "DepotID" "${input.depotId}"`);
728
+ lines.push("");
729
+ lines.push(' "FileMapping"');
730
+ lines.push(" {");
731
+ lines.push(` "LocalPath" ${quoteVdf(localPath)}`);
732
+ lines.push(` "DepotPath" "."`);
733
+ lines.push(` "Recursive" "true"`);
734
+ lines.push(" }");
735
+ for (const exclusion of input.exclusions) {
736
+ lines.push(` "FileExclusion" ${quoteVdf(exclusion)}`);
737
+ }
738
+ lines.push("}");
739
+ return `${lines.join("\n")}
740
+ `;
741
+ }
742
+ function appBuildVdfName(appId) {
743
+ return `app_build_${appId}.vdf`;
744
+ }
745
+ function depotBuildVdfName(depotId) {
746
+ return `depot_build_${depotId}.vdf`;
747
+ }
748
+
749
+ // src/steam/totp.ts
750
+ import { createHmac } from "crypto";
751
+ var ALPHABET = "23456789BCDFGHJKMNPQRTVWXY";
752
+ function generateSteamTotp(secretBase64, timeSeconds) {
753
+ let key;
754
+ try {
755
+ key = Buffer.from(secretBase64, "base64");
756
+ } catch {
757
+ throw new CliError("STM_TOTP_INVALID", "STEAM shared secret is not valid base64");
758
+ }
759
+ if (key.length === 0) {
760
+ throw new CliError("STM_TOTP_INVALID", "STEAM shared secret decodes to zero bytes");
761
+ }
762
+ const timeSlice = Math.floor(timeSeconds / 30);
763
+ const message = Buffer.alloc(8);
764
+ message.writeBigUInt64BE(BigInt(timeSlice));
765
+ const digest = createHmac("sha1", key).update(message).digest();
766
+ const offset = digest[digest.length - 1] & 15;
767
+ const code = digest.readUInt32BE(offset) & 2147483647;
768
+ let out = "";
769
+ let remaining = code;
770
+ for (let i = 0; i < 5; i += 1) {
771
+ out += ALPHABET[remaining % ALPHABET.length];
772
+ remaining = Math.floor(remaining / ALPHABET.length);
773
+ }
774
+ return out;
775
+ }
776
+
777
+ // src/steam/logparse.ts
778
+ var ERROR_PATTERNS = [
779
+ {
780
+ re: /\bERROR[!:]|\bFailed\b/i,
781
+ hint: "steamcmd reported a failure; inspect the build log"
782
+ },
783
+ {
784
+ re: /Login Failure|Account Login Denied|Invalid Password/i,
785
+ hint: "login rejected: check STEAM credentials; first-time logins need a Steam Guard code (SHIP_STEAM_TOTP / SHIP_STEAM_CONFIG_VDF)"
786
+ },
787
+ {
788
+ re: /Invalid content configuration/i,
789
+ hint: "app/depot configuration not saved on the partner backend, or build account lacks Edit App Metadata"
790
+ },
791
+ {
792
+ re: /Failed to get application info for app \d+/i,
793
+ hint: "app config not yet live on the backend or wrong build account; also check the AppID"
794
+ },
795
+ {
796
+ re: /Timed out waiting for AppInfo update/i,
797
+ hint: "steamcmd timed out waiting for app info; usually transient, retry"
798
+ },
799
+ {
800
+ re: /set_steam_guard_code/i,
801
+ hint: "steamcmd is asking for a Steam Guard code; provide SHIP_STEAM_TOTP or seed config.vdf via SHIP_STEAM_CONFIG_VDF"
802
+ }
803
+ ];
804
+ var FINISHED_RE = /Successfully finished\s+(?:AppID\s+)?(?:build\s+)?(\d+)\s+build(?:\s*\(BuildID\s+(\d+)\))?/i;
805
+ function analyzeSteamcmdOutput(stdout, stderr, exitCode, expected) {
806
+ const combined = `${stdout}
807
+ ${stderr}`;
808
+ const errors = [];
809
+ const hints = [];
810
+ for (const line of combined.split(/\r?\n/)) {
811
+ for (const pattern of ERROR_PATTERNS) {
812
+ if (pattern.re.test(line)) {
813
+ if (!errors.includes(line.trim())) errors.push(line.trim());
814
+ if (!hints.includes(pattern.hint)) hints.push(pattern.hint);
815
+ }
816
+ }
817
+ }
818
+ if (errors.length > 0) {
819
+ return { status: "failed", errors, hints };
820
+ }
821
+ if (exitCode !== 0) {
822
+ return {
823
+ status: "failed",
824
+ errors: [`steamcmd exited with code ${exitCode}`],
825
+ hints
826
+ };
827
+ }
828
+ const finished = FINISHED_RE.exec(combined);
829
+ if (finished && (!expected || Number(finished[1]) === expected.appId) && (expected?.preview || finished[2])) {
830
+ return { status: "success", buildId: finished[2], errors, hints };
831
+ }
832
+ return {
833
+ status: "unknown",
834
+ errors: ["steamcmd exited 0 without a matching completed app-build record"],
835
+ hints: [
836
+ "inspect the full log; run with Preview first to validate the pipeline"
837
+ ]
838
+ };
839
+ }
840
+
841
+ // src/steam/steamcmd.ts
842
+ import fsp3 from "fs/promises";
843
+ import path4 from "path";
844
+
845
+ // src/core/logger.ts
846
+ function createLogger() {
847
+ return {
848
+ json: false,
849
+ info(message) {
850
+ process.stderr.write(`${message}
851
+ `);
852
+ },
853
+ warn(message) {
854
+ process.stderr.write(`warning: ${message}
855
+ `);
856
+ },
857
+ error(message) {
858
+ process.stderr.write(`error: ${message}
859
+ `);
860
+ },
861
+ result(payload) {
862
+ if (this.json && payload !== null && typeof payload === "object" && !Array.isArray(payload)) {
863
+ payload = { ok: true, ...payload };
864
+ }
865
+ if (typeof payload === "string") {
866
+ process.stdout.write(`${payload}
867
+ `);
868
+ } else {
869
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}
870
+ `);
871
+ }
872
+ }
873
+ };
874
+ }
875
+ var log = createLogger();
876
+
877
+ // src/steam/steamcmd.ts
878
+ function pick(env, suffix) {
879
+ const prefixed = env[`SHIP_STEAM_${suffix}`];
880
+ if (prefixed !== void 0 && prefixed !== "") return prefixed;
881
+ return void 0;
882
+ }
883
+ function resolveSteamCredentials(env) {
884
+ return {
885
+ username: pick(env, "USERNAME"),
886
+ password: pick(env, "PASSWORD"),
887
+ totp: pick(env, "TOTP"),
888
+ totpSecret: pick(env, "TOTP_SECRET"),
889
+ configVdf: pick(env, "CONFIG_VDF")
890
+ };
891
+ }
892
+ function describeCredentialMode(creds) {
893
+ if (creds.password !== void 0 && (creds.totp !== void 0 || creds.totpSecret !== void 0)) {
894
+ return "password + TOTP";
895
+ }
896
+ if (creds.password !== void 0) {
897
+ return "password only (first login may require a Steam Guard code)";
898
+ }
899
+ return "passwordless (requires a valid config.vdf next to steamcmd)";
900
+ }
901
+ function buildLoginArgs(creds, nowSeconds = Math.floor(Date.now() / 1e3)) {
902
+ if (creds.username === void 0 || creds.username === "") {
903
+ throw new CliError(
904
+ "STM_NO_USERNAME",
905
+ "Steam build account username missing: set SHIP_STEAM_USERNAME"
906
+ );
907
+ }
908
+ if (creds.password === void 0 || creds.password === "") {
909
+ return ["+login", creds.username];
910
+ }
911
+ const code = creds.totp ?? (creds.totpSecret !== void 0 ? generateSteamTotp(creds.totpSecret, nowSeconds) : void 0);
912
+ if (code !== void 0) {
913
+ return ["+login", creds.username, creds.password, code];
914
+ }
915
+ return ["+login", creds.username, creds.password];
916
+ }
917
+ var COMMON_STEAMCMD_DIRS = ["~/steamcmd", "C:/steamcmd", "D:/steamcmd"];
918
+ async function findSteamcmd(env, configPath) {
919
+ const candidates = [];
920
+ const fromEnv = env.SHIP_STEAMCMD;
921
+ const explicit = fromEnv || configPath;
922
+ if (explicit)
923
+ return await isFile(expandHome(explicit)) ? path4.resolve(expandHome(explicit)) : void 0;
924
+ const onPath = await lookupOnPath("steamcmd");
925
+ if (onPath) return onPath;
926
+ for (const dir of COMMON_STEAMCMD_DIRS) {
927
+ if (process.platform !== "win32") candidates.push(path4.join(expandHome(dir), "steamcmd.sh"));
928
+ candidates.push(
929
+ path4.join(
930
+ expandHome(dir),
931
+ process.platform === "win32" ? "steamcmd.exe" : "steamcmd"
932
+ )
933
+ );
934
+ }
935
+ for (const candidate of candidates) {
936
+ const expanded = expandHome(candidate);
937
+ if (await isFile(expanded)) return path4.resolve(expanded);
938
+ }
939
+ return void 0;
940
+ }
941
+ async function seedConfigVdf(steamcmdExe, base64Payload) {
942
+ let decoded;
943
+ try {
944
+ decoded = Buffer.from(base64Payload, "base64").toString("utf8");
945
+ } catch {
946
+ throw new CliError(
947
+ "STM_CONFIG_VDF_INVALID",
948
+ "SHIP_STEAM_CONFIG_VDF is not valid base64"
949
+ );
950
+ }
951
+ if (!decoded.includes("InstallConfigStore")) {
952
+ throw new CliError(
953
+ "STM_CONFIG_VDF_INVALID",
954
+ "SHIP_STEAM_CONFIG_VDF payload does not look like a steamcmd config.vdf (missing InstallConfigStore)"
955
+ );
956
+ }
957
+ const target = path4.join(
958
+ path4.dirname(path4.resolve(steamcmdExe)),
959
+ "config",
960
+ "config.vdf"
961
+ );
962
+ if (await pathExists(target)) {
963
+ const existing = await fsp3.readFile(target, "utf8");
964
+ if (existing === decoded) return target;
965
+ throw new CliError(
966
+ "STM_CONFIG_VDF_CONFLICT",
967
+ `${target} already exists and differs from SHIP_STEAM_CONFIG_VDF; remove it (or re-export a fresh config.vdf) before seeding. Remember: logging in with a password again invalidates the old token.`
968
+ );
969
+ }
970
+ await atomicWriteFile(target, decoded);
971
+ return target;
972
+ }
973
+ async function runSteamcmdBuild(input, secrets) {
974
+ const args = [
975
+ ...input.loginArgs,
976
+ "+run_app_build",
977
+ input.appBuildVdf,
978
+ "+quit"
979
+ ];
980
+ const run = input.run ?? runProcess;
981
+ const logsDir = path4.join(input.buildOutputDir, "logs");
982
+ await fsp3.mkdir(logsDir, { recursive: true });
983
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
984
+ const logPath = path4.join(logsDir, `steamcmd-${stamp}.log`);
985
+ const effectiveSecrets = [...secrets, ...input.loginArgs.slice(2)];
986
+ let result;
987
+ try {
988
+ result = await run(input.steamcmdExe, args, {
989
+ env: input.env,
990
+ redact: effectiveSecrets,
991
+ timeoutMs: 60 * 60 * 1e3,
992
+ onLine: (line) => {
993
+ if (!log.json) log.info(line);
994
+ }
995
+ });
996
+ } catch (err) {
997
+ const error = err;
998
+ await atomicWriteFile(
999
+ logPath,
1000
+ redactSecrets(
1001
+ JSON.stringify({ message: error.message, details: error.details }),
1002
+ effectiveSecrets
1003
+ )
1004
+ );
1005
+ throw new CliError(
1006
+ "STM_BUILD_FAILED",
1007
+ redactSecrets(error.message, effectiveSecrets),
1008
+ { details: { logPath } }
1009
+ );
1010
+ }
1011
+ const combined = redactSecrets(
1012
+ `${result.stdout}
1013
+ ${result.stderr}`,
1014
+ effectiveSecrets
1015
+ );
1016
+ await atomicWriteFile(
1017
+ logPath,
1018
+ combined.endsWith("\n") ? combined : `${combined}
1019
+ `
1020
+ );
1021
+ return { exitCode: result.code, logPath, combinedOutput: combined };
1022
+ }
1023
+ function steamBuildsUrl(appId) {
1024
+ return `https://partner.steamgames.com/apps/builds/${appId}`;
1025
+ }
1026
+
1027
+ // src/wx/gamejson.ts
1028
+ function gameRelativePath(value) {
1029
+ if (value.startsWith("//") || value.includes("\\") || /^[A-Za-z]:/.test(value) || [...value].some((char) => char.charCodeAt(0) < 32))
1030
+ throw new Error("unsafe game path");
1031
+ const normalized = value.replace(/^\//, "").replace(/\/$/, "");
1032
+ if (!normalized || normalized.split("/").some((part) => part === ".." || part === "." || part === ""))
1033
+ throw new Error("unsafe game path");
1034
+ return normalized;
1035
+ }
1036
+ function parseGameJson(text) {
1037
+ let parsed;
1038
+ try {
1039
+ parsed = JSON.parse(text);
1040
+ } catch (err) {
1041
+ return { ok: false, error: `game.json is not valid JSON: ${String(err)}` };
1042
+ }
1043
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1044
+ return { ok: false, error: "game.json must be a JSON object" };
1045
+ }
1046
+ const obj = parsed;
1047
+ if (obj.subpackages !== void 0 && obj.subPackages !== void 0)
1048
+ return { ok: false, error: "use one subpackages spelling, not both" };
1049
+ const rawSubs = obj.subpackages ?? obj.subPackages;
1050
+ if (rawSubs !== void 0 && !Array.isArray(rawSubs)) {
1051
+ return { ok: false, error: "game.json subpackages must be an array" };
1052
+ }
1053
+ const subpackages = [];
1054
+ for (const [index, raw] of (rawSubs ?? []).entries()) {
1055
+ if (typeof raw !== "object" || raw === null) {
1056
+ return {
1057
+ ok: false,
1058
+ error: `game.json subpackages[${index}] must be an object`
1059
+ };
1060
+ }
1061
+ const entry = raw;
1062
+ if (typeof entry.name !== "string" || entry.name.length === 0) {
1063
+ return {
1064
+ ok: false,
1065
+ error: `game.json subpackages[${index}].name must be a non-empty string`
1066
+ };
1067
+ }
1068
+ if (typeof entry.root !== "string" || entry.root.length === 0) {
1069
+ return {
1070
+ ok: false,
1071
+ error: `game.json subpackages[${index}].root must be a non-empty string`
1072
+ };
1073
+ }
1074
+ let root;
1075
+ try {
1076
+ root = gameRelativePath(entry.root);
1077
+ } catch {
1078
+ return {
1079
+ ok: false,
1080
+ error: `game.json subpackages[${index}].root must be a safe relative path (got "${entry.root}")`
1081
+ };
1082
+ }
1083
+ if (entry.independent !== void 0 && typeof entry.independent !== "boolean")
1084
+ return {
1085
+ ok: false,
1086
+ error: `subpackages[${index}].independent must be boolean`
1087
+ };
1088
+ if (subpackages.some((existing) => existing.name === entry.name)) {
1089
+ return {
1090
+ ok: false,
1091
+ error: `game.json subpackages: duplicate name "${entry.name}"`
1092
+ };
1093
+ }
1094
+ subpackages.push({
1095
+ name: entry.name,
1096
+ root,
1097
+ independent: entry.independent === true
1098
+ });
1099
+ }
1100
+ let openDataContext;
1101
+ if (obj.openDataContext !== void 0) {
1102
+ if (typeof obj.openDataContext !== "string" || obj.openDataContext.length === 0) {
1103
+ return {
1104
+ ok: false,
1105
+ error: "game.json openDataContext must be a non-empty string"
1106
+ };
1107
+ }
1108
+ try {
1109
+ openDataContext = gameRelativePath(obj.openDataContext);
1110
+ } catch {
1111
+ return {
1112
+ ok: false,
1113
+ error: "game.json openDataContext must be a safe relative path"
1114
+ };
1115
+ }
1116
+ }
1117
+ let workers;
1118
+ if (obj.workers !== void 0) {
1119
+ try {
1120
+ if (typeof obj.workers === "string")
1121
+ workers = gameRelativePath(obj.workers);
1122
+ else if (obj.workers && typeof obj.workers === "object" && !Array.isArray(obj.workers)) {
1123
+ const worker = obj.workers;
1124
+ if (typeof worker.path !== "string" || worker.isSubpackage !== void 0 && typeof worker.isSubpackage !== "boolean")
1125
+ throw Error();
1126
+ workers = {
1127
+ path: gameRelativePath(worker.path),
1128
+ isSubpackage: worker.isSubpackage === true
1129
+ };
1130
+ } else throw Error();
1131
+ } catch {
1132
+ return {
1133
+ ok: false,
1134
+ error: "workers must specify a safe directory path and optional boolean isSubpackage"
1135
+ };
1136
+ }
1137
+ }
1138
+ return {
1139
+ ok: true,
1140
+ gameJson: {
1141
+ deviceOrientation: typeof obj.deviceOrientation === "string" ? obj.deviceOrientation : void 0,
1142
+ openDataContext,
1143
+ workers,
1144
+ subpackages
1145
+ }
1146
+ };
1147
+ }
1148
+
1149
+ // src/wx/validate.ts
1150
+ import fsp4 from "fs/promises";
1151
+ import path5 from "path";
1152
+
1153
+ // src/wx/files.ts
1154
+ import { minimatch } from "minimatch";
1155
+ function fileSelector(projectConfig, ignores = ["node_modules/**/*"]) {
1156
+ const options = projectConfig.packOptions;
1157
+ if (options !== void 0 && (!options || typeof options !== "object" || Array.isArray(options)))
1158
+ throw new CliError(
1159
+ "WX_PROJECT_CONFIG_INVALID",
1160
+ "packOptions must be an object"
1161
+ );
1162
+ const pack = options ?? {};
1163
+ function rules(value) {
1164
+ if (value === void 0) return [];
1165
+ if (!Array.isArray(value))
1166
+ throw new CliError(
1167
+ "WX_PROJECT_CONFIG_INVALID",
1168
+ "packOptions rules must be arrays"
1169
+ );
1170
+ return value.map((rule) => {
1171
+ if (!rule || typeof rule !== "object" || typeof rule.value !== "string" || !["file", "folder", "suffix", "prefix", "regexp", "glob"].includes(
1172
+ rule.type
1173
+ ))
1174
+ throw new CliError(
1175
+ "WX_PROJECT_CONFIG_INVALID",
1176
+ "invalid packOptions rule"
1177
+ );
1178
+ if (rule.type === "regexp")
1179
+ try {
1180
+ new RegExp(rule.value);
1181
+ } catch {
1182
+ throw new CliError(
1183
+ "WX_PROJECT_CONFIG_INVALID",
1184
+ "invalid packOptions regular expression"
1185
+ );
1186
+ }
1187
+ return { type: rule.type, value: rule.value };
1188
+ });
1189
+ }
1190
+ const include = rules(pack.include), ignore = rules(pack.ignore);
1191
+ const matches = (file, rule) => {
1192
+ const value = rule.value.replace(/\\/g, "/").replace(/^\//, "").replace(/\/$/, "");
1193
+ switch (rule.type) {
1194
+ case "file":
1195
+ return file === value;
1196
+ case "folder":
1197
+ return file.startsWith(value + "/");
1198
+ case "suffix":
1199
+ return file.endsWith(value);
1200
+ case "prefix":
1201
+ return file.startsWith(value);
1202
+ case "regexp":
1203
+ return new RegExp(rule.value).test(file);
1204
+ default:
1205
+ return minimatch(file, rule.value, { dot: true });
1206
+ }
1207
+ };
1208
+ return (file) => !ignores.some((pattern) => minimatch(file, pattern, { dot: true })) && (include.some((rule) => matches(file, rule)) || !ignore.some((rule) => matches(file, rule)));
1209
+ }
1210
+
1211
+ // src/wx/validate.ts
1212
+ var WARN_RATIO = 0.9;
1213
+ var ANALYSIS_TOP_N = 10;
1214
+ function problem(code, subject, message, override) {
1215
+ const fix = suggest(code);
1216
+ return {
1217
+ code,
1218
+ subject,
1219
+ message,
1220
+ suggestion: override?.suggestion ?? fix.suggestion,
1221
+ ...fix.docs !== void 0 ? { docs: fix.docs } : {}
1222
+ };
1223
+ }
1224
+ async function validateWechatProject(wechat, projectRoot, limitsOverride) {
1225
+ const limits = {
1226
+ main: limitsOverride?.wxMainBytes ?? DEFAULT_WX_LIMITS.wxMainBytes,
1227
+ independent: limitsOverride?.wxIndependentSubpackageBytes ?? DEFAULT_WX_LIMITS.wxIndependentSubpackageBytes,
1228
+ total: limitsOverride?.wxTotalBytes ?? DEFAULT_WX_LIMITS.wxTotalBytes
1229
+ };
1230
+ const projectPath = safeJoin(projectRoot, wechat.projectPath);
1231
+ const errors = [];
1232
+ const warnings = [];
1233
+ const analysis = [];
1234
+ const emptyReport = (code, message) => ({
1235
+ measurement: "filtered-source-estimate",
1236
+ ok: false,
1237
+ projectPath,
1238
+ appid: wechat.appid,
1239
+ limits,
1240
+ main: { files: 0, bytes: 0 },
1241
+ subpackages: [],
1242
+ total: { files: 0, bytes: 0 },
1243
+ openDataContext: null,
1244
+ errors: [problem(code, "project", message)],
1245
+ warnings,
1246
+ analysis
1247
+ });
1248
+ if (!await isDirectory(projectPath)) {
1249
+ return emptyReport(
1250
+ "WX_PROJECT_DIR_MISSING",
1251
+ `projectPath does not exist or is not a directory: ${projectPath}`
1252
+ );
1253
+ }
1254
+ const gameJsonPath = path5.join(projectPath, "game.json");
1255
+ let gameJsonText;
1256
+ try {
1257
+ gameJsonText = await fsp4.readFile(gameJsonPath, "utf8");
1258
+ } catch {
1259
+ return emptyReport(
1260
+ "WX_GAME_JSON_MISSING",
1261
+ `game.json not found at ${gameJsonPath}`
1262
+ );
1263
+ }
1264
+ const parsed = parseGameJson(gameJsonText);
1265
+ if (!parsed.ok || parsed.gameJson === void 0) {
1266
+ return emptyReport(
1267
+ "WX_GAME_JSON_INVALID",
1268
+ parsed.error ?? "game.json is invalid"
1269
+ );
1270
+ }
1271
+ const gameJson = parsed.gameJson;
1272
+ if (!await isFile(path5.join(projectPath, "game.js"))) {
1273
+ errors.push(
1274
+ problem(
1275
+ "WX_GAME_ENTRY_MISSING",
1276
+ "main package",
1277
+ "main package entry game.js is missing"
1278
+ )
1279
+ );
1280
+ }
1281
+ let projectConfig = {};
1282
+ if (!await isFile(path5.join(projectPath, "project.config.json"))) {
1283
+ errors.push(
1284
+ problem(
1285
+ "WX_PROJECT_CONFIG_MISSING",
1286
+ "project",
1287
+ "project.config.json is missing (miniprogram-ci needs it; the Unity convertor normally emits it)"
1288
+ )
1289
+ );
1290
+ } else {
1291
+ try {
1292
+ projectConfig = JSON.parse(
1293
+ await fsp4.readFile(
1294
+ path5.join(projectPath, "project.config.json"),
1295
+ "utf8"
1296
+ )
1297
+ );
1298
+ if (!projectConfig || typeof projectConfig !== "object" || Array.isArray(projectConfig) || projectConfig.appid !== wechat.appid || projectConfig.compileType !== "game")
1299
+ throw Error(
1300
+ "project.config.json must be an object with matching appid and compileType=game"
1301
+ );
1302
+ } catch (err) {
1303
+ errors.push(
1304
+ problem(
1305
+ "WX_PROJECT_CONFIG_INVALID",
1306
+ "project.config.json",
1307
+ String(err)
1308
+ )
1309
+ );
1310
+ }
1311
+ }
1312
+ let include;
1313
+ try {
1314
+ include = fileSelector(projectConfig, wechat.ignores);
1315
+ } catch (err) {
1316
+ return emptyReport("WX_PROJECT_CONFIG_INVALID", String(err));
1317
+ }
1318
+ for (const required of ["game.js", "game.json"])
1319
+ if (!include(required))
1320
+ errors.push(
1321
+ problem(
1322
+ "WX_GAME_ENTRY_MISSING",
1323
+ required,
1324
+ `${required} is excluded from the package`
1325
+ )
1326
+ );
1327
+ const resolvedSubs = [];
1328
+ for (const entry of gameJson.subpackages) {
1329
+ const abs = safeJoin(projectPath, entry.root);
1330
+ if (!isInsidePath(projectPath, abs)) {
1331
+ errors.push(
1332
+ problem(
1333
+ "WX_SUBPACKAGE_ROOT_OUTSIDE",
1334
+ `subpackage "${entry.name}"`,
1335
+ `root escapes projectPath: ${entry.root}`
1336
+ )
1337
+ );
1338
+ continue;
1339
+ }
1340
+ const isJsFile = /\.js$/i.test(entry.root);
1341
+ const exists = isJsFile ? await isFile(abs) : await isDirectory(abs);
1342
+ if (!exists) {
1343
+ errors.push(
1344
+ problem(
1345
+ "WX_SUBPACKAGE_ROOT_MISSING",
1346
+ `subpackage "${entry.name}"`,
1347
+ `root does not exist: ${entry.root}`
1348
+ )
1349
+ );
1350
+ } else if (isJsFile && !include(entry.root) || !isJsFile && (!await isFile(path5.join(abs, "game.js")) || !include(`${entry.root}/game.js`))) {
1351
+ errors.push(
1352
+ problem(
1353
+ "WX_GAME_ENTRY_MISSING",
1354
+ entry.root,
1355
+ `subpackage "${entry.name}" has no game.js at its root`
1356
+ )
1357
+ );
1358
+ }
1359
+ resolvedSubs.push({ entry, abs, isJsFile, exists });
1360
+ }
1361
+ for (let i = 0; i < resolvedSubs.length; i += 1) {
1362
+ for (let j = i + 1; j < resolvedSubs.length; j += 1) {
1363
+ const a = resolvedSubs[i];
1364
+ const b = resolvedSubs[j];
1365
+ if (isInsidePath(a.abs, b.abs) || isInsidePath(b.abs, a.abs)) {
1366
+ errors.push(
1367
+ problem(
1368
+ "WX_SUBPACKAGE_NESTED",
1369
+ `subpackages "${a.entry.name}" + "${b.entry.name}"`,
1370
+ "subpackage roots must not nest or overlap"
1371
+ )
1372
+ );
1373
+ }
1374
+ }
1375
+ }
1376
+ let openDataContext = null;
1377
+ if (gameJson.openDataContext !== void 0) {
1378
+ const oddAbs = safeJoin(projectPath, gameJson.openDataContext);
1379
+ if (!isInsidePath(projectPath, oddAbs)) {
1380
+ errors.push(
1381
+ problem(
1382
+ "WX_SUBPACKAGE_ROOT_OUTSIDE",
1383
+ "openDataContext",
1384
+ `openDataContext escapes projectPath: ${gameJson.openDataContext}`
1385
+ )
1386
+ );
1387
+ } else {
1388
+ if (!await isDirectory(oddAbs)) {
1389
+ errors.push(
1390
+ problem(
1391
+ "WX_GAME_ENTRY_MISSING",
1392
+ "openDataContext",
1393
+ `directory does not exist: ${gameJson.openDataContext}`
1394
+ )
1395
+ );
1396
+ }
1397
+ if (!await isFile(path5.join(oddAbs, "index.js")))
1398
+ errors.push(
1399
+ problem(
1400
+ "WX_GAME_ENTRY_MISSING",
1401
+ "openDataContext",
1402
+ "index.js is missing"
1403
+ )
1404
+ );
1405
+ for (const sub of resolvedSubs) {
1406
+ if (isInsidePath(sub.abs, oddAbs) || isInsidePath(oddAbs, sub.abs)) {
1407
+ errors.push(
1408
+ problem(
1409
+ "WX_SUBPACKAGE_OVERLAPS_OPEN_DATA",
1410
+ `openDataContext vs subpackage "${sub.entry.name}"`,
1411
+ "openDataContext must not overlap any subpackage"
1412
+ )
1413
+ );
1414
+ }
1415
+ }
1416
+ if (await isDirectory(oddAbs)) {
1417
+ const measured = await walkTree(oddAbs, {
1418
+ relBase: projectPath,
1419
+ include,
1420
+ ignores: wechat.ignores
1421
+ });
1422
+ openDataContext = {
1423
+ dir: gameJson.openDataContext,
1424
+ files: measured.files,
1425
+ bytes: measured.bytes
1426
+ };
1427
+ }
1428
+ }
1429
+ }
1430
+ if (gameJson.workers) {
1431
+ const worker = typeof gameJson.workers === "string" ? { path: gameJson.workers, isSubpackage: false } : gameJson.workers;
1432
+ const abs = safeJoin(projectPath, worker.path);
1433
+ if (!await isDirectory(abs))
1434
+ errors.push(
1435
+ problem(
1436
+ "WX_WORKERS_INVALID",
1437
+ worker.path,
1438
+ "workers directory is missing"
1439
+ )
1440
+ );
1441
+ if (worker.isSubpackage) {
1442
+ if (resolvedSubs.some(
1443
+ (sub) => isInsidePath(sub.abs, abs) || isInsidePath(abs, sub.abs)
1444
+ ) || gameJson.openDataContext && (isInsidePath(abs, safeJoin(projectPath, gameJson.openDataContext)) || isInsidePath(safeJoin(projectPath, gameJson.openDataContext), abs)))
1445
+ errors.push(
1446
+ problem(
1447
+ "WX_WORKERS_INVALID",
1448
+ worker.path,
1449
+ "worker package overlaps another package or open data context"
1450
+ )
1451
+ );
1452
+ else
1453
+ resolvedSubs.push({
1454
+ entry: { name: "__WORKERS__", root: worker.path, independent: false },
1455
+ abs,
1456
+ isJsFile: false,
1457
+ exists: await isDirectory(abs)
1458
+ });
1459
+ }
1460
+ }
1461
+ const subRoots = resolvedSubs.map((s) => s.abs);
1462
+ const mainWalk = await walkTree(projectPath, {
1463
+ skip: (abs) => subRoots.some((root) => isInsidePath(root, abs)),
1464
+ relBase: projectPath,
1465
+ include,
1466
+ ignores: wechat.ignores
1467
+ });
1468
+ const main2 = {
1469
+ files: mainWalk.files,
1470
+ bytes: mainWalk.bytes,
1471
+ limitBytes: limits.main
1472
+ };
1473
+ const subpackages = [];
1474
+ const subWalks = /* @__PURE__ */ new Map();
1475
+ for (const sub of resolvedSubs) {
1476
+ if (!sub.exists) {
1477
+ subpackages.push({
1478
+ name: sub.entry.name,
1479
+ root: sub.entry.root,
1480
+ independent: sub.entry.independent,
1481
+ rootExists: false,
1482
+ files: 0,
1483
+ bytes: 0
1484
+ });
1485
+ continue;
1486
+ }
1487
+ const walked = sub.isJsFile ? {
1488
+ files: 1,
1489
+ bytes: (await fsp4.stat(sub.abs)).size,
1490
+ entries: [
1491
+ {
1492
+ path: toPosix(path5.relative(projectPath, sub.abs)),
1493
+ bytes: (await fsp4.stat(sub.abs)).size
1494
+ }
1495
+ ]
1496
+ } : await walkTree(sub.abs, {
1497
+ relBase: projectPath,
1498
+ include,
1499
+ ignores: wechat.ignores
1500
+ });
1501
+ subWalks.set(sub.entry.name, walked);
1502
+ subpackages.push({
1503
+ name: sub.entry.name,
1504
+ root: sub.entry.root,
1505
+ independent: sub.entry.independent,
1506
+ rootExists: true,
1507
+ files: walked.files,
1508
+ bytes: walked.bytes,
1509
+ ...sub.entry.independent ? { limitBytes: limits.independent } : {}
1510
+ });
1511
+ }
1512
+ const totalBytes = main2.bytes + subpackages.reduce((sum, s) => sum + s.bytes, 0);
1513
+ const totalFiles = main2.files + subpackages.reduce((sum, s) => sum + s.files, 0);
1514
+ const total = {
1515
+ files: totalFiles,
1516
+ bytes: totalBytes,
1517
+ limitBytes: limits.total
1518
+ };
1519
+ if (main2.bytes > limits.main) {
1520
+ errors.push(
1521
+ problem(
1522
+ "WX_MAIN_OVER_LIMIT",
1523
+ "main package",
1524
+ `main package is ${formatBytes(main2.bytes)} but the limit is ${formatBytes(limits.main)}`
1525
+ )
1526
+ );
1527
+ } else if (main2.bytes >= limits.main * WARN_RATIO) {
1528
+ warnings.push(
1529
+ `main package is at ${Math.round(main2.bytes / limits.main * 100)}% of the limit`
1530
+ );
1531
+ }
1532
+ for (const sub of subpackages) {
1533
+ if (sub.independent && sub.bytes > limits.independent) {
1534
+ errors.push(
1535
+ problem(
1536
+ "WX_INDEPENDENT_OVER_LIMIT",
1537
+ `subpackage "${sub.name}" (independent)`,
1538
+ `size is ${formatBytes(sub.bytes)} but the limit is ${formatBytes(limits.independent)}`
1539
+ )
1540
+ );
1541
+ }
1542
+ }
1543
+ if (totalBytes > limits.total) {
1544
+ errors.push(
1545
+ problem(
1546
+ "WX_TOTAL_OVER_LIMIT",
1547
+ "total package",
1548
+ `total size is ${formatBytes(totalBytes)} but the limit is ${formatBytes(limits.total)}`
1549
+ )
1550
+ );
1551
+ } else if (totalBytes >= limits.total * WARN_RATIO) {
1552
+ warnings.push(
1553
+ `total package size is at ${Math.round(totalBytes / limits.total * 100)}% of the limit`
1554
+ );
1555
+ }
1556
+ if (main2.bytes >= limits.main * WARN_RATIO) {
1557
+ analysis.push({
1558
+ package: "main",
1559
+ files: topEntries(mainWalk.entries, ANALYSIS_TOP_N)
1560
+ });
1561
+ }
1562
+ for (const sub of subpackages) {
1563
+ const overIndependent = sub.independent && sub.bytes >= limits.independent * WARN_RATIO;
1564
+ if (overIndependent) {
1565
+ const walked = subWalks.get(sub.name);
1566
+ if (walked !== void 0)
1567
+ analysis.push({
1568
+ package: `subpackage "${sub.name}"`,
1569
+ files: topEntries(walked.entries, ANALYSIS_TOP_N)
1570
+ });
1571
+ }
1572
+ }
1573
+ if (totalBytes >= limits.total * WARN_RATIO) {
1574
+ const largest = [...subpackages].sort((a, b) => b.bytes - a.bytes)[0];
1575
+ if (largest !== void 0 && !analysis.some(
1576
+ (entry) => entry.package === `subpackage "${largest.name}"`
1577
+ )) {
1578
+ const walked = subWalks.get(largest.name);
1579
+ if (walked !== void 0)
1580
+ analysis.push({
1581
+ package: `subpackage "${largest.name}" (largest)`,
1582
+ files: topEntries(walked.entries, ANALYSIS_TOP_N)
1583
+ });
1584
+ }
1585
+ }
1586
+ return {
1587
+ measurement: "filtered-source-estimate",
1588
+ ok: errors.length === 0,
1589
+ projectPath,
1590
+ appid: wechat.appid,
1591
+ limits,
1592
+ main: main2,
1593
+ subpackages,
1594
+ total,
1595
+ openDataContext,
1596
+ errors,
1597
+ warnings,
1598
+ analysis
1599
+ };
1600
+ }
1601
+ async function walkTree(root, opts = {}) {
1602
+ const relBase = opts.relBase ?? root;
1603
+ let files = 0;
1604
+ let bytes = 0;
1605
+ const entries = [];
1606
+ const stack = [root];
1607
+ while (stack.length > 0) {
1608
+ const current = stack.pop();
1609
+ if (current === void 0) break;
1610
+ let dirents;
1611
+ try {
1612
+ dirents = await fsp4.readdir(current, { withFileTypes: true });
1613
+ } catch {
1614
+ throw new CliError(
1615
+ "WX_CONTENT_UNREADABLE",
1616
+ `cannot read directory: ${current}`
1617
+ );
1618
+ }
1619
+ for (const entry of dirents) {
1620
+ const full = path5.join(current, entry.name);
1621
+ if (opts.skip !== void 0 && opts.skip(full)) continue;
1622
+ const relative = toPosix(path5.relative(relBase, full));
1623
+ if ((entry.isDirectory() || entry.isSymbolicLink()) && (opts.ignores ?? ["node_modules/**/*"]).some((pattern) => {
1624
+ if (!pattern.endsWith("/**/*")) return false;
1625
+ const prefix = pattern.slice(0, -5);
1626
+ return ![...prefix].some((char) => "?*[{}".includes(char)) && (relative === prefix || relative.startsWith(`${prefix}/`));
1627
+ }))
1628
+ continue;
1629
+ if (entry.isSymbolicLink())
1630
+ throw new CliError(
1631
+ "WX_CONTENT_UNREADABLE",
1632
+ `packaged content contains a link: ${full}`
1633
+ );
1634
+ if (entry.isDirectory()) {
1635
+ stack.push(full);
1636
+ } else if (entry.isFile()) {
1637
+ if (opts.include && !opts.include(toPosix(path5.relative(relBase, full))))
1638
+ continue;
1639
+ let size = 0;
1640
+ try {
1641
+ size = (await fsp4.stat(full)).size;
1642
+ } catch {
1643
+ throw new CliError(
1644
+ "WX_CONTENT_UNREADABLE",
1645
+ `cannot measure file: ${full}`
1646
+ );
1647
+ }
1648
+ files += 1;
1649
+ bytes += size;
1650
+ entries.push({
1651
+ path: toPosix(path5.relative(relBase, full)),
1652
+ bytes: size
1653
+ });
1654
+ }
1655
+ }
1656
+ }
1657
+ return { files, bytes, entries };
1658
+ }
1659
+ function topEntries(entries, count) {
1660
+ return [...entries].sort((a, b) => b.bytes - a.bytes).slice(0, count);
1661
+ }
1662
+ function toPosix(input) {
1663
+ return input.replace(/\\/g, "/");
1664
+ }
1665
+ function formatBytes(bytes) {
1666
+ const mib = bytes / (1024 * 1024);
1667
+ if (mib >= 1) return `${mib.toFixed(2)}M`;
1668
+ return `${(bytes / 1024).toFixed(1)}K`;
1669
+ }
1670
+
1671
+ // src/wx/cicli.ts
1672
+ import { createRequire } from "module";
1673
+ import path6 from "path";
1674
+ import fsp5 from "fs/promises";
1675
+ import { createPrivateKey } from "crypto";
1676
+ async function resolveMiniprogramCi(candidateRoots) {
1677
+ for (const root of candidateRoots) {
1678
+ const req = createRequire(path6.join(path6.resolve(root), "noop.js"));
1679
+ let pkgJsonPath;
1680
+ try {
1681
+ pkgJsonPath = req.resolve("miniprogram-ci/package.json");
1682
+ } catch {
1683
+ continue;
1684
+ }
1685
+ try {
1686
+ const pkg = JSON.parse(await fsp5.readFile(pkgJsonPath, "utf8"));
1687
+ if (pkg.name === "miniprogram-ci") {
1688
+ const version = pkg.version?.match(/^(\d+)\.(\d+)\.(\d+)$/);
1689
+ if (!version || Number(version[1]) !== 2 || Number(version[2]) < 1 || Number(version[2]) === 1 && Number(version[3]) < 31)
1690
+ throw new CliError(
1691
+ "WX_CI_VERSION_UNSUPPORTED",
1692
+ `supported miniprogram-ci range is >=2.1.31 <3; found ${pkg.version}`
1693
+ );
1694
+ return path6.dirname(pkgJsonPath);
1695
+ }
1696
+ } catch (err) {
1697
+ if (err instanceof CliError) throw err;
1698
+ continue;
1699
+ }
1700
+ }
1701
+ throw new CliError(
1702
+ "WX_CI_NOT_INSTALLED",
1703
+ "miniprogram-ci is not resolvable; install it in the game project (npm i -D miniprogram-ci) or any parent directory"
1704
+ );
1705
+ }
1706
+ async function resolvePrivateKey(env, configPath, projectRoot) {
1707
+ const fromEnv = env.SHIP_WX_PRIVATE_KEY;
1708
+ const raw = fromEnv ?? configPath;
1709
+ if (raw === void 0 || raw === "") {
1710
+ throw new CliError(
1711
+ "WX_KEY_NOT_FOUND",
1712
+ "WeChat upload key not configured: set SHIP_WX_PRIVATE_KEY or wechat.privateKeyPath in ship.config.json. Download the key from MP backend: \u7BA1\u7406 -> \u5F00\u53D1\u7BA1\u7406 -> \u5F00\u53D1\u8BBE\u7F6E -> \u5C0F\u7A0B\u5E8F\u4EE3\u7801\u4E0A\u4F20"
1713
+ );
1714
+ }
1715
+ const expanded = expandHome(raw);
1716
+ const resolved = path6.resolve(projectRoot, expanded);
1717
+ if (!await isFile(resolved)) {
1718
+ throw new CliError(
1719
+ "WX_KEY_NOT_FOUND",
1720
+ `WeChat upload key file not found: ${resolved}`
1721
+ );
1722
+ }
1723
+ try {
1724
+ createPrivateKey(await fsp5.readFile(resolved));
1725
+ } catch {
1726
+ throw new CliError(
1727
+ "WX_KEY_INVALID",
1728
+ `WeChat upload key does not look like a PEM private key: ${resolved}`
1729
+ );
1730
+ }
1731
+ return resolved;
1732
+ }
1733
+ function js(value) {
1734
+ return JSON.stringify(value);
1735
+ }
1736
+ function buildDriverScript(input) {
1737
+ const lines = [
1738
+ `"use strict";`,
1739
+ `const ci = require(${js(input.pkgDir)});`,
1740
+ `function done(label, payload) { process.stdout.write(label + JSON.stringify(payload) + "\\n"); }`,
1741
+ `(async () => {`,
1742
+ ` const project = new ci.Project({`,
1743
+ ` appid: ${js(input.appid)},`,
1744
+ ` type: "miniGame",`,
1745
+ ` projectPath: ${js(input.projectPath)},`,
1746
+ ` privateKeyPath: ${js(input.privateKeyPath)},`,
1747
+ ` ignores: ${js(input.ignores ?? ["node_modules/**/*"])}`,
1748
+ ` });`,
1749
+ ` const onProgressUpdate = (task) => { try { process.stderr.write("[ci] " + (typeof task === "string" ? task : JSON.stringify(task)) + "\\n"); } catch {} };`
1750
+ ];
1751
+ if (input.mode === "upload") {
1752
+ if (input.version === void 0 || input.version === "") {
1753
+ throw new CliError(
1754
+ "WX_VERSION_REQUIRED",
1755
+ "upload requires --version (becomes the dev-version number, e.g. 1.0.0)"
1756
+ );
1757
+ }
1758
+ lines.push(
1759
+ ` const result = await ci.upload({ project, version: ${js(input.version)}, desc: ${js(input.desc ?? "")}, robot: ${input.robot ?? 1}, setting: ${js({ useProjectConfig: true, ...input.setting })}, onProgressUpdate });`,
1760
+ ` done("SHIP_RESULT_JSON:", { completed: true, mode: "upload", appid: ${js(input.appid)}, result });`
1761
+ );
1762
+ } else {
1763
+ const dest = input.qrcodeOutputDest ?? "wx-preview-qr.png";
1764
+ lines.push(
1765
+ ` const result = await ci.preview({ project, desc: ${js(input.desc ?? "")}, robot: ${input.robot ?? 1}, setting: ${js({ useProjectConfig: true, ...input.setting })}, bigPackageSizeSupport: ${js(input.bigPackageSizeSupport ?? false)}, qrcodeFormat: "image", qrcodeOutputDest: ${js(path6.resolve(dest))}, onProgressUpdate });`,
1766
+ ` done("SHIP_RESULT_JSON:", { completed: true, mode: "preview", appid: ${js(input.appid)}, result });`
1767
+ );
1768
+ }
1769
+ lines.push(
1770
+ `})().catch((err) => { done("SHIP_ERROR_JSON:", { message: String((err && err.message) || err) }); process.exit(1); });`
1771
+ );
1772
+ return lines.join("\n");
1773
+ }
1774
+ var RESULT_MARK = "SHIP_RESULT_JSON:";
1775
+ var ERROR_MARK = "SHIP_ERROR_JSON:";
1776
+ async function runWxCi(input) {
1777
+ const script = buildDriverScript(input);
1778
+ const run = input.run ?? runProcess;
1779
+ const privateKey = await fsp5.readFile(input.privateKeyPath, "utf8");
1780
+ const secrets = [
1781
+ privateKey,
1782
+ ...privateKey.split(/\r?\n/).filter((line) => line && !line.startsWith("-----"))
1783
+ ];
1784
+ let result;
1785
+ try {
1786
+ result = await run(process.execPath, ["-e", script], {
1787
+ cwd: input.projectPath,
1788
+ timeoutMs: input.timeoutMs ?? 15 * 60 * 1e3,
1789
+ redact: secrets,
1790
+ onLine: (line) => {
1791
+ if (!log.json && !line.startsWith(RESULT_MARK) && !line.startsWith(ERROR_MARK))
1792
+ log.info(line);
1793
+ }
1794
+ });
1795
+ } catch (err) {
1796
+ const error = err;
1797
+ throw new CliError(
1798
+ input.mode === "upload" ? "WX_UPLOAD_FAILED" : "WX_PREVIEW_FAILED",
1799
+ redactSecrets(error.message, secrets),
1800
+ { details: error.details }
1801
+ );
1802
+ }
1803
+ const stdout = redactSecrets(result.stdout, secrets);
1804
+ const markers = stdout.split(/\r?\n/).filter(
1805
+ (line) => line.startsWith(RESULT_MARK) || line.startsWith(ERROR_MARK)
1806
+ );
1807
+ const markerLine = markers.length === 1 ? markers[0] : void 0;
1808
+ let parsed;
1809
+ let errorMessage;
1810
+ if (markerLine !== void 0) {
1811
+ try {
1812
+ parsed = JSON.parse(
1813
+ markerLine.slice(markerLine.indexOf(":") + 1)
1814
+ );
1815
+ if (markerLine.startsWith(ERROR_MARK)) {
1816
+ errorMessage = parsed !== null && typeof parsed === "object" ? String(
1817
+ parsed.message ?? "miniprogram-ci failed"
1818
+ ) : "miniprogram-ci returned an invalid error record";
1819
+ }
1820
+ if (markerLine.startsWith(RESULT_MARK)) {
1821
+ const payload = parsed;
1822
+ if (!payload || payload.completed !== true || payload.mode !== input.mode || payload.appid !== input.appid || !payload.result || typeof payload.result !== "object" || Array.isArray(payload.result))
1823
+ errorMessage = "miniprogram-ci completion protocol is invalid";
1824
+ else parsed = payload.result;
1825
+ }
1826
+ } catch {
1827
+ errorMessage = "miniprogram-ci returned an invalid JSON completion record";
1828
+ }
1829
+ } else errorMessage = "miniprogram-ci returned no unique completion record";
1830
+ if (input.mode === "preview" && result.code === 0 && !errorMessage) {
1831
+ const file = path6.resolve(input.qrcodeOutputDest ?? "wx-preview-qr.png");
1832
+ const bytes = await fsp5.readFile(file).catch(() => void 0);
1833
+ if (!bytes || bytes.length < 8 || !bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
1834
+ errorMessage = "preview completed without a valid PNG QR image";
1835
+ }
1836
+ return {
1837
+ exitCode: result.code,
1838
+ result: parsed,
1839
+ errorMessage,
1840
+ output: stdout + redactSecrets(result.stderr, secrets)
1841
+ };
1842
+ }
1843
+
1844
+ // src/cli.ts
1845
+ import path11 from "path";
1846
+ import { fileURLToPath as fileURLToPath2 } from "url";
1847
+ import { Command, CommanderError } from "commander";
1848
+
1849
+ // src/commands/doctor.ts
1850
+ import path7 from "path";
1851
+ async function runDoctor(startDir) {
1852
+ const checks = [];
1853
+ const report = {
1854
+ node: process.version,
1855
+ platform: process.platform,
1856
+ config: { found: false },
1857
+ checks
1858
+ };
1859
+ const configFile = await findConfigFile(startDir);
1860
+ report.config = configFile === void 0 ? { found: false } : { found: true, path: configFile };
1861
+ const config = configFile === void 0 ? void 0 : await safeLoad(startDir);
1862
+ if (configFile === void 0) {
1863
+ checks.push({
1864
+ name: "config",
1865
+ status: "fail",
1866
+ detail: `no ${CONFIG_FILE_NAME} in or above ${startDir} (config init creates one)`
1867
+ });
1868
+ } else if (config === void 0) {
1869
+ checks.push({
1870
+ name: "config",
1871
+ status: "fail",
1872
+ detail: `${configFile} failed to load; run: ship-cli config validate`
1873
+ });
1874
+ } else {
1875
+ checks.push({ name: "config", status: "ok", detail: configFile });
1876
+ }
1877
+ if (config?.steam !== void 0) {
1878
+ const steamExe = await findSteamcmd(process.env, config.steam.steamcmdPath);
1879
+ report.steam = {
1880
+ configured: true,
1881
+ steamcmd: steamExe === void 0 ? { found: false } : { found: true, path: steamExe }
1882
+ };
1883
+ if (steamExe === void 0) {
1884
+ checks.push({
1885
+ name: "steam:steamcmd",
1886
+ status: "fail",
1887
+ detail: "steamcmd not found; install from https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip and set SHIP_STEAMCMD or steam.steamcmdPath"
1888
+ });
1889
+ } else {
1890
+ checks.push({ name: "steam:steamcmd", status: "ok", detail: steamExe });
1891
+ }
1892
+ const creds = resolveSteamCredentials(process.env);
1893
+ const mode = describeCredentialMode(creds);
1894
+ report.steam.credentials = {
1895
+ mode,
1896
+ username: creds.username !== void 0,
1897
+ configVdfSeeded: creds.configVdf !== void 0
1898
+ };
1899
+ if (creds.username === void 0) {
1900
+ checks.push({
1901
+ name: "steam:credentials",
1902
+ status: "fail",
1903
+ detail: "SHIP_STEAM_USERNAME unset; steam push will not run"
1904
+ });
1905
+ } else if (!creds.password && !creds.configVdf && (!steamExe || !await isFile(
1906
+ path7.join(path7.dirname(steamExe), "config", "config.vdf")
1907
+ ))) {
1908
+ checks.push({
1909
+ name: "steam:credentials",
1910
+ status: "fail",
1911
+ detail: "passwordless login requires an existing config/config.vdf or SHIP_STEAM_CONFIG_VDF"
1912
+ });
1913
+ } else {
1914
+ checks.push({ name: "steam:credentials", status: "ok", detail: mode });
1915
+ }
1916
+ const contentRoot = path7.resolve(
1917
+ config.projectRoot,
1918
+ config.steam.contentRoot
1919
+ );
1920
+ if (!await isDirectory(contentRoot)) {
1921
+ checks.push({
1922
+ name: "steam:contentRoot",
1923
+ status: "fail",
1924
+ detail: `contentRoot does not exist yet: ${contentRoot}`
1925
+ });
1926
+ } else {
1927
+ checks.push({
1928
+ name: "steam:contentRoot",
1929
+ status: "ok",
1930
+ detail: contentRoot
1931
+ });
1932
+ }
1933
+ }
1934
+ if (config?.wechat !== void 0) {
1935
+ const projectPath = path7.resolve(
1936
+ config.projectRoot,
1937
+ config.wechat.projectPath
1938
+ );
1939
+ const exists = await isDirectory(projectPath);
1940
+ report.wechat = {
1941
+ configured: true,
1942
+ projectDir: { exists, path: projectPath }
1943
+ };
1944
+ checks.push({
1945
+ name: "wx:project",
1946
+ status: exists ? "ok" : "fail",
1947
+ detail: exists ? projectPath : `wechat projectPath does not exist yet: ${projectPath}`
1948
+ });
1949
+ try {
1950
+ const key = await resolvePrivateKey(
1951
+ process.env,
1952
+ config.wechat.privateKeyPath,
1953
+ config.projectRoot
1954
+ );
1955
+ if (isInsidePath(actualPath(projectPath), actualPath(key))) throw new CliError("WX_KEY_INVALID", "upload private key must be outside packaged game content");
1956
+ report.wechat.privateKey = { found: true, path: key };
1957
+ checks.push({ name: "wx:key", status: "ok", detail: key });
1958
+ } catch (err) {
1959
+ const message = err instanceof Error ? err.message : String(err);
1960
+ report.wechat.privateKey = { found: false, error: message };
1961
+ checks.push({ name: "wx:key", status: "fail", detail: message });
1962
+ }
1963
+ try {
1964
+ const ciDir = await resolveMiniprogramCi([
1965
+ projectPath,
1966
+ config.projectRoot
1967
+ ]);
1968
+ report.wechat.miniprogramCi = { found: true, dir: ciDir };
1969
+ checks.push({ name: "wx:miniprogram-ci", status: "ok", detail: ciDir });
1970
+ } catch (err) {
1971
+ const message = err instanceof Error ? err.message : String(err);
1972
+ report.wechat.miniprogramCi = { found: false };
1973
+ checks.push({
1974
+ name: "wx:miniprogram-ci",
1975
+ status: "fail",
1976
+ detail: message
1977
+ });
1978
+ }
1979
+ }
1980
+ return report;
1981
+ }
1982
+ async function safeLoad(startDir) {
1983
+ try {
1984
+ return await loadConfig(startDir);
1985
+ } catch {
1986
+ return void 0;
1987
+ }
1988
+ }
1989
+ function printDoctor(report, json) {
1990
+ if (json) {
1991
+ log.result({
1992
+ ...report,
1993
+ ok: !report.checks.some((check) => check.status === "fail")
1994
+ });
1995
+ return;
1996
+ }
1997
+ log.info(`ship-cli doctor`);
1998
+ log.info(` node ${report.node} platform ${report.platform}`);
1999
+ for (const check of report.checks) {
2000
+ const icon = check.status === "ok" ? "[ok]" : check.status === "warn" ? "[!!]" : "[XX]";
2001
+ log.info(` ${icon} ${check.name}: ${check.detail}`);
2002
+ }
2003
+ }
2004
+
2005
+ // src/commands/config.ts
2006
+ import path8 from "path";
2007
+ async function runConfigInit(startDir, force, json) {
2008
+ const target = path8.join(path8.resolve(startDir), CONFIG_FILE_NAME);
2009
+ await writeConfigTemplate(target, force);
2010
+ if (json) {
2011
+ log.result({ written: target, force });
2012
+ } else {
2013
+ log.info(`wrote ${target}`);
2014
+ log.info("edit appId/appid/paths, then run: ship-cli config validate");
2015
+ }
2016
+ }
2017
+ async function runConfigValidate(startDir, json) {
2018
+ const config = await loadConfig(startDir);
2019
+ if (json) {
2020
+ log.result({
2021
+ ok: true,
2022
+ projectRoot: config.projectRoot,
2023
+ steam: config.steam === void 0 ? null : { appId: config.steam.appId, depots: config.steam.depots.length },
2024
+ wechat: config.wechat === void 0 ? null : { appid: config.wechat.appid, robot: config.wechat.robot ?? 1 }
2025
+ });
2026
+ } else {
2027
+ log.info(`${path8.join(config.projectRoot, CONFIG_FILE_NAME)} OK`);
2028
+ if (config.steam !== void 0) {
2029
+ log.info(` steam: appId=${config.steam.appId}, depots=${config.steam.depots.map((d) => d.id).join(",")}`);
2030
+ }
2031
+ if (config.wechat !== void 0) {
2032
+ log.info(` wechat: appid=${config.wechat.appid}, robot=${config.wechat.robot ?? 1}`);
2033
+ }
2034
+ }
2035
+ }
2036
+ async function runConfigShow(startDir, json) {
2037
+ const config = await loadConfig(startDir);
2038
+ const { projectRoot, ...rest } = config;
2039
+ if (json) {
2040
+ log.result({ projectRoot, ...rest });
2041
+ } else {
2042
+ log.result(`${JSON.stringify({ projectRoot, ...rest }, null, 2)}`);
2043
+ }
2044
+ }
2045
+
2046
+ // src/commands/steam.ts
2047
+ import fsp6 from "fs/promises";
2048
+ import path9 from "path";
2049
+ function requireSteam(config) {
2050
+ if (config.steam === void 0) {
2051
+ throw new CliError(
2052
+ "CFG_SCHEMA_INVALID",
2053
+ "ship.config.json has no steam section"
2054
+ );
2055
+ }
2056
+ return config.steam;
2057
+ }
2058
+ function normalizeBranch(branch) {
2059
+ if (branch === void 0) return "";
2060
+ const trimmed = branch.trim();
2061
+ if (trimmed === "") {
2062
+ throw new CliError(
2063
+ "STM_SETLIVE_EMPTY_BRANCH",
2064
+ "--set-live must be a non-empty branch name when provided"
2065
+ );
2066
+ }
2067
+ if (trimmed.toLowerCase() === "default") {
2068
+ throw new CliError(
2069
+ "STM_SETLIVE_DEFAULT",
2070
+ `Steam forbids setting the default branch live automatically; set it manually in App Admin -> Builds (requires phone-verified build account for released apps)`
2071
+ );
2072
+ }
2073
+ return trimmed;
2074
+ }
2075
+ async function prepareSteamVdfs(config, opts) {
2076
+ const steam = requireSteam(config);
2077
+ const setLive = normalizeBranch(opts.setLive);
2078
+ const contentRoot = resolveFromProject(config, steam.contentRoot);
2079
+ const buildOutput = resolveFromProject(config, steam.buildOutput);
2080
+ const vdfDir = path9.join(buildOutput, "vdf");
2081
+ const depotNames = steam.depots.map((depot) => ({
2082
+ id: depot.id,
2083
+ vdf: depotBuildVdfName(depot.id)
2084
+ }));
2085
+ const appBuild = renderAppBuildVdf({
2086
+ appId: steam.appId,
2087
+ desc: opts.desc,
2088
+ contentRoot,
2089
+ buildOutput,
2090
+ setLive,
2091
+ preview: opts.preview,
2092
+ depots: depotNames
2093
+ });
2094
+ const appBuildPath = path9.join(vdfDir, appBuildVdfName(steam.appId));
2095
+ await atomicWriteFile(appBuildPath, appBuild);
2096
+ const depotPaths = [];
2097
+ for (const depot of steam.depots) {
2098
+ const content = renderDepotBuildVdf({
2099
+ depotId: depot.id,
2100
+ source: depot.source,
2101
+ exclusions: depot.exclusions ?? []
2102
+ });
2103
+ const depotPath = path9.join(vdfDir, depotBuildVdfName(depot.id));
2104
+ await atomicWriteFile(depotPath, content);
2105
+ depotPaths.push({ id: depot.id, path: depotPath });
2106
+ }
2107
+ return {
2108
+ vdfDir,
2109
+ appBuildPath,
2110
+ depotPaths,
2111
+ contentRoot,
2112
+ buildOutput,
2113
+ setLive,
2114
+ preview: opts.preview
2115
+ };
2116
+ }
2117
+ async function runSteamVdf(startDir, opts) {
2118
+ const config = await loadConfig(startDir);
2119
+ const prepared = await prepareSteamVdfs(config, opts);
2120
+ if (opts.json) {
2121
+ log.result({
2122
+ appBuild: prepared.appBuildPath,
2123
+ depots: prepared.depotPaths,
2124
+ setLive: prepared.setLive,
2125
+ preview: prepared.preview
2126
+ });
2127
+ return;
2128
+ }
2129
+ log.info(`wrote ${prepared.appBuildPath}`);
2130
+ for (const depot of prepared.depotPaths) {
2131
+ log.info(`wrote ${depot.path} (depot ${depot.id})`);
2132
+ }
2133
+ log.info(
2134
+ `preview=${prepared.preview ? "1 (dry-run, uploads nothing)" : "0 (real build)"} setLive="${prepared.setLive}"`
2135
+ );
2136
+ }
2137
+ async function runSteamBuild(startDir, opts) {
2138
+ const config = await loadConfig(startDir);
2139
+ const steam = requireSteam(config);
2140
+ const creds = resolveSteamCredentials(process.env);
2141
+ const loginArgs = buildLoginArgs(creds);
2142
+ const steamcmdExe = await findSteamcmd(process.env, steam.steamcmdPath);
2143
+ if (steamcmdExe === void 0) {
2144
+ throw new CliError(
2145
+ "STM_STEAMCMD_NOT_FOUND",
2146
+ "steamcmd not found; download https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip, then set SHIP_STEAMCMD or steam.steamcmdPath"
2147
+ );
2148
+ }
2149
+ const prepared = await prepareSteamVdfs(config, opts);
2150
+ if (!await isDirectory(prepared.contentRoot)) {
2151
+ throw new CliError(
2152
+ "STM_CONTENT_ROOT_MISSING",
2153
+ `contentRoot does not exist: ${prepared.contentRoot}`
2154
+ );
2155
+ }
2156
+ await inspectSteamContent(prepared.contentRoot);
2157
+ if (creds.configVdf !== void 0) {
2158
+ await seedConfigVdf(steamcmdExe, creds.configVdf);
2159
+ }
2160
+ await fsp6.mkdir(path9.join(prepared.buildOutput, "logs"), { recursive: true });
2161
+ const mode = opts.preview ? "preview (no upload)" : "build";
2162
+ if (!opts.json) {
2163
+ log.info(`steamcmd: ${steamcmdExe}`);
2164
+ log.info(
2165
+ `mode: ${mode} app ${steam.appId} setLive "${prepared.setLive || "(none)"}"`
2166
+ );
2167
+ log.info(`content: ${prepared.contentRoot}`);
2168
+ }
2169
+ const secrets = [creds.password, creds.totp, creds.totpSecret].filter(
2170
+ (s) => s !== void 0
2171
+ );
2172
+ const run = await runSteamcmdBuild(
2173
+ {
2174
+ steamcmdExe,
2175
+ loginArgs,
2176
+ appBuildVdf: prepared.appBuildPath,
2177
+ buildOutputDir: prepared.buildOutput
2178
+ },
2179
+ secrets
2180
+ );
2181
+ const outcome = analyzeSteamcmdOutput(run.combinedOutput, "", run.exitCode, {
2182
+ appId: steam.appId,
2183
+ preview: opts.preview
2184
+ });
2185
+ if (outcome.status !== "success") {
2186
+ const code = outcome.status === "failed" ? "STM_BUILD_FAILED" : "STM_BUILD_UNKNOWN_OUTCOME";
2187
+ throw new CliError(
2188
+ code,
2189
+ `steamcmd ${mode} did not succeed (exit ${run.exitCode})`,
2190
+ {
2191
+ exitCode: 3,
2192
+ details: {
2193
+ errors: outcome.errors,
2194
+ hints: outcome.hints,
2195
+ logPath: run.logPath
2196
+ }
2197
+ }
2198
+ );
2199
+ }
2200
+ if (opts.json) {
2201
+ log.result({
2202
+ ok: true,
2203
+ mode,
2204
+ appId: steam.appId,
2205
+ buildId: outcome.buildId ?? null,
2206
+ setLive: prepared.setLive || null,
2207
+ logPath: run.logPath,
2208
+ vdfDir: prepared.vdfDir
2209
+ });
2210
+ return;
2211
+ }
2212
+ log.info(
2213
+ `success${outcome.buildId !== void 0 ? ` (build ${outcome.buildId})` : ""}`
2214
+ );
2215
+ log.info(`full log: ${run.logPath}`);
2216
+ if (prepared.setLive !== "") {
2217
+ log.info(
2218
+ `build is live on branch "${prepared.setLive}"; test with: steam://run/${steam.appId}`
2219
+ );
2220
+ } else {
2221
+ log.info(
2222
+ `next: set the build live on a branch in App Admin -> Builds: ${steamBuildsUrl(steam.appId)}`
2223
+ );
2224
+ }
2225
+ }
2226
+ async function runSteamTest(startDir, opts) {
2227
+ const config = await loadConfig(startDir);
2228
+ const steam = requireSteam(config);
2229
+ const contentRoot = resolveFromProject(config, steam.contentRoot);
2230
+ const appidFile = path9.join(contentRoot, "steam_appid.txt");
2231
+ if (!await isDirectory(contentRoot)) {
2232
+ throw new CliError(
2233
+ "STM_CONTENT_ROOT_MISSING",
2234
+ `contentRoot does not exist: ${contentRoot}`
2235
+ );
2236
+ }
2237
+ let appidState = "missing";
2238
+ if (await isFile(appidFile)) {
2239
+ appidState = "present";
2240
+ const content = (await fsp6.readFile(appidFile, "utf8")).trim();
2241
+ if (content !== String(steam.appId)) {
2242
+ throw new CliError(
2243
+ "STM_APPID_FILE_MISMATCH",
2244
+ `steam_appid.txt contains "${content}" but config appId is ${steam.appId}`
2245
+ );
2246
+ }
2247
+ } else if (opts.writeAppid) {
2248
+ await atomicWriteFile(appidFile, `${steam.appId}
2249
+ `);
2250
+ appidState = "written";
2251
+ }
2252
+ const libraries = await inspectSteamContent(contentRoot);
2253
+ const apiDllFound = libraries.length > 0;
2254
+ const executable = steam.executable === void 0 ? void 0 : safeJoin(contentRoot, steam.executable);
2255
+ const executableOk = executable === void 0 ? null : await isFile(executable);
2256
+ const summary = {
2257
+ ok: appidState !== "missing" && apiDllFound && executableOk !== false,
2258
+ contentRoot,
2259
+ steamAppidTxt: appidState,
2260
+ steamApiDllFound: apiDllFound,
2261
+ steamApiLibraries: libraries,
2262
+ executable: executable === null || executable === void 0 ? null : { path: executable, exists: executableOk === true },
2263
+ runUrl: `steam://run/${steam.appId}`,
2264
+ buildsUrl: steamBuildsUrl(steam.appId)
2265
+ };
2266
+ if (!summary.ok)
2267
+ throw new CliError(
2268
+ executableOk === false ? "STM_EXECUTABLE_MISSING" : "STM_CONTENT_ROOT_MISSING",
2269
+ "local Steam prerequisites are incomplete (AppID file, Steam API library or configured executable)",
2270
+ { details: summary }
2271
+ );
2272
+ if (opts.json) {
2273
+ log.result(summary);
2274
+ return;
2275
+ }
2276
+ log.info(`content root: ${contentRoot}`);
2277
+ log.info(`steam_appid.txt: ${appidState}`);
2278
+ if (apiDllFound) {
2279
+ log.info("steam_api64.dll: found");
2280
+ } else {
2281
+ log.warn("steam_api64.dll: NOT found");
2282
+ log.warn(
2283
+ " fix: copy steam_api64.dll from the Steamworks SDK redistributables next to the exe (required to run outside the Steam client)"
2284
+ );
2285
+ }
2286
+ if (executable !== void 0) {
2287
+ if (executableOk) {
2288
+ log.info(`executable: ${executable}`);
2289
+ } else {
2290
+ log.warn(`executable: ${executable} (missing)`);
2291
+ log.warn(
2292
+ " fix: point steam.executable at the built exe (relative to contentRoot)"
2293
+ );
2294
+ }
2295
+ }
2296
+ log.info(
2297
+ `run locally from the build dir (needs a logged-in Steam client) or via ${summary.runUrl}`
2298
+ );
2299
+ log.info(`manage builds: ${summary.buildsUrl}`);
2300
+ if (appidState === "missing") {
2301
+ log.warn(
2302
+ "steam_appid.txt missing; rerun with --write-appid to create it for local runs outside the Steam client"
2303
+ );
2304
+ }
2305
+ }
2306
+ async function inspectSteamContent(root) {
2307
+ const libraries = [];
2308
+ const stack = [root];
2309
+ while (stack.length > 0) {
2310
+ const current = stack.pop();
2311
+ if (current === void 0) break;
2312
+ const entries = await fsp6.readdir(current, { withFileTypes: true });
2313
+ for (const entry of entries) {
2314
+ if (entry.isSymbolicLink())
2315
+ throw new CliError(
2316
+ "PAT_ESCAPE",
2317
+ `Steam content must contain actual files, not links: ${path9.join(current, entry.name)}`
2318
+ );
2319
+ if (entry.isFile() && /^(?:steam_api(?:64)?\.dll|libsteam_api\.(?:so|dylib))$/i.test(
2320
+ entry.name
2321
+ ))
2322
+ libraries.push(path9.join(current, entry.name));
2323
+ if (entry.isDirectory()) stack.push(path9.join(current, entry.name));
2324
+ }
2325
+ }
2326
+ return libraries;
2327
+ }
2328
+ async function runSteamOpen(startDir, target) {
2329
+ const config = await loadConfig(startDir);
2330
+ const steam = requireSteam(config);
2331
+ if (target === "builds") {
2332
+ await openUrl(steamBuildsUrl(steam.appId));
2333
+ return;
2334
+ }
2335
+ throw new CliError(
2336
+ "CFG_SCHEMA_INVALID",
2337
+ `unknown steam open target "${target}" (supported: builds)`
2338
+ );
2339
+ }
2340
+ function registerSteamCommand(program) {
2341
+ const steam = program.command("steam").description("Steam packaging and upload via steamcmd / SteamPipe");
2342
+ steam.command("vdf").description(
2343
+ "generate app_build + depot_build VDFs (inspect or hand-run them yourself)"
2344
+ ).option(
2345
+ "--desc <text>",
2346
+ "build description recorded on the Builds page",
2347
+ "ship-cli build"
2348
+ ).option(
2349
+ "--set-live <branch>",
2350
+ "branch to auto set live after upload (NOT allowed: default)"
2351
+ ).option(
2352
+ "--preview",
2353
+ "write Preview=1 (dry-run VDF, uploads nothing)",
2354
+ false
2355
+ ).option("--json", "print JSON result").action(
2356
+ async (options) => {
2357
+ await runSteamVdf(process.cwd(), options);
2358
+ }
2359
+ );
2360
+ const build = (cmd, preview) => {
2361
+ cmd.option(
2362
+ "--desc <text>",
2363
+ "build description recorded on the Builds page",
2364
+ "ship-cli build"
2365
+ ).option("--json", "print JSON result");
2366
+ if (!preview) {
2367
+ cmd.option(
2368
+ "--set-live <branch>",
2369
+ "branch to auto set live after upload (NOT allowed: default)"
2370
+ );
2371
+ }
2372
+ return cmd;
2373
+ };
2374
+ build(
2375
+ steam.command("preview").description(
2376
+ "run steamcmd with Preview=1: full pipeline, uploads NOTHING, produces logs + manifest"
2377
+ ),
2378
+ true
2379
+ ).action(async (options) => {
2380
+ await runSteamBuild(process.cwd(), {
2381
+ ...options,
2382
+ preview: true,
2383
+ setLive: void 0
2384
+ });
2385
+ });
2386
+ build(
2387
+ steam.command("push").description(
2388
+ "upload a real build via steamcmd (optionally set live on a NON-default branch)"
2389
+ ),
2390
+ false
2391
+ ).action(
2392
+ async (options) => {
2393
+ await runSteamBuild(process.cwd(), { ...options, preview: false });
2394
+ }
2395
+ );
2396
+ steam.command("test").description(
2397
+ "local smoke checks on the built content (steam_appid.txt, steam_api dll, run URLs)"
2398
+ ).option(
2399
+ "--write-appid",
2400
+ "create steam_appid.txt in contentRoot when missing",
2401
+ false
2402
+ ).option("--json", "print JSON result").action(async (options) => {
2403
+ await runSteamTest(process.cwd(), options);
2404
+ });
2405
+ steam.command("open <target>").description("open a Steamworks partner page (builds)").action(async (target) => {
2406
+ await runSteamOpen(process.cwd(), target);
2407
+ });
2408
+ }
2409
+
2410
+ // src/commands/wx.ts
2411
+ import path10 from "path";
2412
+ import fsp7 from "fs/promises";
2413
+ import { randomUUID } from "crypto";
2414
+ function parseRobot(value) {
2415
+ if (!/^(?:[1-9]|[12][0-9]|30)$/.test(value))
2416
+ throw new CliError(
2417
+ "CLI_ARGUMENT_INVALID",
2418
+ "--robot must be an integer from 1 to 30"
2419
+ );
2420
+ return Number(value);
2421
+ }
2422
+ function requireWechat(config) {
2423
+ if (config.wechat === void 0) {
2424
+ throw new CliError(
2425
+ "CFG_SCHEMA_INVALID",
2426
+ "ship.config.json has no wechat section"
2427
+ );
2428
+ }
2429
+ return config.wechat;
2430
+ }
2431
+ function renderWxReport(report) {
2432
+ log.info(
2433
+ "sizes are filtered source estimates; miniprogram-ci compilation determines actual package sizes"
2434
+ );
2435
+ log.info(`project: ${report.projectPath} (appid ${report.appid})`);
2436
+ const mainExceeded = report.main.bytes > report.limits.main;
2437
+ log.info(
2438
+ `main: ${formatBytes(report.main.bytes)} / ${formatBytes(report.limits.main)} (${report.main.files} files)${mainExceeded ? " EXCEEDED" : ""}`
2439
+ );
2440
+ for (const sub of report.subpackages) {
2441
+ const limit = sub.limitBytes !== void 0 ? ` / ${formatBytes(sub.limitBytes)}` : "";
2442
+ const exceeded = sub.limitBytes !== void 0 && sub.bytes > sub.limitBytes;
2443
+ log.info(
2444
+ `subpackage "${sub.name}"${sub.independent ? " (independent)" : ""}: ${formatBytes(sub.bytes)}${limit} (${sub.files} files)${exceeded ? " EXCEEDED" : ""}`
2445
+ );
2446
+ }
2447
+ if (report.openDataContext !== null) {
2448
+ log.info(
2449
+ `openDataContext "${report.openDataContext.dir}": ${formatBytes(report.openDataContext.bytes)} (counts toward main)`
2450
+ );
2451
+ }
2452
+ const totalExceeded = report.total.bytes > report.limits.total;
2453
+ log.info(
2454
+ `total: ${formatBytes(report.total.bytes)} / ${formatBytes(report.limits.total)}${totalExceeded ? " EXCEEDED" : ""}`
2455
+ );
2456
+ if (report.analysis.length > 0) {
2457
+ for (const item of report.analysis) {
2458
+ log.info(`largest files in ${item.package} (top ${item.files.length}):`);
2459
+ for (const file of item.files) {
2460
+ log.info(` ${formatBytes(file.bytes).padStart(8)} ${file.path}`);
2461
+ }
2462
+ }
2463
+ }
2464
+ if (report.errors.length > 0) {
2465
+ log.error(`errors (${report.errors.length}):`);
2466
+ report.errors.forEach((error, index) => {
2467
+ log.error(` [E${index + 1}] ${error.code} \u2014 ${error.subject}`);
2468
+ log.error(` ${error.message}`);
2469
+ log.error(` fix: ${error.suggestion}`);
2470
+ if (error.docs !== void 0) log.error(` docs: ${error.docs}`);
2471
+ });
2472
+ }
2473
+ if (report.warnings.length > 0) {
2474
+ log.warn(`warnings (${report.warnings.length}):`);
2475
+ for (const warning of report.warnings) log.warn(` - ${warning}`);
2476
+ }
2477
+ }
2478
+ async function runWxValidate(startDir, json) {
2479
+ const config = await loadConfig(startDir);
2480
+ const wechat = requireWechat(config);
2481
+ const report = await validateWechatProject(
2482
+ wechat,
2483
+ config.projectRoot,
2484
+ config.limits
2485
+ );
2486
+ if (!json) {
2487
+ renderWxReport(report);
2488
+ }
2489
+ if (!report.ok) {
2490
+ throw new CliError(
2491
+ "WX_VALIDATION_FAILED",
2492
+ `${report.errors.length} validation error(s)${json ? "" : " \u2014 see report above"}`,
2493
+ {
2494
+ details: json ? report : void 0
2495
+ }
2496
+ );
2497
+ }
2498
+ if (json) {
2499
+ log.result(report);
2500
+ }
2501
+ }
2502
+ async function runWxPreview(startDir, opts) {
2503
+ const config = await loadConfig(startDir);
2504
+ const wechat = requireWechat(config);
2505
+ if (opts.version !== void 0 && opts.version !== "") {
2506
+ throw new CliError(
2507
+ "CFG_SCHEMA_INVALID",
2508
+ "wx preview does not take --version (uploads create versions; previews do not)"
2509
+ );
2510
+ }
2511
+ const validation = await validateWechatProject(
2512
+ wechat,
2513
+ config.projectRoot,
2514
+ config.limits
2515
+ );
2516
+ const structural = validation.errors.filter(
2517
+ (error) => ![
2518
+ "WX_MAIN_OVER_LIMIT",
2519
+ "WX_INDEPENDENT_OVER_LIMIT",
2520
+ "WX_TOTAL_OVER_LIMIT"
2521
+ ].includes(error.code)
2522
+ );
2523
+ if (structural.length)
2524
+ throw new CliError(
2525
+ "WX_VALIDATION_FAILED",
2526
+ "preview blocked by invalid game structure",
2527
+ { details: validation }
2528
+ );
2529
+ await executeWxCi(config, wechat, { mode: "preview", opts });
2530
+ }
2531
+ async function runWxPush(startDir, opts) {
2532
+ if (!opts.version?.trim())
2533
+ throw new CliError(
2534
+ "WX_VERSION_REQUIRED",
2535
+ "upload requires a non-empty --version"
2536
+ );
2537
+ const config = await loadConfig(startDir);
2538
+ const wechat = requireWechat(config);
2539
+ const validation = await validateWechatProject(
2540
+ wechat,
2541
+ config.projectRoot,
2542
+ config.limits
2543
+ );
2544
+ if (!validation.ok) {
2545
+ if (!opts.json) {
2546
+ log.error("refusing to upload \u2014 validation failed:");
2547
+ renderWxReport(validation);
2548
+ }
2549
+ throw new CliError(
2550
+ "WX_VALIDATION_FAILED",
2551
+ "upload blocked until wx validate passes",
2552
+ {
2553
+ details: opts.json ? validation : void 0
2554
+ }
2555
+ );
2556
+ }
2557
+ await executeWxCi(config, wechat, { mode: "upload", opts });
2558
+ }
2559
+ async function executeWxCi(config, wechat, input) {
2560
+ const projectPath = resolveFromProject(config, wechat.projectPath);
2561
+ const robot = input.opts.robot ?? wechat.robot ?? 1;
2562
+ if (!Number.isInteger(robot) || robot < 1 || robot > 30) {
2563
+ throw new CliError(
2564
+ "CFG_SCHEMA_INVALID",
2565
+ `robot must be an integer in 1..30 (got ${robot})`
2566
+ );
2567
+ }
2568
+ const pkgDir = await resolveMiniprogramCi([projectPath, config.projectRoot]);
2569
+ const privateKeyPath = await resolvePrivateKey(
2570
+ process.env,
2571
+ wechat.privateKeyPath,
2572
+ config.projectRoot
2573
+ );
2574
+ if (isInsidePath(actualPath(projectPath), actualPath(privateKeyPath)))
2575
+ throw new CliError(
2576
+ "WX_KEY_INVALID",
2577
+ "upload private key must be outside packaged game content"
2578
+ );
2579
+ const qrOut = input.mode === "preview" ? input.opts.qrOut ? path10.resolve(config.projectRoot, input.opts.qrOut) : resolveFromProject(
2580
+ config,
2581
+ `build/ship-preview/${randomUUID()}/qr.png`
2582
+ ) : void 0;
2583
+ if (qrOut) {
2584
+ if (isInsidePath(actualPath(projectPath), actualPath(qrOut)))
2585
+ throw new CliError(
2586
+ "PAT_ESCAPE",
2587
+ "QR output must be outside packaged game content"
2588
+ );
2589
+ try {
2590
+ await fsp7.lstat(qrOut);
2591
+ throw new CliError(
2592
+ "PAT_ESCAPE",
2593
+ `QR output already exists; choose another --qr-out: ${qrOut}`
2594
+ );
2595
+ } catch (err) {
2596
+ if (err.code !== "ENOENT") throw err;
2597
+ }
2598
+ await fsp7.mkdir(path10.dirname(qrOut), { recursive: true });
2599
+ }
2600
+ if (!input.opts.json) {
2601
+ log.info(`miniprogram-ci: ${pkgDir}`);
2602
+ log.info(`project: ${projectPath} appid ${wechat.appid} robot ${robot}`);
2603
+ log.info(
2604
+ input.mode === "upload" ? `upload dev-version ${input.opts.version ?? ""} (creates a new \u5F00\u53D1\u7248; select it as \u4F53\u9A8C\u7248 in the MP backend)` : `preview (QR code for phone testing)`
2605
+ );
2606
+ }
2607
+ const result = await runWxCi({
2608
+ mode: input.mode,
2609
+ pkgDir,
2610
+ appid: wechat.appid,
2611
+ projectPath,
2612
+ privateKeyPath,
2613
+ version: input.opts.version,
2614
+ desc: input.opts.desc,
2615
+ robot,
2616
+ qrcodeOutputDest: qrOut,
2617
+ ignores: wechat.ignores,
2618
+ setting: wechat.setting,
2619
+ bigPackageSizeSupport: wechat.bigPackageSizeSupport,
2620
+ timeoutMs: wechat.timeoutMs
2621
+ });
2622
+ if (result.exitCode !== 0 || result.errorMessage !== void 0) {
2623
+ throw new CliError(
2624
+ input.mode === "upload" ? "WX_UPLOAD_FAILED" : "WX_PREVIEW_FAILED",
2625
+ result.errorMessage ?? `miniprogram-ci exited with code ${result.exitCode}`,
2626
+ { exitCode: 4, details: { output: result.output.slice(-4e3) } }
2627
+ );
2628
+ }
2629
+ if (input.opts.json) {
2630
+ log.result({
2631
+ ok: true,
2632
+ mode: input.mode,
2633
+ appid: wechat.appid,
2634
+ robot,
2635
+ version: input.opts.version ?? null,
2636
+ qrOut: qrOut ?? null,
2637
+ result: result.result ?? null
2638
+ });
2639
+ return;
2640
+ }
2641
+ log.info("done");
2642
+ if (qrOut) log.info(`QR image: ${qrOut}`);
2643
+ if (input.mode === "upload") {
2644
+ log.info(
2645
+ "next (human steps): MP \u540E\u53F0 -> \u7248\u672C\u7BA1\u7406 -> \u5F00\u53D1\u7248\u672C -> \u9009\u4E3A\u4F53\u9A8C\u7248 / \u63D0\u4EA4\u5BA1\u6838\u3002\u5BA1\u6838\u901A\u8FC7\u540E\u624D\u80FD\u5168\u91CF\u53D1\u5E03\u6216\u7070\u5EA6\u3002"
2646
+ );
2647
+ }
2648
+ }
2649
+ async function runWxOpen(_startDir, target) {
2650
+ if (target === "mp") {
2651
+ await openUrl("https://mp.weixin.qq.com/");
2652
+ return;
2653
+ }
2654
+ throw new CliError(
2655
+ "CFG_SCHEMA_INVALID",
2656
+ `unknown wx open target "${target}" (supported: mp)`
2657
+ );
2658
+ }
2659
+ function registerWxCommand(program) {
2660
+ const wx = program.command("wx").description(
2661
+ "WeChat mini game packaging checks, preview QR and dev-version upload"
2662
+ );
2663
+ wx.command("validate").description(
2664
+ "offline structural + package-size validation (main 4M / independent subpackage 4M / total 30M)"
2665
+ ).option("--json", "print JSON report").action(async (options) => {
2666
+ await runWxValidate(process.cwd(), options.json === true);
2667
+ });
2668
+ wx.command("preview").description(
2669
+ "generate a phone-test QR code via miniprogram-ci (no version created)"
2670
+ ).option(
2671
+ "--qr-out <file>",
2672
+ "new PNG QR image outside packaged content (relative to project)"
2673
+ ).option("--desc <text>", "note shown in the dev-version list").option("--robot <n>", "miniprogram-ci robot slot 1-30", parseRobot).option("--json", "print JSON result").action(
2674
+ async (options) => {
2675
+ await runWxPreview(process.cwd(), {
2676
+ version: "",
2677
+ desc: options.desc,
2678
+ robot: options.robot,
2679
+ qrOut: options.qrOut,
2680
+ json: options.json === true
2681
+ });
2682
+ }
2683
+ );
2684
+ wx.command("push").description(
2685
+ "upload a new dev version via miniprogram-ci (validate first; NEVER submits for review)"
2686
+ ).requiredOption("--version <text>", "dev-version number, e.g. 1.0.0").option("--desc <text>", "upload note / changelog shown in the backend").option("--robot <n>", "miniprogram-ci robot slot 1-30", parseRobot).option("--json", "print JSON result").action(
2687
+ async (options) => {
2688
+ await runWxPush(process.cwd(), {
2689
+ version: options.version,
2690
+ desc: options.desc,
2691
+ robot: options.robot,
2692
+ json: options.json === true
2693
+ });
2694
+ }
2695
+ );
2696
+ wx.command("open <target>").description("open the WeChat MP backend (mp)").action(async (target) => {
2697
+ await runWxOpen(process.cwd(), target);
2698
+ });
2699
+ }
2700
+
2701
+ // src/cli.ts
2702
+ function extractProjectArg(argv) {
2703
+ const out = [];
2704
+ let project;
2705
+ for (let i = 0; i < argv.length; i += 1) {
2706
+ const token = argv[i];
2707
+ if (token === "--project") {
2708
+ const value = argv[i + 1];
2709
+ if (value === void 0 || value.startsWith("--") || project !== void 0) {
2710
+ throw new CliError(
2711
+ "CLI_ARGUMENT_INVALID",
2712
+ "--project requires one directory argument"
2713
+ );
2714
+ }
2715
+ project = value;
2716
+ i += 1;
2717
+ continue;
2718
+ }
2719
+ if (token.startsWith("--project=")) {
2720
+ if (!token.slice("--project=".length) || project !== void 0)
2721
+ throw new CliError(
2722
+ "CLI_ARGUMENT_INVALID",
2723
+ "--project must appear once with a non-empty directory"
2724
+ );
2725
+ project = token.slice("--project=".length);
2726
+ continue;
2727
+ }
2728
+ out.push(token);
2729
+ }
2730
+ return { argv: out, project };
2731
+ }
2732
+ function normalizeLeadingJson(argv) {
2733
+ const rest = [...argv];
2734
+ let sawJson = false;
2735
+ while (rest[0] === "--json") {
2736
+ sawJson = true;
2737
+ rest.shift();
2738
+ }
2739
+ return sawJson ? [...rest, "--json"] : rest;
2740
+ }
2741
+ function buildProgram() {
2742
+ const program = new Command();
2743
+ program.name("ship-cli").enablePositionalOptions().exitOverride().configureOutput({
2744
+ writeErr: (message) => {
2745
+ if (!log.json) process.stderr.write(message);
2746
+ }
2747
+ }).option(
2748
+ "--project <dir>",
2749
+ "project directory (also accepted after subcommands)"
2750
+ ).version(VERSION).description(
2751
+ "Cross-platform game shipping CLI: package, validate and push builds (Steam, WeChat mini games). Never publishes to players on its own."
2752
+ );
2753
+ program.command("doctor").description(
2754
+ "environment + config + credential preflight (offline, no network calls)"
2755
+ ).option("--json", "print JSON report").action(async (options) => {
2756
+ const report = await runDoctor(process.cwd());
2757
+ printDoctor(report, options.json === true);
2758
+ });
2759
+ const config = program.command("config").description("ship.config.json management");
2760
+ config.command("init").description(
2761
+ "write a ship.config.json template (refuses to overwrite without --force)"
2762
+ ).option("--force", "overwrite an existing file", false).option("--json", "print JSON result").action(async (options) => {
2763
+ await runConfigInit(process.cwd(), options.force, options.json === true);
2764
+ });
2765
+ config.command("validate").description("load + schema + cross-field validation").option("--json", "print JSON result").action(async (options) => {
2766
+ await runConfigValidate(process.cwd(), options.json === true);
2767
+ });
2768
+ config.command("show").description("print the normalized config").option("--json", "print JSON result").action(async (options) => {
2769
+ await runConfigShow(process.cwd(), options.json === true);
2770
+ });
2771
+ registerSteamCommand(program);
2772
+ registerWxCommand(program);
2773
+ return program;
2774
+ }
2775
+ async function main(argv) {
2776
+ log.json = argv.includes("--json");
2777
+ const originalCwd = process.cwd();
2778
+ try {
2779
+ const { argv: withoutProject, project } = extractProjectArg(argv);
2780
+ if (project !== void 0) {
2781
+ const resolved = path11.resolve(project);
2782
+ const fs3 = await import("fs/promises");
2783
+ const stat = await fs3.stat(resolved).catch(() => void 0);
2784
+ if (stat === void 0 || !stat.isDirectory()) {
2785
+ throw new CliError(
2786
+ "CFG_CONFIG_NOT_FOUND",
2787
+ `--project is not a directory: ${resolved}`
2788
+ );
2789
+ }
2790
+ process.chdir(resolved);
2791
+ }
2792
+ const program = buildProgram();
2793
+ await program.parseAsync(normalizeLeadingJson(withoutProject), {
2794
+ from: "user"
2795
+ });
2796
+ return 0;
2797
+ } catch (err) {
2798
+ if (err instanceof CommanderError && err.exitCode === 0) return 0;
2799
+ throw err;
2800
+ } finally {
2801
+ process.chdir(originalCwd);
2802
+ }
2803
+ }
2804
+ function cliErrorExit(err) {
2805
+ if (log.json) {
2806
+ const error = err instanceof CliError ? err : new CliError(
2807
+ "CLI_ARGUMENT_INVALID",
2808
+ err instanceof Error ? err.message : String(err)
2809
+ );
2810
+ log.result({
2811
+ ok: false,
2812
+ error: {
2813
+ code: error.code,
2814
+ message: error.message,
2815
+ details: error.details,
2816
+ fix: error.suggestion,
2817
+ docs: error.docs
2818
+ }
2819
+ });
2820
+ return error.exitCode;
2821
+ }
2822
+ if (err instanceof CliError) {
2823
+ log.error(`${err.code}: ${err.message}`);
2824
+ if (err.suggestion !== void 0) log.error(`fix: ${err.suggestion}`);
2825
+ if (err.docs !== void 0) log.error(`docs: ${err.docs}`);
2826
+ if (err.details !== void 0) {
2827
+ log.error(JSON.stringify(err.details, null, 2));
2828
+ }
2829
+ return err.exitCode || exitCodeFor(err.code);
2830
+ }
2831
+ log.error(err instanceof Error ? `${err.stack ?? err.message}` : String(err));
2832
+ return 1;
2833
+ }
2834
+ var invokedDirect = process.argv[1] !== void 0 && fileURLToPath2(import.meta.url) === path11.resolve(process.argv[1]);
2835
+ if (invokedDirect) {
2836
+ try {
2837
+ process.exitCode = await main(process.argv.slice(2));
2838
+ } catch (err) {
2839
+ process.exitCode = cliErrorExit(err);
2840
+ }
2841
+ }
2842
+ export {
2843
+ CONFIG_FILE_NAME,
2844
+ CliError,
2845
+ DEFAULT_WX_LIMITS,
2846
+ PACKAGE_NAME,
2847
+ SUGGESTIONS,
2848
+ VERSION,
2849
+ analyzeSteamcmdOutput,
2850
+ appBuildVdfName,
2851
+ buildDriverScript,
2852
+ buildLoginArgs,
2853
+ buildProgram,
2854
+ cliErrorExit,
2855
+ depotBuildVdfName,
2856
+ describeCredentialMode,
2857
+ escapeVdfString,
2858
+ findConfigFile,
2859
+ findSteamcmd,
2860
+ formatBytes,
2861
+ generateSteamTotp,
2862
+ loadConfig,
2863
+ lookupOnPath,
2864
+ main,
2865
+ parseGameJson,
2866
+ quoteVdf,
2867
+ renderAppBuildVdf,
2868
+ renderDepotBuildVdf,
2869
+ resolveMiniprogramCi,
2870
+ resolvePrivateKey,
2871
+ resolveSteamCredentials,
2872
+ runProcess,
2873
+ runWxCi,
2874
+ seedConfigVdf,
2875
+ steamBuildsUrl,
2876
+ suggest,
2877
+ validateAgainstSchema,
2878
+ validateWechatProject
2879
+ };