@ttmg/cli 0.4.6-agent-beta.1 → 0.4.6-agent-beta.3

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.js CHANGED
@@ -21,6 +21,7 @@ var express = require('express');
21
21
  var cheerio = require('cheerio');
22
22
  var archiver = require('archiver');
23
23
  var FormData$1 = require('form-data');
24
+ var https = require('https');
24
25
  var promises = require('timers/promises');
25
26
  var chokidar = require('chokidar');
26
27
  var WebSocket = require('ws');
@@ -34,10 +35,10 @@ var require$$0 = require('util');
34
35
  var require$$0$1 = require('events');
35
36
  var require$$0$2 = require('buffer');
36
37
  var require$$1$2 = require('stream');
37
- var https = require('https');
38
38
  var dns = require('dns');
39
39
  var boxen = require('boxen');
40
40
  var semver = require('semver');
41
+ var path$1 = require('node:path');
41
42
  var glob = require('glob');
42
43
  var got = require('got');
43
44
  var ttmgPack = require('ttmg-pack');
@@ -45,7 +46,6 @@ var expressStaticGzip = require('express-static-gzip');
45
46
  var fileUpload = require('express-fileupload');
46
47
  var crypto$1 = require('node:crypto');
47
48
  var fs$1 = require('node:fs');
48
- var path$1 = require('node:path');
49
49
  var zlib = require('zlib');
50
50
  var promises$1 = require('fs/promises');
51
51
  var os$1 = require('node:os');
@@ -80,9 +80,9 @@ var http__namespace = /*#__PURE__*/_interopNamespaceDefault(http);
80
80
  var net__namespace = /*#__PURE__*/_interopNamespaceDefault(net);
81
81
  var require$$1__namespace = /*#__PURE__*/_interopNamespaceDefault(require$$1$1);
82
82
  var dns__namespace = /*#__PURE__*/_interopNamespaceDefault(dns);
83
+ var path__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(path$1);
83
84
  var glob__namespace = /*#__PURE__*/_interopNamespaceDefault(glob);
84
85
  var fs__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(fs$1);
85
- var path__namespace$1 = /*#__PURE__*/_interopNamespaceDefault(path$1);
86
86
 
87
87
  async function openUrl(url) {
88
88
  const { launch } = await import('chrome-launcher');
@@ -6780,11 +6780,11 @@ function printApiResponseLog(title, result) {
6780
6780
  ['Return Value', result.data],
6781
6781
  ]);
6782
6782
  }
6783
- async function request({ url, method, data, headers, params, logRequestBody = true, signal, }) {
6783
+ async function request({ url, method, data, headers, params, logRequestBody = true, logApiCalls = true, signal, }) {
6784
6784
  const config = getTTMGRC();
6785
6785
  const cookie = sanitizeCookieHeader(config?.cookie);
6786
6786
  const proxyConfig = getAxiosProxyConfig();
6787
- if (isVerboseEnabled()) {
6787
+ if (isVerboseEnabled() && logApiCalls) {
6788
6788
  printApiRequestLog({
6789
6789
  url,
6790
6790
  method,
@@ -6811,7 +6811,7 @@ async function request({ url, method, data, headers, params, logRequestBody = tr
6811
6811
  },
6812
6812
  ...proxyConfig,
6813
6813
  });
6814
- if (isVerboseEnabled()) {
6814
+ if (isVerboseEnabled() && logApiCalls) {
6815
6815
  printApiResponseLog('API Response', {
6816
6816
  status: res?.status,
6817
6817
  logid: res?.headers['x-tt-logid'],
@@ -6839,7 +6839,7 @@ async function request({ url, method, data, headers, params, logRequestBody = tr
6839
6839
  NetworkCode: axiosError.code,
6840
6840
  NetworkMessage: axiosError.message,
6841
6841
  };
6842
- if (isVerboseEnabled()) {
6842
+ if (isVerboseEnabled() && logApiCalls) {
6843
6843
  printApiResponseLog('API Error', {
6844
6844
  status: axiosError.response?.status,
6845
6845
  logid: axiosError.response?.headers['x-tt-logid'],
@@ -7210,6 +7210,16 @@ const messages = {
7210
7210
  'en-US': {
7211
7211
  'cli.command.wasm.desc': 'Prepare or split Wasm into a separate output directory',
7212
7212
  'cli.command.wasm.prepare': 'Instrument original Wasm for function collection',
7213
+ 'cli.command.wasm.collect': 'Manage platform function collection using the local CLI login',
7214
+ 'cli.command.wasm.collect.start': 'Open or resume a collection window; preserve existing IDs unless --reset is set',
7215
+ 'cli.command.wasm.collect.export': 'Export collected IDs to an identity-matched collection.json file',
7216
+ 'cli.command.wasm.collect.clientKey': 'Client Key for the game being collected',
7217
+ 'cli.command.wasm.collect.md5': 'Original Wasm collection MD5 used by the instrumented game',
7218
+ 'cli.command.wasm.collect.timeout': 'Platform request deadline: 1–300 seconds',
7219
+ 'cli.command.wasm.collect.reset': 'Clear historical function IDs for this game and Wasm before collecting',
7220
+ 'cli.command.wasm.collect.output': 'New collection JSON file (never overwrite)',
7221
+ 'wasm.collect.succeeded': 'Wasm collection command completed.',
7222
+ 'wasm.collect.failed': 'Wasm collection command failed.',
7213
7223
  'cli.command.wasm.split': 'Split original Wasm using an identity-matched collection',
7214
7224
  'cli.command.wasm.input': 'Original .wasm or Brotli .wasm.br file',
7215
7225
  'cli.command.wasm.output': 'New output directory (never overwrite)',
@@ -7231,6 +7241,36 @@ const messages = {
7231
7241
  'cli.command.login.password': 'Developer account password (implicit mode)',
7232
7242
  'cli.command.logout.desc': 'Logout and clear local user data',
7233
7243
  'cli.command.auth.desc': 'Inspect local authentication state',
7244
+ 'cli.command.auth.open.desc': 'Open an existing Vibe task authorization page in the system browser',
7245
+ 'cli.command.upload.vibeWeb': 'Authorize in the browser, then upload to PPE (--vibe; no Mock or --no-open)',
7246
+ 'cli.command.game.desc': 'Inspect game metadata supported by the platform',
7247
+ 'cli.command.game.categories.desc': 'List game categories and subcategories with gameplay descriptions (offline, read-only)',
7248
+ 'cli.command.game.categories.category': 'Filter by an exact category value, e.g. Puzzle',
7249
+ 'cli.command.game.categories.unknown': 'Unknown category. Run ttmg game categories --format json for valid values.',
7250
+ 'cli.command.game.categories.invalidFormat': 'Use text, json, or ndjson output.',
7251
+ 'cli.command.upload.vibeCategory': 'Optional game category; provide with --subcategory (--vibe). Query ttmg game categories --format json',
7252
+ 'cli.command.upload.vibeSubcategory': 'Optional subcategory belonging to --category; omit both to keep Other/Other (--vibe)',
7253
+ 'cli.command.upload.vibeWebGuide': `
7254
+ Vibe web upload (real PPE trial):
7255
+ ttmg upload --vibe --web --dir ./game --title "My game" --icon ./avatar.png --format ndjson
7256
+ Use --package ./game.zip instead of --dir for an existing ZIP.
7257
+ Collect only title and icon; the CLI fills other app_info fields with PPE trial defaults.
7258
+ Skill may query ttmg game categories --format json and pass --category Puzzle --subcategory Physics together.
7259
+ Omit both to keep Other/Other (or the legacy metadata-file classification). Invalid pairs fail before task creation.
7260
+ prepared and terminal events expose the submitted classification and its source.
7261
+ Keep this process running. At waiting_authorization, complete authorization in the system browser on the CLI computer.
7262
+ pageUrl is the local binding/progress page. To reopen authorization, execute the returned authorizationOpenCommand.
7263
+ The CLI polls and uploads avatar, app_info and package automatically after authorization. Do not start a second upload.
7264
+ Real success: terminal=true, status=uploaded, uploaded=true, simulation=false. On failure, read errorCode and nextAction.
7265
+ --web opens the browser for every output format. --mock is a separate simulation mode.
7266
+ Upload completion is not review approval or production release. See bundled vibe-upload.md and ttmg capabilities --format json.
7267
+ `,
7268
+ 'cli.command.auth.open.pageUrl': 'Local pageUrl returned by the running Vibe upload command',
7269
+ 'cli.command.auth.open.requested': 'Browser open requested. Complete authorization there; the existing upload process continues polling.',
7270
+ 'cli.command.auth.open.invalidPage': 'Pass the local pageUrl from the running Vibe upload command, not the platform URL.',
7271
+ 'cli.command.auth.open.unavailable': 'Authorization page is unavailable, expired, or no longer waiting. Check the existing upload process; no new task was created.',
7272
+ 'cli.command.auth.open.invalidFormat': 'Use text, json, or ndjson output.',
7273
+ 'cli.command.auth.open.failed': 'Could not open the system browser. Retry this command on the CLI computer.',
7234
7274
  'cli.command.auth.status.desc': 'Return whether the local developer session is reusable',
7235
7275
  'cli.command.auth.status.format': 'Output format: text | json',
7236
7276
  'cli.command.auth.status.requireAuth': 'Exit non-zero when no reusable local session exists',
@@ -7258,7 +7298,8 @@ const messages = {
7258
7298
  'cli.command.dev.format': 'Session output format: text | json',
7259
7299
  'cli.command.dev.events': 'Lifecycle event stream: none | ndjson',
7260
7300
  'cli.command.dev.noOpen': 'Do not automatically open the IDE or scan page',
7261
- 'cli.command.dev.evidenceDir': 'Session evidence output directory',
7301
+ 'cli.command.dev.evidenceDir': 'Session evidence output directory (outside the project or under its .ttmg directory)',
7302
+ 'dev.evidence.unsafeDirectory': 'Keep session evidence outside the game project or under its root .ttmg directory so it cannot enter game packages.',
7262
7303
  'dev.logs.recordModeRequired': '--record-logs requires --mode client-preview.',
7263
7304
  'dev.logs.archiveFailed': 'Local log recording stopped [DEV_LOG_ARCHIVE_WRITE_FAILED]. Live debugging is still available.',
7264
7305
  'cli.command.dev.recordLogs': 'Save redacted Runtime logs locally (client-preview only; bounded retention)',
@@ -7426,9 +7467,9 @@ const messages = {
7426
7467
  'cli.command.upload.servePreviewPage': 'Serve a temporary local preview page with test-account and preview QR codes',
7427
7468
  'cli.command.upload.noOpen': 'Do not open the local preview page in the default browser',
7428
7469
  'cli.command.upload.format': 'Output format: text | json | ndjson',
7429
- 'cli.command.upload.vibe': 'Skill-guided binding and upload (schema v2; use --mock for the integration trial)',
7470
+ 'cli.command.upload.vibe': 'Binding and upload: collect only title and icon; CLI fills other fields with PPE trial defaults (schema v2)',
7430
7471
  'cli.command.upload.vibeTitle': 'Game title supplied by the Skill (--vibe)',
7431
- 'cli.command.upload.vibeAppInfo': 'Local JSON with complete app metadata; validates platform title/icon requirements (--vibe)',
7472
+ 'cli.command.upload.vibeAppInfo': 'Legacy metadata file compatibility; not needed for current uploads and must not be requested from users',
7432
7473
  'cli.command.upload.vibeIcon': 'Local PNG/JPEG/WebP selected by the user (--vibe)',
7433
7474
  'cli.command.upload.vibePackage': 'Prepared ZIP instead of --dir (--vibe)',
7434
7475
  'cli.command.upload.vibeDryRun': 'Validate and package locally without authorization or upload (--vibe)',
@@ -7614,6 +7655,16 @@ const messages = {
7614
7655
  'zh-CN': {
7615
7656
  'cli.command.wasm.desc': '准备或分包 Wasm,产物写入独立目录',
7616
7657
  'cli.command.wasm.prepare': '对原始 Wasm 插桩,用于函数采集',
7658
+ 'cli.command.wasm.collect': '复用 CLI 本机登录态管理平台函数采集',
7659
+ 'cli.command.wasm.collect.start': '开启或恢复采集窗口;未指定 --reset 时保留已有函数 ID',
7660
+ 'cli.command.wasm.collect.export': '导出函数 ID,生成与原始 Wasm 匹配的 collection.json',
7661
+ 'cli.command.wasm.collect.clientKey': '采集游戏的 Client Key',
7662
+ 'cli.command.wasm.collect.md5': '插桩游戏上报使用的原始 Wasm 采集 MD5',
7663
+ 'cli.command.wasm.collect.timeout': '平台请求超时:1–300 秒',
7664
+ 'cli.command.wasm.collect.reset': '清空此游戏、此 Wasm 的历史函数 ID,再开始采集',
7665
+ 'cli.command.wasm.collect.output': '新的采集 JSON 文件(不覆盖已有文件)',
7666
+ 'wasm.collect.succeeded': 'Wasm 采集命令已完成。',
7667
+ 'wasm.collect.failed': 'Wasm 采集命令失败。',
7617
7668
  'cli.command.wasm.split': '使用与原始 Wasm 匹配的采集数据分包',
7618
7669
  'cli.command.wasm.input': '原始 .wasm 或 Brotli .wasm.br 文件',
7619
7670
  'cli.command.wasm.output': '新的输出目录(不覆盖已有内容)',
@@ -7635,6 +7686,36 @@ const messages = {
7635
7686
  'cli.command.login.password': '开发者账号密码(implicit 模式)',
7636
7687
  'cli.command.logout.desc': '退出登录并清除本地用户信息',
7637
7688
  'cli.command.auth.desc': '查看本地登录状态',
7689
+ 'cli.command.auth.open.desc': '在系统浏览器打开已有 Vibe 任务的授权页',
7690
+ 'cli.command.upload.vibeWeb': '网页授权就绪后自动上传到 PPE(--vibe;不能与 Mock 或 --no-open 同用)',
7691
+ 'cli.command.game.desc': '查询平台支持的游戏资料',
7692
+ 'cli.command.game.categories.desc': '查询游戏类型、子类型及玩法说明(离线、只读)',
7693
+ 'cli.command.game.categories.category': '按类型的精确值筛选,例如 Puzzle',
7694
+ 'cli.command.game.categories.unknown': '类型不存在。使用 ttmg game categories --format json 查询有效值。',
7695
+ 'cli.command.game.categories.invalidFormat': '输出格式请使用 text、json 或 ndjson。',
7696
+ 'cli.command.upload.vibeCategory': '可选游戏类型,须与 --subcategory 同传(--vibe);用 ttmg game categories --format json 查询',
7697
+ 'cli.command.upload.vibeSubcategory': '所属类型的可选子类型;两者均不传保留 Other/Other(--vibe)',
7698
+ 'cli.command.upload.vibeWebGuide': `
7699
+ Vibe 网页上传(真实 PPE 试用):
7700
+ ttmg upload --vibe --web --dir ./game --title "游戏名称" --icon ./avatar.png --format ndjson
7701
+ 已有 ZIP 时,用 --package ./game.zip 替换 --dir。
7702
+ 只收集标题和头像;其他 app_info 字段由 CLI 使用 PPE 试用默认值补齐。
7703
+ Skill 可先查 ttmg game categories --format json,再成对传入 --category Puzzle --subcategory Physics。
7704
+ 两者均不传保留 Other/Other(旧资料文件调用保留文件分类);无效组合在创建任务前报错。
7705
+ prepared 和终态事件返回最终 classification 及其来源。
7706
+ 保持原进程运行。在 waiting_authorization 阶段,在 CLI 所在电脑的系统浏览器完成授权。
7707
+ pageUrl 是本机绑定/进度页;重新打开授权页时,执行返回的 authorizationOpenCommand。
7708
+ CLI 负责轮询,授权后自动上传 avatar、app_info、package,不再启动第二次上传。
7709
+ 真实成功:terminal=true、status=uploaded、uploaded=true、simulation=false。失败时读取 errorCode 和 nextAction。
7710
+ --web 在所有输出格式下都会打开浏览器;--mock 是独立的模拟模式。
7711
+ 上传完成不代表审核通过或正式上线。详见包内 vibe-upload.md 和 ttmg capabilities --format json。
7712
+ `,
7713
+ 'cli.command.auth.open.pageUrl': '正在运行的 Vibe 上传命令返回的本地 pageUrl',
7714
+ 'cli.command.auth.open.requested': '已请求浏览器打开授权页。请在页面完成授权,原上传进程继续轮询。',
7715
+ 'cli.command.auth.open.invalidPage': '请传入正在运行的 Vibe 上传命令返回的本地 pageUrl,不是平台授权链接。',
7716
+ 'cli.command.auth.open.unavailable': '授权页不可用、已过期或已不在等待授权。请查看原上传进程状态;未新建任务。',
7717
+ 'cli.command.auth.open.invalidFormat': '输出格式请使用 text、json 或 ndjson。',
7718
+ 'cli.command.auth.open.failed': '无法打开系统浏览器,请在 CLI 所在电脑重试此命令。',
7638
7719
  'cli.command.auth.status.desc': '返回本地开发者会话是否可复用',
7639
7720
  'cli.command.auth.status.format': '输出格式:text | json',
7640
7721
  'cli.command.auth.status.requireAuth': '没有可复用本地会话时以非零状态退出',
@@ -7662,7 +7743,8 @@ const messages = {
7662
7743
  'cli.command.dev.format': '会话输出格式:text | json',
7663
7744
  'cli.command.dev.events': '生命周期事件流:none | ndjson',
7664
7745
  'cli.command.dev.noOpen': '不自动打开 IDE 或扫码网页',
7665
- 'cli.command.dev.evidenceDir': '会话证据输出目录',
7746
+ 'cli.command.dev.evidenceDir': '会话证据输出目录(工程外或工程根目录的 .ttmg 下)',
7747
+ 'dev.evidence.unsafeDirectory': '请将会话证据放在游戏工程外,或工程根目录的 .ttmg 下,避免日志、截图和构建输出进入游戏包。',
7666
7748
  'dev.logs.recordModeRequired': '--record-logs 仅支持 --mode client-preview。',
7667
7749
  'dev.logs.archiveFailed': '本地日志留存失败 [DEV_LOG_ARCHIVE_WRITE_FAILED],已停止留存;实时调试仍可继续。',
7668
7750
  'cli.command.dev.recordLogs': '将脱敏后的 Runtime 日志保存在本地(仅 client-preview,有容量上限)',
@@ -7830,8 +7912,8 @@ const messages = {
7830
7912
  'cli.command.upload.servePreviewPage': '启动包含测试账号与预览二维码的临时本地页面',
7831
7913
  'cli.command.upload.noOpen': '不使用默认浏览器打开本地预览页面',
7832
7914
  'cli.command.upload.format': '输出格式:text | json | ndjson',
7833
- 'cli.command.upload.vibe': 'Skill 引导绑定上传(schema v2;使用 --mock 联调试用)',
7834
- 'cli.command.upload.vibeAppInfo': '完整应用信息 JSON 文件,同时检查平台名称和头像要求(--vibe)',
7915
+ 'cli.command.upload.vibe': '绑定上传:只收集标题和头像,CLI 自动补齐其他字段的 PPE 试用默认值(schema v2',
7916
+ 'cli.command.upload.vibeAppInfo': '仅兼容旧资料文件;当前上传无需此参数,不应向用户索要',
7835
7917
  'cli.command.upload.vibeTitle': 'Skill 提供的游戏标题(--vibe)',
7836
7918
  'cli.command.upload.vibeIcon': '用户指定的本地 PNG/JPEG/WebP 图片(--vibe)',
7837
7919
  'cli.command.upload.vibePackage': '已准备的 ZIP,与 --dir 二选一(--vibe)',
@@ -8606,6 +8688,8 @@ const zipCwdToBuffer = (customIgnores = [], targetDir = process.cwd()) => {
8606
8688
  // 推到设备,体积虚胖外,host 在 fallback 路径下还可能错误命中未插桩 wasm。
8607
8689
  // 同时保留裸名 `TTMG_TEMP_DIR` 以兜底空目录场景。
8608
8690
  const defaultIgnores = [
8691
+ '.ttmg',
8692
+ '.ttmg/**',
8609
8693
  'node_modules/**',
8610
8694
  '.git/**',
8611
8695
  '.agents/**',
@@ -16288,7 +16372,7 @@ async function authStatus(options = {}) {
16288
16372
  return result;
16289
16373
  }
16290
16374
 
16291
- var version = "0.4.6-agent-beta.1";
16375
+ var version = "0.4.6-agent-beta.3";
16292
16376
  var engines = {
16293
16377
  node: ">=20.19.1 <21 || >=22"
16294
16378
  };
@@ -16297,6 +16381,722 @@ var pkg = {
16297
16381
  engines: engines
16298
16382
  };
16299
16383
 
16384
+ // Skill-facing v2 contract. Backend credentials never cross this boundary.
16385
+ const VIBE_INPUT_REQUIREMENTS = {
16386
+ requiredUserInputs: ['title', 'icon'],
16387
+ codeSource: ['dir', 'package'],
16388
+ additionalMetadataSource: 'cli-defaults',
16389
+ defaultMetadataProfile: 'ppe-trial',
16390
+ requestAdditionalMetadata: false,
16391
+ optionalSkillInputs: ['category', 'subcategory'],
16392
+ categoryQuery: 'ttmg game categories --format json',
16393
+ categoryPairRequiredWhenProvided: true,
16394
+ defaultCategory: { category: 'Other', subcategory: 'Other' },
16395
+ };
16396
+ class VibeError extends Error {
16397
+ constructor(code) {
16398
+ super(code);
16399
+ this.code = code;
16400
+ }
16401
+ }
16402
+
16403
+ var catalogVersion = "sha256:f1d67b08cfbfb9396cba1ec246a84a4c1b6b2c9f077e236498c42e9c154b6900";
16404
+ var source = "bundled";
16405
+ var categories$1 = [
16406
+ {
16407
+ value: "Action",
16408
+ subcategories: [
16409
+ {
16410
+ value: "Artillery Shooter",
16411
+ description: "Turn based tactics game in which players aim and shoot other players"
16412
+ },
16413
+ {
16414
+ value: "Asymmetric Battle",
16415
+ description: "Game in which different players have different roles and capabilities as well as win condition (e.g. Identity 5)"
16416
+ },
16417
+ {
16418
+ value: "Brawl",
16419
+ description: "Multiplayer battle where player controls a character in team deathmatch battle without aiming a gun"
16420
+ },
16421
+ {
16422
+ value: "Fighting",
16423
+ description: "A game where the player controls a character to fight against other characters"
16424
+ },
16425
+ {
16426
+ value: "MOBA",
16427
+ description: "Multiplayer battle arena where the player controls a character on a team to destroy an enemy base"
16428
+ },
16429
+ {
16430
+ value: "Music & Rhythm",
16431
+ description: "Games that feature music as the primary gameplay driver"
16432
+ },
16433
+ {
16434
+ value: "Open World",
16435
+ description: "Action game in which players can explore the world and attempt objectives freely"
16436
+ },
16437
+ {
16438
+ value: "Platformer",
16439
+ description: "A game where players complete stages by avoiding obstacles and jumping between platforms"
16440
+ },
16441
+ {
16442
+ value: "Royale Battle",
16443
+ description: "Battle royale game (many players compete but with a single winner) that is action based and not a shooter"
16444
+ },
16445
+ {
16446
+ value: "Run & Gun Platformer",
16447
+ description: "Action games similar to side-scrolling platformers, but players are typically battling an onslaught of enemies appearing in front or behind them"
16448
+ },
16449
+ {
16450
+ value: "Runner",
16451
+ description: "A game where the player automatically progresses and must avoid obstacles to continue"
16452
+ },
16453
+ {
16454
+ value: "Shark",
16455
+ description: "Game in which the player eats other objects in the game to grow or stay alive"
16456
+ },
16457
+ {
16458
+ value: "Shoot'em Up",
16459
+ description: "A game where a lone unit (usually a spaceship) shoots projectiles against a large number of other enemies. Generally in a 2D movement space"
16460
+ },
16461
+ {
16462
+ value: "Slicing (Slasher)",
16463
+ description: "Game in which the main objective is to cut objects into pieces using a swiping motion"
16464
+ },
16465
+ {
16466
+ value: "Stealth",
16467
+ description: "Game where the player attempts to sneak around a game world and achieve objectives or escape"
16468
+ },
16469
+ {
16470
+ value: "Adventure",
16471
+ description: "Focuses on the actions themselves (shooting, twitch combat, etc.) or exploration of the world instead (finding non-gameplay collectables, solving puzzles, etc.) - Super Mario"
16472
+ },
16473
+ {
16474
+ value: "Beat 'em up",
16475
+ description: "Featuring hand-to-hand combat between the protagonist and an improbably large number of opponents."
16476
+ },
16477
+ {
16478
+ value: "Other Action",
16479
+ description: "Other general action gameplay or games that feature arcade-style gameplay"
16480
+ }
16481
+ ]
16482
+ },
16483
+ {
16484
+ value: "Match",
16485
+ subcategories: [
16486
+ {
16487
+ value: "Blast-Meta",
16488
+ description: "Blast-style game with a heavy emphasis on an investable meta game system"
16489
+ },
16490
+ {
16491
+ value: "Blast-Saga",
16492
+ description: "Game where primary mechanic involves blasting/removing adjacent like items with saga based progression"
16493
+ },
16494
+ {
16495
+ value: "Bubble Shooter",
16496
+ description: "Game where bubbles are shot at different angles clearing groups of bubbles arranged on top of screen"
16497
+ },
16498
+ {
16499
+ value: "Connect Tiles",
16500
+ description: "Game in which two like tiles are linked via a pathway on a board and removed from the board"
16501
+ },
16502
+ {
16503
+ value: "Line-Saga",
16504
+ description: "Game where a line is created between like items to remove them from the board with saga based progression"
16505
+ },
16506
+ {
16507
+ value: "M3-Meta",
16508
+ description: "Match 3 swapping game with a focus on some form of building in its meta game"
16509
+ },
16510
+ {
16511
+ value: "M3-Saga",
16512
+ description: "Primary gameplay mechanism is matching 3 items on screen and swapping matched items"
16513
+ },
16514
+ {
16515
+ value: "Merge-Saga",
16516
+ description: "Game with focus on merging like items to create more powerful units or items and utilizing saga based progression"
16517
+ },
16518
+ {
16519
+ value: "Other-Meta",
16520
+ description: "Other match style game with a heavy emphasis on an investable meta game system"
16521
+ },
16522
+ {
16523
+ value: "Other Match",
16524
+ description: "Other kind of matching game"
16525
+ }
16526
+ ]
16527
+ },
16528
+ {
16529
+ value: "Puzzle",
16530
+ subcategories: [
16531
+ {
16532
+ value: "Block Puzzle",
16533
+ description: "Game in which players need to fit different shaped block pieces to fit onto a game board efficiently"
16534
+ },
16535
+ {
16536
+ value: "Brain Puzzle",
16537
+ description: "Knowledge or intelligence-based puzzle games"
16538
+ },
16539
+ {
16540
+ value: "Brick-crusher",
16541
+ description: "A bubble shooter game featuring many balls shot, high action, and real time (not turn based) gameplay"
16542
+ },
16543
+ {
16544
+ value: "Detective",
16545
+ description: "Game in which players must solve puzzles based on various clues and involves solving a narrative arc"
16546
+ },
16547
+ {
16548
+ value: "Hidden Object",
16549
+ description: "Games where the primary objective is identifying visual elements"
16550
+ },
16551
+ {
16552
+ value: "Hybrid Puzzle",
16553
+ description: "Puzzle game involving multiple types of puzzles instead of focusing on just one"
16554
+ },
16555
+ {
16556
+ value: "Jigsaw",
16557
+ description: "Jigsaw puzzle game"
16558
+ },
16559
+ {
16560
+ value: "Klotski",
16561
+ description: "A sliding block puzzle game"
16562
+ },
16563
+ {
16564
+ value: "Maze",
16565
+ description: "Game in which players must determine paths out of a maze"
16566
+ },
16567
+ {
16568
+ value: "Minesweeper",
16569
+ description: "Games similar to the classic Minesweeper game"
16570
+ },
16571
+ {
16572
+ value: "Nonogram",
16573
+ description: "Game in which cells in a grid are colored or left blank according to numbers on the side of a grid to reveal a hidden picture"
16574
+ },
16575
+ {
16576
+ value: "Physics",
16577
+ description: "Games that utilize a physics engine-based primary gameplay mechanism"
16578
+ },
16579
+ {
16580
+ value: "Room Escape",
16581
+ description: "Puzzles focused on progressing a player to escape from a room or building"
16582
+ },
16583
+ {
16584
+ value: "Spot the Difference",
16585
+ description: "Matching images and determining the difference between the images"
16586
+ },
16587
+ {
16588
+ value: "Sudoku",
16589
+ description: "Sudoku based game"
16590
+ },
16591
+ {
16592
+ value: "Tile-Matching",
16593
+ description: "Game in which tiles are matched to remove them from the game board (like Tetris)"
16594
+ },
16595
+ {
16596
+ value: "Word",
16597
+ description: "Word Games"
16598
+ },
16599
+ {
16600
+ value: "Other Puzzle",
16601
+ description: "Puzzle-based games that do not fall into the above categories"
16602
+ }
16603
+ ]
16604
+ },
16605
+ {
16606
+ value: "Racing",
16607
+ subcategories: [
16608
+ {
16609
+ value: "Arcade Racing",
16610
+ description: "Casual and fun racing arcade type of game"
16611
+ },
16612
+ {
16613
+ value: "Competitive Racing",
16614
+ description: "PVP Racing that can use a variety of control schemes"
16615
+ },
16616
+ {
16617
+ value: "Simulation Racing",
16618
+ description: "Simulated game of realistic racing"
16619
+ }
16620
+ ]
16621
+ },
16622
+ {
16623
+ value: "RPG",
16624
+ subcategories: [
16625
+ {
16626
+ value: "Empire Simulation RPG",
16627
+ description: "RPG game in which the player rules an empire or kingdom of some kind"
16628
+ },
16629
+ {
16630
+ value: "Idle RPG",
16631
+ description: "Squad based RPG that includes idle progression mechanics"
16632
+ },
16633
+ {
16634
+ value: "Location RPG",
16635
+ description: "Game where location plays a critical component in the core gameplay"
16636
+ },
16637
+ {
16638
+ value: "MMORPG",
16639
+ description: "Massively multiplayer online role-playing game typically featuring worlds or servers with hundreds or thousands of players online simultaneously"
16640
+ },
16641
+ {
16642
+ value: "Open World RPG",
16643
+ description: "RPG game where players traverse an open world relatively freely"
16644
+ },
16645
+ {
16646
+ value: "Puzzle RPG",
16647
+ description: "RPG games that utilize a puzzle system as the primary combat mechanism"
16648
+ },
16649
+ {
16650
+ value: "Roguelike ARPG",
16651
+ description: "ARPG with roguelike (sub-genre of role-playing games characterized by the exploration of randomly-generated dungeons and permanent death) gameplay elements (e.g. Hades)"
16652
+ },
16653
+ {
16654
+ value: "Survival",
16655
+ description: "RPG based game with a strong focus on crafting mechanics"
16656
+ },
16657
+ {
16658
+ value: "Tactics",
16659
+ description: "RPG game involving turn based movement of different characters on a tile-based map"
16660
+ },
16661
+ {
16662
+ value: "Team Battle",
16663
+ description: "RPG where you control multiple characters to advance the gameplay and story"
16664
+ },
16665
+ {
16666
+ value: "Tower Defense RPG",
16667
+ description: "Tower defense game with a strong emphasis on character collection and stateful upgrading"
16668
+ },
16669
+ {
16670
+ value: "Other RPG",
16671
+ description: "Other RPG Game"
16672
+ }
16673
+ ]
16674
+ },
16675
+ {
16676
+ value: "Shooting",
16677
+ subcategories: [
16678
+ {
16679
+ value: "Battle Royale",
16680
+ description: "Shooter game in which battle royale is the primary game mode"
16681
+ },
16682
+ {
16683
+ value: "Cover-fire",
16684
+ description: "Shooting game where the player takes cover and shoots out of cover"
16685
+ },
16686
+ {
16687
+ value: "PvE Shooting",
16688
+ description: "Shooter game in which players primarily battle against computer generated, player-vs-environment enemies"
16689
+ },
16690
+ {
16691
+ value: "Sniper",
16692
+ description: "Shooting game focused on small number of accurate shots through a scope"
16693
+ },
16694
+ {
16695
+ value: "Team Deathmatch",
16696
+ description: "Shooter game in which team deathmatch is the primary gameplay mode"
16697
+ }
16698
+ ]
16699
+ },
16700
+ {
16701
+ value: "Simulation",
16702
+ subcategories: [
16703
+ {
16704
+ value: "Animal Avatar",
16705
+ description: "Game in which players take the form and play as an animal in a virtual world"
16706
+ },
16707
+ {
16708
+ value: "Avatar Life",
16709
+ description: "Simulated life based on avatars in an alternate reality world"
16710
+ },
16711
+ {
16712
+ value: "Build Breed",
16713
+ description: "Games in which the player grows a city used to breed creatures for combat"
16714
+ },
16715
+ {
16716
+ value: "Building",
16717
+ description: "Games focused on building up a city with limited/no focus on combat"
16718
+ },
16719
+ {
16720
+ value: "Creative Sandbox",
16721
+ description: "Players have tools to create user generated levels or games shared with the overall game community"
16722
+ },
16723
+ {
16724
+ value: "Dress Up & Lifestyle",
16725
+ description: "Games built around dressing up avatars and simulated life-like experiences with emphasis on lifestyle"
16726
+ },
16727
+ {
16728
+ value: "Driving",
16729
+ description: "Games where the primary gameplay loop is centered around simulating driving or parking (non-competitive)."
16730
+ },
16731
+ {
16732
+ value: "Farming",
16733
+ description: "Farming-based simulation titles"
16734
+ },
16735
+ {
16736
+ value: "Home Design",
16737
+ description: "Games involving decorating homes"
16738
+ },
16739
+ {
16740
+ value: "Idle",
16741
+ description: "Games where game progression continues even when a player leaves the game session"
16742
+ },
16743
+ {
16744
+ value: "Idol Training Sim",
16745
+ description: "Game in which players train and level up Japanese style idols against different types of activities or professions"
16746
+ },
16747
+ {
16748
+ value: "Life Simulator",
16749
+ description: "Simulation of a virtual character from birth to death in which the player makes different life choices"
16750
+ },
16751
+ {
16752
+ value: "Manager",
16753
+ description: "Games simulating the management of a team or business"
16754
+ },
16755
+ {
16756
+ value: "Pet",
16757
+ description: "Games where the primary gameplay loop is centered around raising a pet (non-combat)"
16758
+ },
16759
+ {
16760
+ value: "Time Management",
16761
+ description: "Simulation games where players are challenged to complete tasks or quests within a specific (usually short) time frame"
16762
+ },
16763
+ {
16764
+ value: "Weapon Simulator",
16765
+ description: "Video game that focuses on simulating the experience of using various weapons. These games aim to provide players with a realistic or immersive representation of different types of firearms, melee weapons, or other tools used in combat"
16766
+ },
16767
+ {
16768
+ value: "Other Sim",
16769
+ description: "Other Sim/Management games"
16770
+ }
16771
+ ]
16772
+ },
16773
+ {
16774
+ value: "Sports",
16775
+ subcategories: [
16776
+ {
16777
+ value: "Arcade Sports",
16778
+ description: "Sports games where gameplay focuses on non-realistic simulation of gameplay"
16779
+ },
16780
+ {
16781
+ value: "Simulation Sports",
16782
+ description: "Sports games where the gameplay focuses on realistic simulation of gameplay"
16783
+ },
16784
+ {
16785
+ value: "Team Manager",
16786
+ description: "Sports games where the player focuses on manage a team"
16787
+ },
16788
+ {
16789
+ value: "Other Sports",
16790
+ description: "Other Sports Games"
16791
+ }
16792
+ ]
16793
+ },
16794
+ {
16795
+ value: "Strategy",
16796
+ subcategories: [
16797
+ {
16798
+ value: "4X March-Battle",
16799
+ description: "Games where player grows a city, builds an army that attacks through timed marches on a map"
16800
+ },
16801
+ {
16802
+ value: "Auto Chess",
16803
+ description: "Tactical strategy game with drafting and in which play is conducted PVP in multiple rounds"
16804
+ },
16805
+ {
16806
+ value: "Build-Battle",
16807
+ description: "Games focused on building troops that attack enemies and can be lost. City building is usually used to upgrade troops."
16808
+ },
16809
+ {
16810
+ value: "CCG-Battle",
16811
+ description: "Strategy game based on card decks typically associated with mana costs to play"
16812
+ },
16813
+ {
16814
+ value: "Lane-Battle",
16815
+ description: "Strategy game involving multiple lanes where units are sent down a specific lane to battle"
16816
+ },
16817
+ {
16818
+ value: "Legion Battle",
16819
+ description: "Game with a focus on building different types of army units and battling other players' armies"
16820
+ },
16821
+ {
16822
+ value: "Summon-Battle",
16823
+ description: "Games where units are summoned via some means (e.g., cards or mana) and then auto-battle"
16824
+ },
16825
+ {
16826
+ value: "Tower Defense Strategy",
16827
+ description: "Games where players establish defenses against NPC enemies that attack the player at regular intervals"
16828
+ },
16829
+ {
16830
+ value: "RTS",
16831
+ description: "Real-time Strategy is a strategy game that allow all users to play simultaneously instead of processing incrementally in turns"
16832
+ },
16833
+ {
16834
+ value: "Other Strategy",
16835
+ description: "Other strategy games"
16836
+ }
16837
+ ]
16838
+ },
16839
+ {
16840
+ value: "Party",
16841
+ subcategories: [
16842
+ {
16843
+ value: "Guess Something",
16844
+ description: "Social game in which players must guess what another player has in mind from different kinds of hints or drawings"
16845
+ },
16846
+ {
16847
+ value: "Karaoke",
16848
+ description: "A Karaoke game is a type of video or computer game that simulates the experience of karaoke singing"
16849
+ },
16850
+ {
16851
+ value: "Dancing",
16852
+ description: "Video game that involves gameplay elements centered around dance and rhythm. These games often require players to mimic dance moves or follow on-screen prompts in synchronization with music."
16853
+ },
16854
+ {
16855
+ value: "Mafia/Betrayal",
16856
+ description: "Mafia based game"
16857
+ },
16858
+ {
16859
+ value: "Mini Games Collection",
16860
+ description: "Multiplayer social game in which players can choose to play from a collection of multiple minigames"
16861
+ },
16862
+ {
16863
+ value: "Party Royale",
16864
+ description: "Party based battle royale game"
16865
+ },
16866
+ {
16867
+ value: "Quiz",
16868
+ description: "Game involving trivia or quizzes"
16869
+ },
16870
+ {
16871
+ value: "Other Party",
16872
+ description: "Other party game"
16873
+ }
16874
+ ]
16875
+ },
16876
+ {
16877
+ value: "Tabletop",
16878
+ subcategories: [
16879
+ {
16880
+ value: "Board Game",
16881
+ description: "Board game is a game that involves the movement of counters or other objects round a board."
16882
+ },
16883
+ {
16884
+ value: "Card Games",
16885
+ description: "[Non gambling] A card game is any game using playing cards as the primary device with which the game is played, be they traditional or game-specific"
16886
+ },
16887
+ {
16888
+ value: "Chess",
16889
+ description: "Chess is a board game for two players, called White and Black. It is sometimes called international chess or Western chess"
16890
+ },
16891
+ {
16892
+ value: "Dice",
16893
+ description: "Dice games are games that use or incorporate one or more dice as their sole or central component, usually as a random device"
16894
+ },
16895
+ {
16896
+ value: "Ludo",
16897
+ description: "Ludo is a strategy board game for two to four players, in which the players race their four tokens from start to finish according to the rolls of a single die"
16898
+ },
16899
+ {
16900
+ value: "Othello",
16901
+ description: "Game based on the classic Othello game"
16902
+ },
16903
+ {
16904
+ value: "Solitaire",
16905
+ description: "Solitaire games are card based games that can be played by a single player."
16906
+ },
16907
+ {
16908
+ value: "Other Tabletop",
16909
+ description: "Other tabletop game"
16910
+ }
16911
+ ]
16912
+ },
16913
+ {
16914
+ value: "Other",
16915
+ subcategories: [
16916
+ {
16917
+ value: "Other",
16918
+ description: "Game that doesn't fit in any of the other categories"
16919
+ }
16920
+ ]
16921
+ }
16922
+ ];
16923
+ var catalog = {
16924
+ catalogVersion: catalogVersion,
16925
+ source: source,
16926
+ categories: categories$1
16927
+ };
16928
+
16929
+ const GAME_CATEGORY_CATALOG_VERSION = catalog.catalogVersion;
16930
+ const GAME_CATEGORIES = catalog.categories;
16931
+ function isGameCategoryPair(category, subcategory) {
16932
+ return GAME_CATEGORIES.some(item => item.value === category &&
16933
+ item.subcategories.some(child => child.value === subcategory));
16934
+ }
16935
+ function getGameCategories(options = {}) {
16936
+ const errorCode = !['text', 'json', 'ndjson'].includes(options.format || 'text')
16937
+ ? 'GAME_CATEGORIES_INVALID_FORMAT'
16938
+ : options.category !== undefined && !GAME_CATEGORIES.some(item => item.value === options.category)
16939
+ ? 'GAME_CATEGORY_UNKNOWN' : null;
16940
+ const categories = errorCode ? [] : GAME_CATEGORIES.filter(item => options.category === undefined || item.value === options.category);
16941
+ return {
16942
+ schemaVersion: '1',
16943
+ command: 'game categories',
16944
+ operationId: `game_categories_${crypto.randomUUID()}`,
16945
+ status: errorCode ? 'failed' : 'ready',
16946
+ terminal: true,
16947
+ platformWrite: false,
16948
+ catalogVersion: GAME_CATEGORY_CATALOG_VERSION,
16949
+ source: catalog.source,
16950
+ descriptionLanguage: 'en',
16951
+ categoryCount: categories.length,
16952
+ subcategoryCount: categories.reduce((count, item) => count + item.subcategories.length, 0),
16953
+ categories,
16954
+ errorCode,
16955
+ error: errorCode ? t(errorCode === 'GAME_CATEGORY_UNKNOWN'
16956
+ ? 'cli.command.game.categories.unknown' : 'cli.command.game.categories.invalidFormat') : null,
16957
+ };
16958
+ }
16959
+ function gameCategories(options) {
16960
+ const result = getGameCategories(options);
16961
+ if (options.format && options.format !== 'text')
16962
+ console.log(JSON.stringify(result));
16963
+ else if (result.error)
16964
+ console.error(result.error);
16965
+ else
16966
+ for (const category of result.categories) {
16967
+ console.log(category.value);
16968
+ for (const item of category.subcategories)
16969
+ console.log(` ${item.value}: ${item.description}`);
16970
+ }
16971
+ if (result.errorCode)
16972
+ process.exitCode = 1;
16973
+ }
16974
+
16975
+ var categories = /*#__PURE__*/Object.freeze({
16976
+ __proto__: null,
16977
+ GAME_CATEGORIES: GAME_CATEGORIES,
16978
+ GAME_CATEGORY_CATALOG_VERSION: GAME_CATEGORY_CATALOG_VERSION,
16979
+ gameCategories: gameCategories,
16980
+ getGameCategories: getGameCategories,
16981
+ isGameCategoryPair: isGameCategoryPair
16982
+ });
16983
+
16984
+ // Current PPE trial defaults: the owner-approved metadata used by the successful
16985
+ // real upload fixture on 2026-09-14, with owner-specified legal URLs updated on
16986
+ // 2026-09-15. This is not a production metadata profile.
16987
+ const VIBE_PPE_DEFAULT_APP_INFO = Object.freeze({
16988
+ description: 'DevTool PPE integration test fixture. Not a production game.',
16989
+ category: 'Other',
16990
+ subcategory: 'Other',
16991
+ terms_of_service: 'https://www.tiktok.com',
16992
+ privacy_policy: 'https://www.tiktok.com',
16993
+ copyright_confirmation: true,
16994
+ contact_email: 'devtool-test@example.invalid',
16995
+ });
16996
+ // BFF IDL + Import Service ValidateAppInfo, feat-minis-import (2026-09-14).
16997
+ // Keep credentials, app_name and icon_uri out of this file: CLI owns those fields.
16998
+ const requiredStrings = [
16999
+ 'description', 'category', 'subcategory', 'terms_of_service', 'privacy_policy',
17000
+ ];
17001
+ const optionalStrings = ['apple_team_id', 'contact_email', 'region', 'min_sdk_version'];
17002
+ function applyVibeCategory(options, appInfo) {
17003
+ if (options.category === undefined && options.subcategory === undefined)
17004
+ return appInfo;
17005
+ if (options.category === undefined || options.subcategory === undefined)
17006
+ throw new VibeError('VIBE_CATEGORY_PAIR_REQUIRED');
17007
+ if (!isGameCategoryPair(options.category, options.subcategory))
17008
+ throw new VibeError('VIBE_INVALID_CATEGORY');
17009
+ return { ...(appInfo || VIBE_PPE_DEFAULT_APP_INFO), category: options.category, subcategory: options.subcategory };
17010
+ }
17011
+ function readVibeAppInfo(file) {
17012
+ if (file === undefined)
17013
+ return undefined;
17014
+ try {
17015
+ const stat = fs.statSync(file);
17016
+ if (!stat.isFile() || stat.size > 64 * 1024)
17017
+ throw new Error();
17018
+ const value = JSON.parse(fs.readFileSync(file, 'utf8'));
17019
+ if (!value || typeof value !== 'object' || Array.isArray(value))
17020
+ throw new Error();
17021
+ const allowed = new Set([...requiredStrings, ...optionalStrings, 'copyright_confirmation']);
17022
+ if (Object.keys(value).some(key => !allowed.has(key)))
17023
+ throw new Error();
17024
+ // JSON can encode lone surrogates that Go rejects as invalid UTF-8 input.
17025
+ for (const item of Object.values(value)) {
17026
+ if (typeof item === 'string' && [...item].some(char => /^[\uD800-\uDFFF]$/.test(char)))
17027
+ throw new Error();
17028
+ }
17029
+ for (const key of requiredStrings) {
17030
+ if (typeof value[key] !== 'string' || !value[key].trim())
17031
+ throw new Error();
17032
+ }
17033
+ for (const key of optionalStrings) {
17034
+ if (value[key] !== undefined && (typeof value[key] !== 'string' || !value[key].trim()))
17035
+ throw new Error();
17036
+ }
17037
+ // An explicit declaration is required; never infer consent or supply a default.
17038
+ if (value.copyright_confirmation !== true)
17039
+ throw new Error();
17040
+ for (const key of ['terms_of_service', 'privacy_policy']) {
17041
+ if (/\s/.test(value[key]))
17042
+ throw new Error();
17043
+ const url = new URL(value[key]);
17044
+ if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password)
17045
+ throw new Error();
17046
+ }
17047
+ if (value.contact_email !== undefined && !/^[^\s<>@]+@[^\s<>@]+$/.test(value.contact_email))
17048
+ throw new Error();
17049
+ return value;
17050
+ }
17051
+ catch {
17052
+ // Do not expose paths, file contents or parser errors in structured output.
17053
+ throw new VibeError('VIBE_INVALID_APP_INFO');
17054
+ }
17055
+ }
17056
+ const VIBE_PLATFORM_ICON_LIMIT = 5 * 1024 * 1024;
17057
+ // Header preflight only. The BFF remains responsible for decoding the image.
17058
+ function validateVibeUploadInput(input) {
17059
+ if ([...input.title].length > 30)
17060
+ throw new VibeError('VIBE_TITLE_TOO_LONG');
17061
+ const image = input.icon.bytes;
17062
+ if (image.length > VIBE_PLATFORM_ICON_LIMIT)
17063
+ throw new VibeError('VIBE_PLATFORM_ICON_INVALID');
17064
+ let width = 0, height = 0;
17065
+ if (image.length >= 33 && image.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex')) &&
17066
+ image.readUInt32BE(8) === 13 && image.toString('ascii', 12, 16) === 'IHDR') {
17067
+ width = image.readUInt32BE(16);
17068
+ height = image.readUInt32BE(20);
17069
+ }
17070
+ else if (image[0] === 0xff && image[1] === 0xd8) {
17071
+ let offset = 2;
17072
+ while (offset + 4 <= image.length) {
17073
+ if (image[offset++] !== 0xff)
17074
+ break;
17075
+ while (image[offset] === 0xff)
17076
+ offset++;
17077
+ const marker = image[offset++];
17078
+ if (marker === 0xda || marker === 0xd9)
17079
+ break;
17080
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7))
17081
+ continue;
17082
+ if (offset + 2 > image.length)
17083
+ break;
17084
+ const length = image.readUInt16BE(offset);
17085
+ if (length < 2 || offset + length > image.length)
17086
+ break;
17087
+ // Go image/jpeg accepts baseline, extended sequential and progressive SOF.
17088
+ if ([0xc0, 0xc1, 0xc2].includes(marker) && length >= 8) {
17089
+ height = image.readUInt16BE(offset + 3);
17090
+ width = image.readUInt16BE(offset + 5);
17091
+ break;
17092
+ }
17093
+ offset += length;
17094
+ }
17095
+ }
17096
+ if (width !== 1024 || height !== 1024)
17097
+ throw new VibeError('VIBE_PLATFORM_ICON_INVALID');
17098
+ }
17099
+
16300
17100
  function getCapabilities() {
16301
17101
  return {
16302
17102
  schemaVersion: '1',
@@ -16308,8 +17108,22 @@ function getCapabilities() {
16308
17108
  stdoutProtocolOnly: true,
16309
17109
  },
16310
17110
  commands: {
17111
+ gameCategories: {
17112
+ command: 'ttmg game categories --format json',
17113
+ available: true,
17114
+ schemaVersion: '1',
17115
+ platformWrite: false,
17116
+ source: 'bundled',
17117
+ catalogVersion: GAME_CATEGORY_CATALOG_VERSION,
17118
+ filterOption: '--category',
17119
+ includesGameplayDescriptions: true,
17120
+ requiresLogin: false,
17121
+ networkRequired: false,
17122
+ },
16311
17123
  vibeUpload: {
16312
- command: 'ttmg upload --vibe --mock --format ndjson',
17124
+ command: 'ttmg upload --vibe --web --dir <gameDir> --title <title> --icon <iconPath> --format ndjson',
17125
+ mockCommand: 'ttmg upload --vibe --mock --dir <gameDir> --title <title> --icon <iconPath> --format ndjson',
17126
+ guide: 'vibe-upload.md',
16313
17127
  option: '--vibe',
16314
17128
  available: true,
16315
17129
  platformWrite: true,
@@ -16331,19 +17145,55 @@ function getCapabilities() {
16331
17145
  taskCreation: true,
16332
17146
  statusPolling: true,
16333
17147
  authorizationScheme: 'aweme://webview?url=<encoded landing_url>',
16334
- uploadEnabled: false,
16335
- uploadBlockedError: 'VIBE_REAL_UPLOAD_NOT_READY',
17148
+ uploadEnabled: true,
16336
17149
  },
16337
17150
  terminalSuccess: 'uploaded',
16338
17151
  stages: ['precheck', 'packaging', 'prepared', 'creating_task', 'waiting_authorization', 'uploading', 'uploaded', 'failed', 'cancelled'],
16339
17152
  uploadSteps: ['avatar', 'app_info', 'package'],
16340
17153
  sourceProjectIdentity: '.ttmg/vibe-project.json',
16341
17154
  metadataInputs: ['title', 'icon', 'dir|package'],
17155
+ inputRequirements: VIBE_INPUT_REQUIREMENTS,
17156
+ classification: {
17157
+ queryCommand: 'ttmg game categories --format json',
17158
+ options: ['--category', '--subcategory'],
17159
+ suppliedTogether: true,
17160
+ default: { category: 'Other', subcategory: 'Other' },
17161
+ invalidPair: 'reject-before-task-creation',
17162
+ legacyAppInfo: 'preserved-when-options-omitted; explicit-pair-overrides-category-fields',
17163
+ resultField: 'classification',
17164
+ },
17165
+ webAuthorization: { option: '--web', backend: 'ppe', defaultLane: 'ppe_op_vc', simulation: false, opensSystemBrowser: true, continuesPolling: true, formatIndependent: true, conflictsWith: ['--mock', '--mock-scenario', '--vibe-api', '--no-open'], mockBehaviorUnchanged: true, materialUploadEnabled: true },
17166
+ skillWorkflow: {
17167
+ preferredFormat: 'ndjson',
17168
+ packageAlternative: '--package <zipPath> replaces --dir <gameDir>',
17169
+ keepUploadProcessRunning: true,
17170
+ pollingOwner: 'cli',
17171
+ uploadOwner: 'cli',
17172
+ waitingAuthorization: {
17173
+ status: 'waiting_authorization',
17174
+ userAction: 'complete authorization in the system browser on the CLI computer',
17175
+ pageUrlField: 'pageUrl',
17176
+ pageUrlPurpose: 'local binding and upload progress page',
17177
+ reopenCommandField: 'authorizationOpenCommand',
17178
+ browserOpenRequestedProvesAuthorization: false,
17179
+ createAnotherUploadTask: false,
17180
+ },
17181
+ realUploadSuccess: { terminal: true, status: 'uploaded', uploaded: true, simulation: false },
17182
+ failure: 'Read errorCode and nextAction; do not automatically create another task or reupload',
17183
+ completionMeaning: 'materials uploaded to PPE; review and production release are separate',
17184
+ sameHostRequired: true,
17185
+ },
16342
17186
  optionalMetadataFile: '--app-info',
16343
17187
  appInfoFields: {
16344
- required: ['description', 'category', 'subcategory', 'terms_of_service', 'privacy_policy', 'copyright_confirmation'],
16345
- optional: ['apple_team_id', 'contact_email', 'region', 'min_sdk_version'],
17188
+ required: [],
17189
+ optional: [],
16346
17190
  suppliedByCli: ['app_name', 'icon_uri'],
17191
+ defaultedByCli: Object.keys(VIBE_PPE_DEFAULT_APP_INFO),
17192
+ additionalMetadataSource: 'cli-defaults',
17193
+ defaultMetadataProfile: 'ppe-trial',
17194
+ requestFromUser: false,
17195
+ legacyFileOption: '--app-info',
17196
+ legacyFileOptionRecommended: false,
16347
17197
  },
16348
17198
  appInfoPreflight: {
16349
17199
  titleMaxCharacters: 30,
@@ -16369,8 +17219,9 @@ function getCapabilities() {
16369
17219
  httpContract: 'minis-import-task',
16370
17220
  authorizationQr: true,
16371
17221
  previewQr: false,
16372
- realAuthorizationVerified: false,
16373
- realUploadVerified: false,
17222
+ realAuthorizationVerified: true,
17223
+ realUploadVerified: true,
17224
+ verificationScope: { backend: 'ppe', assets: 'test-fixtures', date: '2026-09-15', production: false },
16374
17225
  autoUploadAfterAuthorization: true,
16375
17226
  boundedPolling: true,
16376
17227
  cancellation: true,
@@ -16380,6 +17231,18 @@ function getCapabilities() {
16380
17231
  available: true,
16381
17232
  platformWrite: false,
16382
17233
  },
17234
+ vibeAuthOpen: {
17235
+ command: 'ttmg auth open --page-url <pageUrl> --format json',
17236
+ available: true,
17237
+ schemaVersion: '1',
17238
+ platformWrite: false,
17239
+ requiresRunningUpload: true,
17240
+ createsTask: false,
17241
+ outputContainsLandingCredentials: false,
17242
+ target: 'system-browser',
17243
+ successStatus: 'open_requested',
17244
+ provesAuthorization: false,
17245
+ },
16383
17246
  loginImplicit: {
16384
17247
  command: 'ttmg login --implicit',
16385
17248
  available: true,
@@ -16427,6 +17290,20 @@ function getCapabilities() {
16427
17290
  projectIntegration: false,
16428
17291
  platformWrite: false,
16429
17292
  },
17293
+ wasmCollect: {
17294
+ available: true,
17295
+ experimental: true,
17296
+ authentication: 'local_cli_login',
17297
+ credentialOutput: false,
17298
+ nativeBindingRequired: false,
17299
+ platformVerified: false,
17300
+ outputFormats: ['text', 'json'],
17301
+ start: { command: 'ttmg wasm collect start', platformWrite: true,
17302
+ resetDefault: false, resetOption: '--reset', automaticRetry: false },
17303
+ export: { command: 'ttmg wasm collect export', platformWrite: false,
17304
+ collectionSchemaVersion: '1', originalInputRequired: true,
17305
+ outputPathField: 'collection.path', overwrite: false, strategy: 'union' },
17306
+ },
16430
17307
  dev: {
16431
17308
  command: 'ttmg dev --format json --no-open',
16432
17309
  eventsCommand: 'ttmg dev --events ndjson --no-open',
@@ -16511,6 +17388,8 @@ function getCapabilities() {
16511
17388
  verifiedOnDevice: false,
16512
17389
  },
16513
17390
  sessionLogs: {
17391
+ evidenceDirectoryLocations: ['outside-project', 'project-root-.ttmg'],
17392
+ unsafeEvidenceDirectoryError: 'DEV_EVIDENCE_DIRECTORY_UNSAFE',
16514
17393
  sources: ['live', 'archive'],
16515
17394
  defaultSource: 'live',
16516
17395
  runtimeSessionOption: '--runtime-session',
@@ -17016,6 +17895,8 @@ function getDesktopPath() {
17016
17895
  const program = new commander.Command();
17017
17896
  const isPreviewPageServerWorker = process.argv[2] === '__preview-page-server';
17018
17897
  const isVibePageServerWorker = process.argv[2] === '__vibe-page-server';
17898
+ const wasmArgumentIndex = process.argv.indexOf('wasm', 2);
17899
+ const isWasmCollectCommand = wasmArgumentIndex >= 2 && process.argv[wasmArgumentIndex + 1] === 'collect';
17019
17900
  const withVerboseOption = (command) => command.option('--verbose', t('cli.option.verbose'));
17020
17901
  const structuredFormatIndex = process.argv.indexOf('--format');
17021
17902
  const inlineStructuredFormat = process.argv.find(arg => arg.startsWith('--format='));
@@ -17026,6 +17907,7 @@ const structuredFormat = inlineStructuredFormat
17026
17907
  : '';
17027
17908
  if (!isPreviewPageServerWorker &&
17028
17909
  !isVibePageServerWorker &&
17910
+ !(isWasmCollectCommand && !structuredFormat) &&
17029
17911
  structuredFormat !== 'json' &&
17030
17912
  structuredFormat !== 'ndjson' &&
17031
17913
  !process.argv.includes('--implicit')) {
@@ -17054,6 +17936,21 @@ withVerboseOption(program.command('logout').description(t('cli.command.logout.de
17054
17936
  await logout();
17055
17937
  });
17056
17938
  const authCmd = program.command('auth').description(t('cli.command.auth.desc'));
17939
+ const gameCmd = program.command('game').description(t('cli.command.game.desc'));
17940
+ withVerboseOption(gameCmd.command('categories')
17941
+ .description(t('cli.command.game.categories.desc'))
17942
+ .option('--category <category>', t('cli.command.game.categories.category'))
17943
+ .option('--format <format>', t('cli.command.capabilities.format'), 'text')).action(async (cmd) => {
17944
+ const { gameCategories } = await Promise.resolve().then(function () { return categories; });
17945
+ gameCategories(cmd);
17946
+ });
17947
+ withVerboseOption(authCmd.command('open')
17948
+ .description(t('cli.command.auth.open.desc'))
17949
+ .option('--page-url <url>', t('cli.command.auth.open.pageUrl'))
17950
+ .option('--format <format>', t('cli.command.upload.format'), 'text')).action(async (cmd) => {
17951
+ const { vibeAuthOpen } = await Promise.resolve().then(function () { return authOpen; });
17952
+ await vibeAuthOpen(cmd);
17953
+ });
17057
17954
  withVerboseOption(authCmd
17058
17955
  .command('status')
17059
17956
  .description(t('cli.command.auth.status.desc'))
@@ -17080,10 +17977,13 @@ withVerboseOption(program
17080
17977
  .option('--serve-preview-page', t('cli.command.upload.servePreviewPage'))
17081
17978
  .option('--no-open', t('cli.command.upload.noOpen'))
17082
17979
  .option('--vibe', t('cli.command.upload.vibe'))
17980
+ .option('--web', t('cli.command.upload.vibeWeb'))
17083
17981
  // Vibe v2 backend selection (including opt-in ppe-mock) stays outside the Skill workflow.
17084
17982
  .option('--title <title>', t('cli.command.upload.vibeTitle'))
17085
17983
  .option('--icon <path>', t('cli.command.upload.vibeIcon'))
17086
- .option('--app-info <path>', t('cli.command.upload.vibeAppInfo'))
17984
+ .option('--category <category>', t('cli.command.upload.vibeCategory'))
17985
+ .option('--subcategory <subcategory>', t('cli.command.upload.vibeSubcategory'))
17986
+ .addOption(new commander.Option('--app-info <path>', t('cli.command.upload.vibeAppInfo')).hideHelp())
17087
17987
  .option('--package <path>', t('cli.command.upload.vibePackage'))
17088
17988
  .option('--dry-run', t('cli.command.upload.vibeDryRun'))
17089
17989
  .option('--mock', t('cli.command.upload.vibeMock'))
@@ -17092,7 +17992,8 @@ withVerboseOption(program
17092
17992
  .option('--vibe-authorize-url <url>', t('cli.command.upload.vibeAuthorizeUrl'))
17093
17993
  .option('--poll-interval <seconds>', t('cli.command.upload.vibePollInterval'))
17094
17994
  .option('--timeout <seconds>', t('cli.command.upload.vibeTimeout'))
17095
- .option('--format <format>', t('cli.command.upload.format'), 'text')).action(async (cmd) => {
17995
+ .option('--format <format>', t('cli.command.upload.format'), 'text')
17996
+ .addHelpText('after', t('cli.command.upload.vibeWebGuide'))).action(async (cmd) => {
17096
17997
  if (cmd.vibe) {
17097
17998
  const { vibeUpload } = await Promise.resolve().then(function () { return index$1; });
17098
17999
  await vibeUpload(cmd);
@@ -17102,6 +18003,9 @@ withVerboseOption(program
17102
18003
  cmd.title,
17103
18004
  cmd.icon,
17104
18005
  cmd.appInfo,
18006
+ cmd.category,
18007
+ cmd.subcategory,
18008
+ cmd.web,
17105
18009
  cmd.package,
17106
18010
  cmd.dryRun,
17107
18011
  cmd.mock,
@@ -17443,6 +18347,25 @@ devSessionCmd
17443
18347
  });
17444
18348
  });
17445
18349
  const wasmCmd = program.command('wasm').description(t('cli.command.wasm.desc'));
18350
+ const collectCmd = wasmCmd.command('collect').description(t('cli.command.wasm.collect'));
18351
+ for (const operation of ['start', 'export']) {
18352
+ const command = collectCmd.command(operation)
18353
+ .description(t(operation === 'start' ? 'cli.command.wasm.collect.start' : 'cli.command.wasm.collect.export'))
18354
+ .allowExcessArguments(false)
18355
+ .option('--client-key <key>', t('cli.command.wasm.collect.clientKey'))
18356
+ .option('--wasm-md5 <md5>', t('cli.command.wasm.collect.md5'))
18357
+ .option('--timeout <seconds>', t('cli.command.wasm.collect.timeout'), '30')
18358
+ .option('--format <format>', t('cli.command.wasm.format'), 'json');
18359
+ if (operation === 'start')
18360
+ command.option('--reset', t('cli.command.wasm.collect.reset'));
18361
+ else
18362
+ command.option('--input <path>', t('cli.command.wasm.input'))
18363
+ .option('--output <path>', t('cli.command.wasm.collect.output'));
18364
+ command.action(async (options) => {
18365
+ const Native = await Promise.resolve().then(function () { return index; });
18366
+ await Native.wasmCollectCommand(operation, options);
18367
+ });
18368
+ }
17446
18369
  for (const operation of ['prepare', 'split']) {
17447
18370
  const command = wasmCmd.command(operation)
17448
18371
  .description(t(operation === 'prepare' ? 'cli.command.wasm.prepare' : 'cli.command.wasm.split'))
@@ -17532,14 +18455,118 @@ else {
17532
18455
  program.parse(process.argv);
17533
18456
  }
17534
18457
 
17535
- class VibeError extends Error {
17536
- constructor(code) {
17537
- super(code);
17538
- this.code = code;
18458
+ // Only the backend-owned authorization page can be a browser redirect target.
18459
+ // Never return its import_token to Skill stdout or put it in a CLI argument.
18460
+ function browserLandingUrl(value) {
18461
+ if (typeof value !== 'string' || value.length > 16384 || /[\r\n]/.test(value))
18462
+ return;
18463
+ try {
18464
+ const url = new URL(value);
18465
+ if (url.origin !== 'https://developers.tiktok.com' || url.username || url.password || url.hash ||
18466
+ url.pathname !== '/portal_h5/minis_import' ||
18467
+ url.searchParams.getAll('task_id').length !== 1 ||
18468
+ url.searchParams.getAll('import_token').length !== 1 ||
18469
+ !/^\d{1,19}$/.test(url.searchParams.get('task_id') || '') ||
18470
+ !url.searchParams.get('import_token'))
18471
+ return;
18472
+ return url.href;
18473
+ }
18474
+ catch {
18475
+ return;
18476
+ }
18477
+ }
18478
+ function authorizationOpenUrl(pageUrl) {
18479
+ try {
18480
+ if (typeof pageUrl !== 'string')
18481
+ throw new Error();
18482
+ const url = new URL(pageUrl);
18483
+ if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port ||
18484
+ url.username || url.password || url.search || url.hash ||
18485
+ !/^\/[a-f0-9]{48}$/.test(url.pathname))
18486
+ throw new Error();
18487
+ return `${url.origin}${url.pathname}/authorize`;
18488
+ }
18489
+ catch {
18490
+ throw new VibeError('VIBE_INVALID_PAGE_URL');
18491
+ }
18492
+ }
18493
+ function checkAuthorizationPage(url) {
18494
+ return new Promise((resolve, reject) => {
18495
+ // HEAD never follows the redirect or requests the platform. The browser GET
18496
+ // rechecks readiness, covering state changes between the probe and opening.
18497
+ const req = http.request(url, { method: 'HEAD' }, res => {
18498
+ res.resume();
18499
+ if (res.statusCode === 204 && res.headers['x-ttmg-authorization'] === 'ready')
18500
+ resolve();
18501
+ else
18502
+ reject(new VibeError('VIBE_AUTH_PAGE_UNAVAILABLE'));
18503
+ });
18504
+ const timer = setTimeout(() => req.destroy(new Error('timeout')), 5000);
18505
+ req.once('close', () => clearTimeout(timer));
18506
+ req.once('error', () => reject(new VibeError('VIBE_AUTH_PAGE_UNAVAILABLE')));
18507
+ req.end();
18508
+ });
18509
+ }
18510
+ async function openVibeBrowser(url) {
18511
+ const child = await (await import('open')).default(url);
18512
+ return new Promise((resolve, reject) => {
18513
+ child.once('error', reject);
18514
+ if (child.pid)
18515
+ resolve(child);
18516
+ else
18517
+ child.once('spawn', () => resolve(child));
18518
+ });
18519
+ }
18520
+ async function executeVibeAuthOpen(options, deps = { open: openVibeBrowser }) {
18521
+ const result = {
18522
+ schemaVersion: '1', command: 'auth open', operationId: `auth_open_${crypto.randomUUID()}`,
18523
+ status: 'failed', platformWrite: false, browserOpenRequested: false,
18524
+ errorCode: null, error: null,
18525
+ };
18526
+ try {
18527
+ if (!['text', 'json', 'ndjson'].includes(options.format || 'text'))
18528
+ throw new VibeError('VIBE_INVALID_FORMAT');
18529
+ const url = authorizationOpenUrl(options.pageUrl);
18530
+ await checkAuthorizationPage(url);
18531
+ try {
18532
+ await deps.open(url);
18533
+ }
18534
+ catch {
18535
+ throw new VibeError('VIBE_BROWSER_OPEN_FAILED');
18536
+ }
18537
+ result.status = 'open_requested';
18538
+ result.browserOpenRequested = true;
18539
+ }
18540
+ catch (error) {
18541
+ result.errorCode = error instanceof VibeError ? error.code : 'VIBE_BROWSER_OPEN_FAILED';
18542
+ const key = result.errorCode === 'VIBE_INVALID_PAGE_URL' ? 'invalidPage'
18543
+ : result.errorCode === 'VIBE_AUTH_PAGE_UNAVAILABLE' ? 'unavailable'
18544
+ : result.errorCode === 'VIBE_INVALID_FORMAT' ? 'invalidFormat' : 'failed';
18545
+ result.error = t(`cli.command.auth.open.${key}`);
17539
18546
  }
18547
+ return result;
18548
+ }
18549
+ async function vibeAuthOpen(options) {
18550
+ const result = await executeVibeAuthOpen(options);
18551
+ if (options.format === 'json' || options.format === 'ndjson')
18552
+ console.log(JSON.stringify(result));
18553
+ else
18554
+ console.log(result.error || t('cli.command.auth.open.requested'));
18555
+ if (result.errorCode)
18556
+ process.exitCode = 1;
18557
+ return result;
17540
18558
  }
17541
18559
 
17542
- // BFF IDL: feat-minis-import. PPE task creation/polling only; no production adapter.
18560
+ var authOpen = /*#__PURE__*/Object.freeze({
18561
+ __proto__: null,
18562
+ authorizationOpenUrl: authorizationOpenUrl,
18563
+ browserLandingUrl: browserLandingUrl,
18564
+ executeVibeAuthOpen: executeVibeAuthOpen,
18565
+ openVibeBrowser: openVibeBrowser,
18566
+ vibeAuthOpen: vibeAuthOpen
18567
+ });
18568
+
18569
+ // BFF IDL: feat-minis-import. PPE import flow; no production adapter.
17543
18570
  const prefix = '/bff/app_info/minis_import/';
17544
18571
  const MINIS_IMPORT_PATHS = {
17545
18572
  create: `${prefix}create_minis_import_task_for_agent/`,
@@ -17659,7 +18686,7 @@ function createBackend(mode, transport) {
17659
18686
  path: MINIS_IMPORT_PATHS.info,
17660
18687
  data: {
17661
18688
  ...credentials(task),
17662
- app_info: { ...appInfo, app_name: title, icon_uri: iconUri },
18689
+ app_info: { ...(appInfo || VIBE_PPE_DEFAULT_APP_INFO), app_name: title, icon_uri: iconUri },
17663
18690
  },
17664
18691
  signal,
17665
18692
  });
@@ -17707,15 +18734,9 @@ function createPpeBackend(lane, adapter) {
17707
18734
  'x-use-ppe': '1',
17708
18735
  'x-tt-env': lane,
17709
18736
  }, adapter);
17710
- const uploadUnavailable = async () => {
17711
- throw new VibeError('VIBE_REAL_UPLOAD_NOT_READY');
17712
- };
17713
18737
  return {
17714
18738
  ...backend,
17715
- uploadAvailable: false,
17716
- uploadIcon: uploadUnavailable,
17717
- submitInfo: uploadUnavailable,
17718
- uploadPackage: uploadUnavailable,
18739
+ uploadAvailable: true,
17719
18740
  };
17720
18741
  }
17721
18742
  /** Opt-in integration demo: only creation and polling can reach PPE. */
@@ -17739,6 +18760,7 @@ function createPpeMockBackend(lane, adapter, scenario = 'success') {
17739
18760
  return {
17740
18761
  mode: 'ppe-mock',
17741
18762
  uploadAvailable: true,
18763
+ dispose: () => real.dispose?.(),
17742
18764
  async createTask(input, signal) {
17743
18765
  if (realTask)
17744
18766
  throw new VibeError('VIBE_TASK_NOT_ACCEPTING_INPUT');
@@ -17770,14 +18792,20 @@ function createPpeMockBackend(lane, adapter, scenario = 'success') {
17770
18792
  };
17771
18793
  }
17772
18794
  function createHttpBackend(mode, baseURL, environmentHeaders = {}, adapter) {
18795
+ // Own the agents so an early multipart response cannot leave upload sockets
18796
+ // keeping the CLI alive after its terminal result. Never destroy global agents.
18797
+ const httpAgent = new http.Agent();
18798
+ const httpsAgent = new https.Agent();
17773
18799
  const client = axios.create({
17774
18800
  baseURL,
17775
18801
  timeout: 30000,
17776
18802
  maxRedirects: 0,
17777
18803
  proxy: false,
17778
18804
  adapter,
18805
+ httpAgent,
18806
+ httpsAgent,
17779
18807
  });
17780
- return createBackend(mode, async (request) => {
18808
+ const backend = createBackend(mode, async (request) => {
17781
18809
  try {
17782
18810
  let body;
17783
18811
  let headers = {};
@@ -17816,6 +18844,13 @@ function createHttpBackend(mode, baseURL, environmentHeaders = {}, adapter) {
17816
18844
  : 'VIBE_BACKEND_REQUEST_FAILED');
17817
18845
  }
17818
18846
  });
18847
+ return {
18848
+ ...backend,
18849
+ dispose() {
18850
+ httpAgent.destroy();
18851
+ httpsAgent.destroy();
18852
+ },
18853
+ };
17819
18854
  }
17820
18855
  const MOCK_SCENARIOS = [
17821
18856
  'success',
@@ -18127,101 +19162,6 @@ async function checkCommand(options = {}) {
18127
19162
  return report;
18128
19163
  }
18129
19164
 
18130
- // BFF IDL + Import Service ValidateAppInfo, feat-minis-import (2026-09-14).
18131
- // Keep credentials, app_name and icon_uri out of this file: CLI owns those fields.
18132
- const requiredStrings = [
18133
- 'description', 'category', 'subcategory', 'terms_of_service', 'privacy_policy',
18134
- ];
18135
- const optionalStrings = ['apple_team_id', 'contact_email', 'region', 'min_sdk_version'];
18136
- function readVibeAppInfo(file) {
18137
- if (file === undefined)
18138
- return undefined;
18139
- try {
18140
- const stat = fs.statSync(file);
18141
- if (!stat.isFile() || stat.size > 64 * 1024)
18142
- throw new Error();
18143
- const value = JSON.parse(fs.readFileSync(file, 'utf8'));
18144
- if (!value || typeof value !== 'object' || Array.isArray(value))
18145
- throw new Error();
18146
- const allowed = new Set([...requiredStrings, ...optionalStrings, 'copyright_confirmation']);
18147
- if (Object.keys(value).some(key => !allowed.has(key)))
18148
- throw new Error();
18149
- // JSON can encode lone surrogates that Go rejects as invalid UTF-8 input.
18150
- for (const item of Object.values(value)) {
18151
- if (typeof item === 'string' && [...item].some(char => /^[\uD800-\uDFFF]$/.test(char)))
18152
- throw new Error();
18153
- }
18154
- for (const key of requiredStrings) {
18155
- if (typeof value[key] !== 'string' || !value[key].trim())
18156
- throw new Error();
18157
- }
18158
- for (const key of optionalStrings) {
18159
- if (value[key] !== undefined && (typeof value[key] !== 'string' || !value[key].trim()))
18160
- throw new Error();
18161
- }
18162
- // An explicit declaration is required; never infer consent or supply a default.
18163
- if (value.copyright_confirmation !== true)
18164
- throw new Error();
18165
- for (const key of ['terms_of_service', 'privacy_policy']) {
18166
- if (/\s/.test(value[key]))
18167
- throw new Error();
18168
- const url = new URL(value[key]);
18169
- if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password)
18170
- throw new Error();
18171
- }
18172
- if (value.contact_email !== undefined && !/^[^\s<>@]+@[^\s<>@]+$/.test(value.contact_email))
18173
- throw new Error();
18174
- return value;
18175
- }
18176
- catch {
18177
- // Do not expose paths, file contents or parser errors in structured output.
18178
- throw new VibeError('VIBE_INVALID_APP_INFO');
18179
- }
18180
- }
18181
- const VIBE_PLATFORM_ICON_LIMIT = 5 * 1024 * 1024;
18182
- // Header preflight only. The BFF remains responsible for decoding the image.
18183
- function validateVibeUploadInput(input) {
18184
- if ([...input.title].length > 30)
18185
- throw new VibeError('VIBE_TITLE_TOO_LONG');
18186
- const image = input.icon.bytes;
18187
- if (image.length > VIBE_PLATFORM_ICON_LIMIT)
18188
- throw new VibeError('VIBE_PLATFORM_ICON_INVALID');
18189
- let width = 0, height = 0;
18190
- if (image.length >= 33 && image.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex')) &&
18191
- image.readUInt32BE(8) === 13 && image.toString('ascii', 12, 16) === 'IHDR') {
18192
- width = image.readUInt32BE(16);
18193
- height = image.readUInt32BE(20);
18194
- }
18195
- else if (image[0] === 0xff && image[1] === 0xd8) {
18196
- let offset = 2;
18197
- while (offset + 4 <= image.length) {
18198
- if (image[offset++] !== 0xff)
18199
- break;
18200
- while (image[offset] === 0xff)
18201
- offset++;
18202
- const marker = image[offset++];
18203
- if (marker === 0xda || marker === 0xd9)
18204
- break;
18205
- if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7))
18206
- continue;
18207
- if (offset + 2 > image.length)
18208
- break;
18209
- const length = image.readUInt16BE(offset);
18210
- if (length < 2 || offset + length > image.length)
18211
- break;
18212
- // Go image/jpeg accepts baseline, extended sequential and progressive SOF.
18213
- if ([0xc0, 0xc1, 0xc2].includes(marker) && length >= 8) {
18214
- height = image.readUInt16BE(offset + 3);
18215
- width = image.readUInt16BE(offset + 5);
18216
- break;
18217
- }
18218
- offset += length;
18219
- }
18220
- }
18221
- if (width !== 1024 || height !== 1024)
18222
- throw new VibeError('VIBE_PLATFORM_ICON_INVALID');
18223
- }
18224
-
18225
19165
  // Prototype resource guards, not agreed platform validation rules.
18226
19166
  const VIBE_ICON_LIMIT = 10 * 1024 * 1024;
18227
19167
  const VIBE_ZIP_LIMIT = 100 * 1024 * 1024;
@@ -18242,7 +19182,7 @@ async function prepareVibeInput(options, onPackaging, signal, deps = { check: ex
18242
19182
  const title = options.title?.trim();
18243
19183
  if (!title)
18244
19184
  throw new VibeError('VIBE_TITLE_REQUIRED');
18245
- const appInfo = readVibeAppInfo(options.appInfo);
19185
+ const appInfo = applyVibeCategory(options, readVibeAppInfo(options.appInfo));
18246
19186
  if (options.package && options.dir)
18247
19187
  throw new VibeError('VIBE_INPUT_CONFLICT');
18248
19188
  const image = readFile(options.icon, VIBE_ICON_LIMIT, 'VIBE_INVALID_ICON');
@@ -18285,7 +19225,7 @@ async function prepareVibeInput(options, onPackaging, signal, deps = { check: ex
18285
19225
  },
18286
19226
  code: { bytes: code, name: 'game.zip', contentType: 'application/zip' },
18287
19227
  };
18288
- if (appInfo)
19228
+ if (options.appInfo !== undefined)
18289
19229
  validateVibeUploadInput(input);
18290
19230
  return input;
18291
19231
  }
@@ -18369,6 +19309,14 @@ async function renderVibePage(input) {
18369
19309
  mockNote: input.hybrid ? 'Real task creation / polling · Mock authorization / upload · Real task status unchanged' : 'Mock only · no scan needed, nothing is uploaded',
18370
19310
  footer: 'Temporary page · closes after 15 minutes',
18371
19311
  };
19312
+ if (input.web) {
19313
+ copy.steps[0] = zh ? '网页授权' : 'Web authorization';
19314
+ copy.bindingAction = zh ? '在浏览器完成授权绑定' : 'Authorize in your browser';
19315
+ copy.bindingSubtitle = input.simulation
19316
+ ? (zh ? '本次为模拟运行,无需完成网页授权,稍后继续模拟上传。' : 'Mock run: browser approval is not required; simulated upload continues shortly.')
19317
+ : (zh ? '请在已打开的 TikTok 页面确认授权,当前命令会继续查询绑定状态。' : 'Confirm authorization on the opened TikTok page. The command continues polling.');
19318
+ copy.bindingStatus = input.simulation ? (zh ? '模拟等待授权' : 'Simulating authorization') : (zh ? '等待网页授权' : 'Waiting for browser authorization');
19319
+ }
18372
19320
  const step = phase === 'binding' ? 0 : phase === 'uploading' ? 1 : 2;
18373
19321
  // Only embed the chosen raster image; never fetch a URL or expose its local path.
18374
19322
  const icon = input.iconDataUrl &&
@@ -18392,7 +19340,7 @@ async function renderVibePage(input) {
18392
19340
  : phase === 'failed'
18393
19341
  ? escape(input.message)
18394
19342
  : copy.completeSubtitle;
18395
- const qr = phase === 'binding' && input.qrUrl
19343
+ const qr = phase === 'binding' && !input.web && input.qrUrl
18396
19344
  ? await QRCode.toDataURL(input.qrUrl, {
18397
19345
  width: 384,
18398
19346
  margin: 4,
@@ -18411,11 +19359,13 @@ async function renderVibePage(input) {
18411
19359
  : phase === 'failed'
18412
19360
  ? copy.failedTitle
18413
19361
  : copy.accepted;
18414
- const content = qr
18415
- ? `<div class="qr-area"><div class="qr-frame"><img width="192" height="192" alt="${purpose}" src="${qr}"></div></div>`
18416
- : phase === 'uploading'
18417
- ? `<div class="upload-area"><div class="upload-symbol"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 16V4m-4 4 4-4 4 4M5 15v5h14v-5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></div>${progress !== undefined ? `<div class="percentage">${progress}<span>%</span></div><div class="progress-wrap"><progress aria-label="${copy.uploadLabel}" max="100" value="${progress}"></progress><p class="transfer-label">${copy.uploadLabel}</p></div>` : '<span class="spinner" aria-hidden="true"></span>'}</div>`
18418
- : `<div class="upload-area"><div class="pending-symbol">${phase === 'failed' ? '!' : checkIcon}</div></div>`;
19362
+ const content = phase === 'binding' && input.web
19363
+ ? '<div class="upload-area"><span class="spinner" aria-hidden="true"></span></div>'
19364
+ : qr
19365
+ ? `<div class="qr-area"><div class="qr-frame"><img width="192" height="192" alt="${purpose}" src="${qr}"></div></div>`
19366
+ : phase === 'uploading'
19367
+ ? `<div class="upload-area"><div class="upload-symbol"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 16V4m-4 4 4-4 4 4M5 15v5h14v-5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></div>${progress !== undefined ? `<div class="percentage">${progress}<span>%</span></div><div class="progress-wrap"><progress aria-label="${copy.uploadLabel}" max="100" value="${progress}"></progress><p class="transfer-label">${copy.uploadLabel}</p></div>` : '<span class="spinner" aria-hidden="true"></span>'}</div>`
19368
+ : `<div class="upload-area"><div class="pending-symbol">${phase === 'failed' ? '!' : checkIcon}</div></div>`;
18419
19369
  return `<!doctype html><html lang="${zh ? 'zh-CN' : 'en'}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta http-equiv="refresh" content="2"><title>${escape(title)} · ${escape(input.title)}</title><style>${vibePageStyles}</style></head>
18420
19370
  <body><main class="shell" data-phase="${phase}"><header class="brand"><span class="brand-mark" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 20 20" fill="none"><path d="m7 5-4 5 4 5m6-10 4 5-4 5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="brand-name">TikTok Minis</span><span class="brand-tool">DevTool</span>${input.simulation ? `<span class="mock-badge">${copy.mock}</span>` : ''}</header>
18421
19371
  <section class="card" aria-labelledby="page-title">${phase === 'failed' ? '' : `<ol class="steps" aria-label="${zh ? '上传步骤' : 'Upload steps'}">${copy.steps.map((label, i) => `<li class="step ${i < step ? 'done' : i === step ? 'active' : ''}"${i === step ? ' aria-current="step"' : ''}><span class="step-number">${i < step ? checkIcon : i + 1}</span>${label}</li>`).join('')}</ol>`}
@@ -18427,9 +19377,27 @@ async function renderVibePage(input) {
18427
19377
  async function createVibePageServer() {
18428
19378
  const route = `/${crypto.randomBytes(24).toString('hex')}`;
18429
19379
  let html = 'Starting…';
19380
+ let landingUrl;
18430
19381
  const server = http.createServer((req, res) => {
18431
19382
  const address = server.address();
18432
19383
  const host = address && typeof address !== 'string' ? `127.0.0.1:${address.port}` : '';
19384
+ if (req.url === `${route}/authorize` && req.headers.host === host &&
19385
+ ['GET', 'HEAD'].includes(req.method || '') &&
19386
+ (!req.headers.origin || req.headers.origin === `http://${host}`) &&
19387
+ req.headers['sec-fetch-site'] !== 'cross-site') {
19388
+ const headers = { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' };
19389
+ if (!landingUrl) {
19390
+ res.writeHead(410, { ...headers, 'Content-Type': 'text/plain; charset=utf-8' });
19391
+ res.end('Authorization is no longer available. Return to the upload process.');
19392
+ }
19393
+ else if (req.method === 'HEAD') {
19394
+ res.writeHead(204, { ...headers, 'X-TTMG-Authorization': 'ready' }).end();
19395
+ }
19396
+ else {
19397
+ res.writeHead(302, { ...headers, Location: landingUrl }).end();
19398
+ }
19399
+ return;
19400
+ }
18433
19401
  if (req.method !== 'GET' ||
18434
19402
  req.url !== route ||
18435
19403
  req.headers.host !== host) {
@@ -18452,8 +19420,9 @@ async function createVibePageServer() {
18452
19420
  const address = server.address();
18453
19421
  return {
18454
19422
  url: `http://127.0.0.1:${address.port}${route}`,
18455
- update: (value) => {
19423
+ update: (value, authorization) => {
18456
19424
  html = value;
19425
+ landingUrl = browserLandingUrl(authorization);
18457
19426
  },
18458
19427
  close: () => server.close(),
18459
19428
  };
@@ -18475,7 +19444,7 @@ async function runVibePageWorker() {
18475
19444
  if (message?.type === 'render' &&
18476
19445
  typeof message.html === 'string' &&
18477
19446
  message.html.length < VIBE_PAGE_HTML_LIMIT) {
18478
- server.update(message.html);
19447
+ server.update(message.html, message.browserLandingUrl);
18479
19448
  process.send?.({ type: 'rendered', id: message.id });
18480
19449
  }
18481
19450
  if (message?.type === 'close') {
@@ -18523,7 +19492,8 @@ async function startVibePage() {
18523
19492
  const html = await renderVibePage(input);
18524
19493
  const current = ++id;
18525
19494
  const ack = receive(m => m?.type === 'rendered' && m.id === current);
18526
- child.send({ type: 'render', id: current, html }, error => {
19495
+ const landingUrl = input.phase === 'binding' ? browserLandingUrl(input.browserLandingUrl) : undefined;
19496
+ child.send({ type: 'render', id: current, html, browserLandingUrl: landingUrl }, error => {
18527
19497
  if (error)
18528
19498
  child.emit('error', error);
18529
19499
  });
@@ -18640,7 +19610,7 @@ const defaults = {
18640
19610
  startPage: startVibePage,
18641
19611
  sleep: abortableDelay,
18642
19612
  projectId: getVibeProjectId,
18643
- open: async (url) => (await import('open')).default(url),
19613
+ open: openVibeBrowser,
18644
19614
  };
18645
19615
  async function executeVibeUpload(options, onEvent = () => { }, dependencies = {}, externalSignal) {
18646
19616
  const deps = { ...defaults, ...dependencies };
@@ -18652,6 +19622,7 @@ async function executeVibeUpload(options, onEvent = () => { }, dependencies = {}
18652
19622
  let timeout;
18653
19623
  let timedOut = false;
18654
19624
  let page;
19625
+ let backend = deps.backend;
18655
19626
  let detached = false;
18656
19627
  let progressUpdates = Promise.resolve();
18657
19628
  let ppeLane;
@@ -18661,6 +19632,7 @@ async function executeVibeUpload(options, onEvent = () => { }, dependencies = {}
18661
19632
  schemaVersion: '2',
18662
19633
  command: 'upload',
18663
19634
  mode: 'vibe',
19635
+ inputRequirements: VIBE_INPUT_REQUIREMENTS,
18664
19636
  operationId: `vibe_${crypto.randomUUID()}`,
18665
19637
  ts: Date.now(),
18666
19638
  status: 'precheck',
@@ -18694,7 +19666,17 @@ async function executeVibeUpload(options, onEvent = () => { }, dependencies = {}
18694
19666
  // --mock is the stable trial entry: working steps use the built-in PPE lane,
18695
19667
  // blocked steps remain simulated. No environment setup is required.
18696
19668
  // Explicit command-line selection wins; real failures never fall back to mock.
18697
- if (!options.mock && !options.vibeApi && process.env.TTMG_VIBE_BACKEND) {
19669
+ if (options.web) {
19670
+ if (options.mock || options.mockScenario || options.vibeApi || options.open === false)
19671
+ throw new VibeError('VIBE_WEB_OPTION_CONFLICT');
19672
+ ppeSelected = true;
19673
+ ppeLane = process.env.TTMG_VIBE_PPE || 'ppe_op_vc';
19674
+ if (!/^ppe_[a-zA-Z0-9_-]{1,64}$/.test(ppeLane))
19675
+ throw new VibeError('VIBE_INVALID_PPE_ENV');
19676
+ if (deps.backend && deps.backend.mode !== 'ppe')
19677
+ throw new VibeError('VIBE_WEB_OPTION_CONFLICT');
19678
+ }
19679
+ else if (!options.mock && !options.vibeApi && process.env.TTMG_VIBE_BACKEND) {
18698
19680
  const configured = process.env.TTMG_VIBE_BACKEND;
18699
19681
  if (configured === 'mock')
18700
19682
  options = { ...options, mock: true };
@@ -18731,7 +19713,6 @@ async function executeVibeUpload(options, onEvent = () => { }, dependencies = {}
18731
19713
  timedOut = true;
18732
19714
  controller.abort();
18733
19715
  }, timeoutMs);
18734
- let backend = deps.backend;
18735
19716
  if (!options.dryRun && !backend) {
18736
19717
  if (options.mock)
18737
19718
  backend = deps.createTrialBackend(options.mockScenario);
@@ -18756,6 +19737,12 @@ async function executeVibeUpload(options, onEvent = () => { }, dependencies = {}
18756
19737
  terminal: !!options.dryRun,
18757
19738
  nextAction: options.dryRun ? 'none' : 'wait',
18758
19739
  title: input.title,
19740
+ classification: {
19741
+ category: (input.appInfo || VIBE_PPE_DEFAULT_APP_INFO).category,
19742
+ subcategory: (input.appInfo || VIBE_PPE_DEFAULT_APP_INFO).subcategory,
19743
+ source: options.category !== undefined ? 'arguments' : input.appInfo ? 'app-info' : 'defaults',
19744
+ catalogVersion: GAME_CATEGORY_CATALOG_VERSION,
19745
+ },
18759
19746
  iconSizeBytes: input.icon.bytes.length,
18760
19747
  packageSizeBytes: input.code.bytes.length,
18761
19748
  packageSha256: crypto.createHash('sha256')
@@ -18769,6 +19756,7 @@ async function executeVibeUpload(options, onEvent = () => { }, dependencies = {}
18769
19756
  iconDataUrl: `data:${input.icon.contentType};base64,${input.icon.bytes.toString('base64')}`,
18770
19757
  uploadAvailable: backend.uploadAvailable !== false,
18771
19758
  hybrid: backend.mode === 'ppe-mock',
19759
+ web: !!options.web,
18772
19760
  };
18773
19761
  const sourceProjectId = deps.projectId(options);
18774
19762
  emit('creating_task', { platformWrite: backend.mode === 'ppe' || backend.mode === 'ppe-mock' });
@@ -18780,6 +19768,8 @@ async function executeVibeUpload(options, onEvent = () => { }, dependencies = {}
18780
19768
  }, controller.signal);
18781
19769
  ensureActive();
18782
19770
  const authorizationUrl = createAuthorizationScheme(task.landingUrl);
19771
+ if (options.web && !browserLandingUrl(task.landingUrl))
19772
+ throw new VibeError('VIBE_INVALID_RESPONSE');
18783
19773
  const pollMs = seconds(options.pollInterval, task.pollIntervalSeconds);
18784
19774
  result = {
18785
19775
  ...result,
@@ -18788,32 +19778,55 @@ async function executeVibeUpload(options, onEvent = () => { }, dependencies = {}
18788
19778
  };
18789
19779
  page = await deps.startPage();
18790
19780
  ensureActive();
18791
- const bindingMessage = backend.mode === 'ppe-mock'
19781
+ const bindingMessage = options.web
18792
19782
  ? (getCurrentLanguage() === 'zh-CN'
18793
- ? '真实 PPE 任务和授权二维码;查询三次后模拟授权、上传,无需完成手机授权。'
18794
- : 'Real PPE task and authorization QR. After three polls, authorization and upload are simulated; phone authorization is not required.')
18795
- : backend.uploadAvailable === false
18796
- ? (getCurrentLanguage() === 'zh-CN' ? '请扫码完成真实 PPE 授权;上传尚未开放。' : 'Scan to authorize in PPE. Upload is not enabled yet.')
18797
- : text('waiting_authorization', result.simulation);
19783
+ ? '请在浏览器完成真实授权,当前命令会继续查询绑定状态。'
19784
+ : 'Complete real authorization in the browser; this command keeps polling the binding status.')
19785
+ : backend.mode === 'ppe-mock'
19786
+ ? (getCurrentLanguage() === 'zh-CN'
19787
+ ? '真实 PPE 任务和授权二维码;查询三次后模拟授权、上传,无需完成手机授权。'
19788
+ : 'Real PPE task and authorization QR. After three polls, authorization and upload are simulated; phone authorization is not required.')
19789
+ : backend.uploadAvailable === false
19790
+ ? (getCurrentLanguage() === 'zh-CN' ? '请扫码完成真实 PPE 授权;上传尚未开放。' : 'Scan to authorize in PPE. Upload is not enabled yet.')
19791
+ : text('waiting_authorization', result.simulation);
18798
19792
  await page.update({
18799
19793
  ...pageGame,
18800
19794
  language: getCurrentLanguage(),
18801
19795
  phase: 'binding',
18802
19796
  simulation: result.simulation,
18803
19797
  message: bindingMessage,
18804
- qrUrl: authorizationUrl,
19798
+ qrUrl: options.web ? undefined : authorizationUrl,
19799
+ browserLandingUrl: task.landingUrl,
18805
19800
  purpose: 'authorization_binding',
18806
19801
  });
18807
19802
  ensureActive();
19803
+ let browserOpenRequested = false;
19804
+ let browserOpenErrorCode;
19805
+ if (options.web) {
19806
+ try {
19807
+ await deps.open(authorizationOpenUrl(page.url));
19808
+ browserOpenRequested = true;
19809
+ }
19810
+ catch {
19811
+ // Preserve the task and polling; the returned helper retries this same page.
19812
+ browserOpenErrorCode = 'VIBE_BROWSER_OPEN_FAILED';
19813
+ }
19814
+ }
18808
19815
  emit('waiting_authorization', {
18809
- message: bindingMessage,
19816
+ message: browserOpenErrorCode
19817
+ ? (getCurrentLanguage() === 'zh-CN' ? '浏览器未能打开,请使用 authorizationOpenCommand 重试;当前任务仍在等待授权。' : 'Browser opening failed. Retry authorizationOpenCommand; this task is still waiting for authorization.')
19818
+ : bindingMessage,
18810
19819
  pageUrl: page.url,
18811
- qrPurpose: 'authorization_binding',
18812
- nextAction: result.simulation ? 'wait' : 'scan_qr',
19820
+ authorizationMode: options.web ? 'web' : 'qr',
19821
+ ...(options.web ? { browserOpenRequested, browserOpenErrorCode } : {}),
19822
+ authorizationOpenCommand: browserLandingUrl(task.landingUrl)
19823
+ ? `ttmg auth open --page-url ${page.url} --format json` : undefined,
19824
+ qrPurpose: options.web ? undefined : 'authorization_binding',
19825
+ nextAction: result.simulation ? 'wait' : options.web ? 'open_browser' : 'scan_qr',
18813
19826
  readyForInput: false,
18814
19827
  });
18815
19828
  // Opening a browser is presentation only and never gates token polling.
18816
- if ((options.format || 'text') === 'text' && options.open !== false)
19829
+ if (!options.web && (options.format || 'text') === 'text' && options.open !== false)
18817
19830
  void deps.open(page.url).catch(() => { });
18818
19831
  while (true) {
18819
19832
  ensureActive();
@@ -18921,6 +19934,9 @@ async function executeVibeUpload(options, onEvent = () => { }, dependencies = {}
18921
19934
  ? 'cancelled'
18922
19935
  : 'failed';
18923
19936
  const messages = {
19937
+ VIBE_CATEGORY_PAIR_REQUIRED: ['--category 和 --subcategory 必须同时提供;均不传时保留默认分类。使用 ttmg game categories --format json 查询。', 'Provide --category and --subcategory together, or omit both to keep the default. Query ttmg game categories --format json.'],
19938
+ VIBE_INVALID_CATEGORY: ['类型或子类型无效,或两者不匹配。使用 ttmg game categories --format json 查询有效组合。', 'Invalid category/subcategory pair. Query valid values with ttmg game categories --format json.'],
19939
+ VIBE_WEB_OPTION_CONFLICT: ['--web 使用真实网页授权,不能与 --mock、--mock-scenario、--vibe-api 或 --no-open 同时使用。', '--web uses real browser authorization and cannot be combined with --mock, --mock-scenario, --vibe-api or --no-open.'],
18924
19940
  VIBE_AUTH_DENIED: [
18925
19941
  '已取消授权,本次未上传。',
18926
19942
  'Authorization cancelled. No upload was performed.',
@@ -18937,8 +19953,8 @@ async function executeVibeUpload(options, onEvent = () => { }, dependencies = {}
18937
19953
  VIBE_TITLE_REQUIRED: ['请提供游戏名称。', 'Provide a game title.'],
18938
19954
  VIBE_TITLE_TOO_LONG: ['游戏名称不能超过 30 个字符。', 'The game title must not exceed 30 characters.'],
18939
19955
  VIBE_INVALID_APP_INFO: [
18940
- '请检查 --app-info JSON:需要简介、分类、子分类、有效的条款及隐私链接、明确的版权确认;不要填写任务凭据、名称或头像 URI。',
18941
- 'Check --app-info JSON: description, category, subcategory, valid terms/privacy URLs and explicit copyright confirmation are required. Do not include credentials, app_name or icon_uri.',
19956
+ ' --app-info 文件无效。当前上传只需标题和头像,CLI 自动补齐其他字段的 PPE 试用默认值;移除 --app-info 后重试,不要向用户索要额外资料。',
19957
+ 'Invalid legacy --app-info file. Current uploads need only title and icon; CLI fills other fields with PPE trial defaults. Remove --app-info and retry without requesting extra fields from the user.',
18942
19958
  ],
18943
19959
  VIBE_PLATFORM_ICON_INVALID: [
18944
19960
  '平台头像要求 PNG/JPEG、1024×1024,且不超过 5 MiB,请重新选择本地图片。',
@@ -19043,6 +20059,7 @@ async function executeVibeUpload(options, onEvent = () => { }, dependencies = {}
19043
20059
  finally {
19044
20060
  clearTimeout(timeout);
19045
20061
  externalSignal?.removeEventListener('abort', cancel);
20062
+ backend?.dispose?.();
19046
20063
  if (page && !detached)
19047
20064
  page.close();
19048
20065
  }
@@ -19913,7 +20930,12 @@ async function watch(options = {}) {
19913
20930
  /**
19914
20931
  * 忽略 wasmcode/cache 目录
19915
20932
  */
19916
- ignored: /(^|[\/\\])(\.git|\.ttmg|node_modules|__TTMG_TEMP__)/,
20933
+ ignored: file => {
20934
+ if (/(^|[\/\\])(\.git|node_modules|__TTMG_TEMP__)/.test(file))
20935
+ return true;
20936
+ const relative = path$1.relative(process.cwd(), file);
20937
+ return relative === '.ttmg' || relative.startsWith(`.ttmg${path$1.sep}`);
20938
+ },
19917
20939
  ignoreInitial: true, // 忽略初始添加事件
19918
20940
  persistent: true,
19919
20941
  awaitWriteFinish: {
@@ -27737,7 +28759,7 @@ function requireOperationId(value) {
27737
28759
  async function snapshotProject(source, signal) {
27738
28760
  const directory = await fsp.mkdtemp(path$1.join(os$1.tmpdir(), 'ttmg-iteration-'));
27739
28761
  const hash = crypto$1.createHash('sha256');
27740
- const ignored = new Set(['.git', '.ttmg', 'node_modules', '__TTMG_TEMP__']);
28762
+ const ignored = new Set(['.git', 'node_modules', '__TTMG_TEMP__']);
27741
28763
  const dispose = () => fs$1.rmSync(directory, { recursive: true, force: true });
27742
28764
  try {
27743
28765
  async function copy(relative) {
@@ -27746,7 +28768,7 @@ async function snapshotProject(source, signal) {
27746
28768
  });
27747
28769
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
27748
28770
  signal?.throwIfAborted();
27749
- if (ignored.has(entry.name))
28771
+ if (ignored.has(entry.name) || (!relative && entry.name === '.ttmg'))
27750
28772
  continue;
27751
28773
  const name = path$1.join(relative, entry.name);
27752
28774
  if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile()))
@@ -28047,6 +29069,24 @@ class PreviewWorkflow {
28047
29069
  }
28048
29070
  }
28049
29071
 
29072
+ function physicalPath(directory) {
29073
+ const absolute = path$1.resolve(directory);
29074
+ if (fs$1.existsSync(absolute))
29075
+ return fs$1.realpathSync(absolute);
29076
+ const parent = path$1.dirname(absolute);
29077
+ if (parent === absolute)
29078
+ return absolute;
29079
+ return path$1.join(physicalPath(parent), path$1.basename(absolute));
29080
+ }
29081
+ // Packaging reserves only the project-root .ttmg directory. Never silently put
29082
+ // session output into an ordinary game asset directory (including via symlinks).
29083
+ function isSafeEvidenceDirectory(project, directory) {
29084
+ const relative = path$1.relative(physicalPath(project), physicalPath(directory));
29085
+ return path$1.isAbsolute(relative) || relative === '..' ||
29086
+ relative.startsWith(`..${path$1.sep}`) || relative === '.ttmg' ||
29087
+ relative.startsWith(`.ttmg${path$1.sep}`);
29088
+ }
29089
+
28050
29090
  function normalizeMode(mode) {
28051
29091
  const value = (mode || 'default').toLowerCase();
28052
29092
  if (value !== 'default' && value !== 'client-preview') {
@@ -28116,6 +29156,17 @@ async function dev(options = {}) {
28116
29156
  }
28117
29157
  const humanPreview = mode === 'client-preview' && !structured;
28118
29158
  const managed = structured || humanPreview;
29159
+ if (managed && !isSafeEvidenceDirectory(process.cwd(), options.evidenceDir || path$1.join('.ttmg', 'sessions'))) {
29160
+ const failed = { schemaVersion: '1', command: 'dev', status: 'failed',
29161
+ sessionId: null, platformWrite: false, errorCode: 'DEV_EVIDENCE_DIRECTORY_UNSAFE',
29162
+ error: t('dev.evidence.unsafeDirectory') };
29163
+ if (structured)
29164
+ console.log(JSON.stringify(failed));
29165
+ else
29166
+ console.error(failed.error);
29167
+ process.exitCode = 1;
29168
+ return failed;
29169
+ }
28119
29170
  const humanOutput = humanPreview ? new HumanPreviewOutput() : null;
28120
29171
  const scanPage = mode === 'client-preview' ? new ClientPreviewScanPage() : null;
28121
29172
  const timeoutMs = normalizeTimeout(options.timeout);
@@ -29993,6 +31044,8 @@ async function startBackgroundPreview(options) {
29993
31044
  const clientKey = options.clientKey?.trim();
29994
31045
  if (!clientKey)
29995
31046
  throw workflowError('DEV_ITERATION_INVALID_OPTIONS', 'Pass --client-key when starting a background preview.');
31047
+ if (!isSafeEvidenceDirectory(process.cwd(), path$1.dirname(lock)))
31048
+ throw workflowError('DEV_EVIDENCE_DIRECTORY_UNSAFE', 'The project .ttmg directory must not resolve to a game asset directory.');
29996
31049
  fs$1.mkdirSync(path$1.dirname(lock), { recursive: true });
29997
31050
  try {
29998
31051
  fs$1.mkdirSync(lock, { mode: 0o700 });
@@ -30198,12 +31251,17 @@ async function snapshotWasm(input, output) {
30198
31251
  callback(bytes > 512 * 1024 * 1024 ? fail('WASM_INPUT_INVALID', 'Expanded Wasm exceeds 512 MiB.') : null, chunk);
30199
31252
  } });
30200
31253
  const source = fs$1.createReadStream(input);
31254
+ const fileMd5 = crypto$1.createHash('md5');
31255
+ const identity = new node_stream.Transform({ transform(chunk, _encoding, callback) {
31256
+ fileMd5.update(chunk);
31257
+ callback(null, chunk);
31258
+ } });
30201
31259
  const target = fs$1.createWriteStream(output, { flags: 'wx', mode: 0o600 });
30202
31260
  try {
30203
31261
  if (input.endsWith('.br'))
30204
- await promises$2.pipeline(source, zlib$1.createBrotliDecompress(), bound, target);
31262
+ await promises$2.pipeline(source, identity, zlib$1.createBrotliDecompress(), bound, target);
30205
31263
  else
30206
- await promises$2.pipeline(source, bound, target);
31264
+ await promises$2.pipeline(source, identity, bound, target);
30207
31265
  }
30208
31266
  catch (error) {
30209
31267
  throw fail(error.code === 'WASM_INPUT_INVALID' ? error.code : 'WASM_INPUT_INVALID', 'Could not read the original Wasm input.');
@@ -30220,7 +31278,11 @@ async function snapshotWasm(input, output) {
30220
31278
  throw fail('WASM_INPUT_INVALID', 'Expected a WebAssembly version 1 binary.');
30221
31279
  if (!WebAssembly.validate(await fsp.readFile(output)))
30222
31280
  throw fail('WASM_INPUT_INVALID', 'Original Wasm is invalid or unsupported by this Node.js WebAssembly engine.');
30223
- return { bytes, wasmSha256: await digest(output) };
31281
+ const rawMd5 = crypto$1.createHash('md5');
31282
+ for await (const chunk of fs$1.createReadStream(output))
31283
+ rawMd5.update(chunk);
31284
+ return { bytes, wasmSha256: await digest(output), wasmMd5: fileMd5.digest('hex'),
31285
+ uncompressedWasmMd5: rawMd5.digest('hex') };
30224
31286
  }
30225
31287
  async function executeWasm(operation, options, dependencies = {}) {
30226
31288
  const base = { schemaVersion: '1', command: `wasm.${operation}`, operationId: `wasm_${crypto$1.randomUUID()}`,
@@ -30328,6 +31390,149 @@ async function wasmCommand(operation, options) {
30328
31390
  process.exitCode = result.status === 'failed' ? 1 : 0;
30329
31391
  }
30330
31392
 
31393
+ class CollectError extends Error {
31394
+ constructor(code, message) {
31395
+ super(message);
31396
+ this.code = code;
31397
+ }
31398
+ }
31399
+ const invalid = (code, message) => { throw new CollectError(code, message); };
31400
+ async function executeWasmCollect(operation, options, overrides = {}) {
31401
+ const deps = { authenticated: () => Boolean(getCurrentUser()?.cookie), request, ...overrides };
31402
+ const base = { schemaVersion: '1', command: `wasm.collect.${operation}`,
31403
+ operationId: `wasm_collect_${crypto$1.randomUUID()}`, platformWrite: operation === 'start',
31404
+ platformWriteAttempted: false, sourceModified: false };
31405
+ let temporary;
31406
+ let staging;
31407
+ let timer;
31408
+ try {
31409
+ const timeout = Number(options.timeout ?? 30);
31410
+ if (!['start', 'export'].includes(operation) || !['json', 'text'].includes(options.format || 'json') ||
31411
+ !/^[A-Za-z0-9_-]{1,128}$/.test(options.clientKey || '') || !/^[a-fA-F0-9]{32}$/.test(options.wasmMd5 || '') ||
31412
+ !Number.isInteger(timeout) || timeout < 1 || timeout > 300 ||
31413
+ (options.reset !== undefined && typeof options.reset !== 'boolean') ||
31414
+ (operation === 'export' && (!options.input || !options.output || options.reset !== undefined)) ||
31415
+ (operation === 'start' && (options.input || options.output))) {
31416
+ invalid('WASM_COLLECT_INVALID_OPTIONS', 'Provide client-key and wasm-md5; export also requires original input and a new output file. Timeout: 1–300 seconds.');
31417
+ }
31418
+ const clientKey = options.clientKey;
31419
+ const wasmMd5 = options.wasmMd5.toLowerCase();
31420
+ const output = options.output ? path$1.resolve(options.output) : undefined;
31421
+ let source;
31422
+ if (operation === 'export') {
31423
+ // lstat detects dangling links as well as existing files. Never overwrite.
31424
+ if (await fsp.lstat(output).catch((e) => { if (e.code !== 'ENOENT')
31425
+ throw e; return null; }))
31426
+ invalid('WASM_OUTPUT_EXISTS', 'Collection output already exists; choose a new file.');
31427
+ temporary = await fsp.mkdtemp(path$1.join(os$1.tmpdir(), 'ttmg-collect-'));
31428
+ try {
31429
+ source = await snapshotWasm(path$1.resolve(options.input), path$1.join(temporary, 'original.wasm'));
31430
+ }
31431
+ catch {
31432
+ return invalid('WASM_INPUT_INVALID', 'Expected a valid original Wasm or Brotli Wasm file.');
31433
+ }
31434
+ if (wasmMd5 !== source.wasmMd5 && wasmMd5 !== source.uncompressedWasmMd5)
31435
+ invalid('WASM_COLLECTION_MISMATCH', 'Collection MD5 does not match this original Wasm (file bytes or uncompressed bytes).');
31436
+ }
31437
+ if (!deps.authenticated())
31438
+ invalid('WASM_AUTH_REQUIRED', 'Run ttmg login on this machine before collecting or exporting.');
31439
+ const controller = new AbortController();
31440
+ const deadline = new Promise((_, reject) => {
31441
+ timer = setTimeout(() => {
31442
+ controller.abort();
31443
+ reject(new CollectError('WASM_COLLECT_TIMEOUT', 'Platform request timed out. A start may have reached the server; retry without --reset to preserve collection.'));
31444
+ }, timeout * 1000);
31445
+ });
31446
+ base.platformWriteAttempted = operation === 'start';
31447
+ const response = await Promise.race([deps.request({
31448
+ url: `${WASM_COLLECT_BASE_URL}/${operation === 'start' ? 'start' : 'export'}`,
31449
+ method: operation === 'start' ? 'POST' : 'GET',
31450
+ ...(operation === 'start'
31451
+ ? { data: { app_id: clientKey, wasm_md5: wasmMd5, reset: options.reset ?? false } }
31452
+ : { params: { app_id: clientKey, wasm_md5: wasmMd5, strategy: 'union' } }),
31453
+ signal: controller.signal,
31454
+ logRequestBody: false, logApiCalls: false,
31455
+ }), deadline]);
31456
+ clearTimeout(timer);
31457
+ const code = response.error?.code ?? response.error?.StatusCode ?? response.data?.code;
31458
+ const httpStatus = response.ctx?.httpStatusCode;
31459
+ // Upstream bodies/errors are deliberately never included in output: they
31460
+ // can contain credentials or gateway HTML, even with verbose enabled.
31461
+ if ([401, -401].includes(Number(code)) || httpStatus === 401 ||
31462
+ (Number(code) === -1 && /login|log.?in|登录|未登|auth/i.test(String(response.error?.message ?? response.error?.StatusMessage ?? response.data?.message ?? ''))))
31463
+ invalid('WASM_AUTH_REQUIRED', 'Platform login expired. Run ttmg login and retry.');
31464
+ if ([403, -403].includes(Number(code)) || httpStatus === 403)
31465
+ invalid('WASM_COLLECT_FORBIDDEN', 'This account cannot access collection for this game.');
31466
+ if (response.error || response.data?.code !== 0 || (httpStatus && (httpStatus < 200 || httpStatus >= 300)))
31467
+ invalid('WASM_COLLECT_API_FAILED', 'Platform collection request failed; no collection file was written.');
31468
+ if (operation === 'start') {
31469
+ if (response.data.collect_state !== 'open')
31470
+ invalid('WASM_COLLECT_INVALID_RESPONSE', 'Platform did not confirm an open collection window.');
31471
+ return { ...base, status: 'succeeded', clientKey, wasmMd5, reset: options.reset ?? false,
31472
+ collectState: 'open', nextAction: 'run_instrumented_game_and_collect', errorCode: null, error: null };
31473
+ }
31474
+ if (Array.isArray(response.data.func_ids) && response.data.func_ids.length === 0)
31475
+ invalid('WASM_COLLECTION_EMPTY', 'No function IDs collected. Run the instrumented game and export again.');
31476
+ let ids;
31477
+ try {
31478
+ ids = parseCollection({ schemaVersion: '1', wasmSha256: source.wasmSha256,
31479
+ funcIds: response.data.func_ids, bootFuncIds: response.data.boot_func_ids }, source.wasmSha256);
31480
+ }
31481
+ catch {
31482
+ return invalid('WASM_COLLECTION_INVALID', 'Platform returned invalid function IDs; no collection file was written.');
31483
+ }
31484
+ const collection = { schemaVersion: '1', wasmSha256: source.wasmSha256, ...ids,
31485
+ clientKey, wasmMd5, strategy: 'union', exportedAt: new Date().toISOString() };
31486
+ const bytes = Buffer.from(JSON.stringify(collection, null, 2) + '\n');
31487
+ if (bytes.length > 32 * 1024 * 1024)
31488
+ invalid('WASM_COLLECTION_INVALID', 'Collection JSON exceeds 32 MiB.');
31489
+ await fsp.mkdir(path$1.dirname(output), { recursive: true });
31490
+ staging = path$1.join(path$1.dirname(output), `.ttmg-collection-${crypto$1.randomUUID()}.tmp`);
31491
+ await fsp.writeFile(staging, bytes, { flag: 'wx', mode: 0o600 });
31492
+ // Publish a complete file atomically, with exclusive no-clobber semantics.
31493
+ try {
31494
+ await fsp.link(staging, output);
31495
+ }
31496
+ catch (e) {
31497
+ if (e.code === 'EEXIST')
31498
+ invalid('WASM_OUTPUT_EXISTS', 'Collection output already exists; choose a new file.');
31499
+ throw e;
31500
+ }
31501
+ return { ...base, status: 'succeeded', clientKey, wasmMd5,
31502
+ collection: { path: output, schemaVersion: '1', wasmSha256: source.wasmSha256,
31503
+ funcCount: ids.funcIds.length, bootFuncCount: ids.bootFuncIds.length,
31504
+ bytes: bytes.length, sha256: crypto$1.createHash('sha256').update(bytes).digest('hex') },
31505
+ nextAction: 'split_with_collection', errorCode: null, error: null };
31506
+ }
31507
+ catch (error) {
31508
+ const known = error instanceof CollectError;
31509
+ return { ...base, status: 'failed', errorCode: known ? error.code : 'WASM_COLLECT_FAILED',
31510
+ error: known ? error.message : 'Collection operation failed; check the original Wasm, output directory and connection.',
31511
+ nextAction: known && error.code === 'WASM_AUTH_REQUIRED' ? 'login' : 'fix_and_retry' };
31512
+ }
31513
+ finally {
31514
+ if (timer)
31515
+ clearTimeout(timer);
31516
+ if (staging)
31517
+ await fsp.unlink(staging).catch(() => { });
31518
+ if (temporary)
31519
+ await fsp.rm(temporary, { recursive: true, force: true });
31520
+ }
31521
+ }
31522
+ async function wasmCollectCommand(operation, options) {
31523
+ const result = await executeWasmCollect(operation, options);
31524
+ if ((options.format || 'json') === 'json')
31525
+ console.log(JSON.stringify(result));
31526
+ else {
31527
+ console.log(t(result.status === 'failed' ? 'wasm.collect.failed' : 'wasm.collect.succeeded'));
31528
+ if (result.error)
31529
+ console.error(result.error);
31530
+ if ('collection' in result)
31531
+ console.log(result.collection.path);
31532
+ }
31533
+ process.exitCode = result.status === 'failed' ? 1 : 0;
31534
+ }
31535
+
30331
31536
  const problem = (code, message) => Object.assign(new Error(message), { code });
30332
31537
  const MAX_BYTES = 64 * 1024 * 1024;
30333
31538
  // Export one finite Runtime interval. Sequence numbers are global, so gaps in
@@ -30531,6 +31736,7 @@ var index = /*#__PURE__*/Object.freeze({
30531
31736
  devSessionStopCommand: devSessionStopCommand,
30532
31737
  devWorkflowCommand: devWorkflowCommand,
30533
31738
  doctor: doctor,
31739
+ wasmCollectCommand: wasmCollectCommand,
30534
31740
  wasmCommand: wasmCommand
30535
31741
  });
30536
31742
  //# sourceMappingURL=index.js.map