@ttmg/cli 0.4.6-vibe-beta.14 → 0.4.6-vibe-beta.16

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
@@ -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,32 +7241,35 @@ 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',
7234
- 'cli.command.auth.open.desc': 'Open an existing Vibe task authorization page in the system browser',
7235
- 'cli.command.upload.vibeWeb': 'Authorize in the browser, then upload to PPE (--vibe; no Mock or --no-open)',
7244
+ 'cli.command.auth.open.desc': 'Reopen the local QR page for an existing Vibe task',
7245
+ 'cli.command.upload.vibeWeb': 'Disabled; use --vibe and scan with TikTok on your phone',
7236
7246
  'cli.command.game.desc': 'Inspect game metadata supported by the platform',
7237
7247
  'cli.command.game.categories.desc': 'List game categories and subcategories with gameplay descriptions (offline, read-only)',
7238
7248
  'cli.command.game.categories.category': 'Filter by an exact category value, e.g. Puzzle',
7239
7249
  'cli.command.game.categories.unknown': 'Unknown category. Run ttmg game categories --format json for valid values.',
7240
7250
  'cli.command.game.categories.invalidFormat': 'Use text, json, or ndjson output.',
7241
7251
  'cli.command.upload.vibeCategory': 'Optional game category; provide with --subcategory (--vibe). Query ttmg game categories --format json',
7252
+ 'cli.command.upload.vibeDescription': 'Optional game description (--vibe); default: Discover the fun of TikTok Mini Games.',
7242
7253
  'cli.command.upload.vibeSubcategory': 'Optional subcategory belonging to --category; omit both to keep Other/Other (--vibe)',
7243
7254
  'cli.command.upload.vibeWebGuide': `
7244
- Vibe web upload (real PPE trial):
7245
- ttmg upload --vibe --web --dir ./game --title "My game" --icon ./avatar.png --format ndjson
7255
+ Vibe QR upload (real PPE trial):
7256
+ ttmg upload --vibe --dir ./game --title "My game" --icon ./avatar.png --format ndjson
7246
7257
  Use --package ./game.zip instead of --dir for an existing ZIP.
7247
7258
  Collect only title and icon; the CLI fills other app_info fields with PPE trial defaults.
7259
+ Skill may pass --description "Game description"; otherwise use "Discover the fun of TikTok Mini Games." (legacy metadata-file descriptions are preserved).
7248
7260
  Skill may query ttmg game categories --format json and pass --category Puzzle --subcategory Physics together.
7249
7261
  Omit both to keep Other/Other (or the legacy metadata-file classification). Invalid pairs fail before task creation.
7250
7262
  prepared and terminal events expose the submitted classification and its source.
7251
- Keep this process running. At waiting_authorization, complete authorization in the system browser on the CLI computer.
7252
- pageUrl is the local binding/progress page. To reopen authorization, execute the returned authorizationOpenCommand.
7263
+ Keep this process running. At waiting_authorization, scan the QR on pageUrl with TikTok on your phone.
7264
+ pageUrl is the local QR/progress page on the CLI computer. Reopen it with authorizationOpenCommand.
7253
7265
  The CLI polls and uploads avatar, app_info and package automatically after authorization. Do not start a second upload.
7254
7266
  Real success: terminal=true, status=uploaded, uploaded=true, simulation=false. On failure, read errorCode and nextAction.
7255
- --web opens the browser for every output format. --mock is a separate simulation mode.
7267
+ The local QR page opens for every output format; --no-open disables automatic opening.
7268
+ The page displays the submitted title, icon, description, category and subcategory.
7256
7269
  Upload completion is not review approval or production release. See bundled vibe-upload.md and ttmg capabilities --format json.
7257
7270
  `,
7258
7271
  'cli.command.auth.open.pageUrl': 'Local pageUrl returned by the running Vibe upload command',
7259
- 'cli.command.auth.open.requested': 'Browser open requested. Complete authorization there; the existing upload process continues polling.',
7272
+ 'cli.command.auth.open.requested': 'QR page open requested. Scan with TikTok on your phone; the existing upload process continues polling.',
7260
7273
  'cli.command.auth.open.invalidPage': 'Pass the local pageUrl from the running Vibe upload command, not the platform URL.',
7261
7274
  'cli.command.auth.open.unavailable': 'Authorization page is unavailable, expired, or no longer waiting. Check the existing upload process; no new task was created.',
7262
7275
  'cli.command.auth.open.invalidFormat': 'Use text, json, or ndjson output.',
@@ -7455,9 +7468,9 @@ Vibe web upload (real PPE trial):
7455
7468
  'cli.command.upload.dir': 'Game project directory (default: current directory)',
7456
7469
  'cli.command.upload.preview': 'Wait for platform processing and output the preview payload',
7457
7470
  'cli.command.upload.servePreviewPage': 'Serve a temporary local preview page with test-account and preview QR codes',
7458
- 'cli.command.upload.noOpen': 'Do not open the local preview page in the default browser',
7471
+ 'cli.command.upload.noOpen': 'Do not automatically open the local preview or Vibe QR page',
7459
7472
  'cli.command.upload.format': 'Output format: text | json | ndjson',
7460
- 'cli.command.upload.vibe': 'Binding and upload: collect only title and icon; CLI fills other fields with PPE trial defaults (schema v2)',
7473
+ 'cli.command.upload.vibe': 'Scan with TikTok and upload: provide title and icon; CLI fills other fields with PPE trial defaults (schema v2)',
7461
7474
  'cli.command.upload.vibeTitle': 'Game title supplied by the Skill (--vibe)',
7462
7475
  'cli.command.upload.vibeAppInfo': 'Legacy metadata file compatibility; not needed for current uploads and must not be requested from users',
7463
7476
  'cli.command.upload.vibeIcon': 'Local PNG/JPEG/WebP selected by the user (--vibe)',
@@ -7645,6 +7658,16 @@ Vibe web upload (real PPE trial):
7645
7658
  'zh-CN': {
7646
7659
  'cli.command.wasm.desc': '准备或分包 Wasm,产物写入独立目录',
7647
7660
  'cli.command.wasm.prepare': '对原始 Wasm 插桩,用于函数采集',
7661
+ 'cli.command.wasm.collect': '复用 CLI 本机登录态管理平台函数采集',
7662
+ 'cli.command.wasm.collect.start': '开启或恢复采集窗口;未指定 --reset 时保留已有函数 ID',
7663
+ 'cli.command.wasm.collect.export': '导出函数 ID,生成与原始 Wasm 匹配的 collection.json',
7664
+ 'cli.command.wasm.collect.clientKey': '采集游戏的 Client Key',
7665
+ 'cli.command.wasm.collect.md5': '插桩游戏上报使用的原始 Wasm 采集 MD5',
7666
+ 'cli.command.wasm.collect.timeout': '平台请求超时:1–300 秒',
7667
+ 'cli.command.wasm.collect.reset': '清空此游戏、此 Wasm 的历史函数 ID,再开始采集',
7668
+ 'cli.command.wasm.collect.output': '新的采集 JSON 文件(不覆盖已有文件)',
7669
+ 'wasm.collect.succeeded': 'Wasm 采集命令已完成。',
7670
+ 'wasm.collect.failed': 'Wasm 采集命令失败。',
7648
7671
  'cli.command.wasm.split': '使用与原始 Wasm 匹配的采集数据分包',
7649
7672
  'cli.command.wasm.input': '原始 .wasm 或 Brotli .wasm.br 文件',
7650
7673
  'cli.command.wasm.output': '新的输出目录(不覆盖已有内容)',
@@ -7666,32 +7689,35 @@ Vibe web upload (real PPE trial):
7666
7689
  'cli.command.login.password': '开发者账号密码(implicit 模式)',
7667
7690
  'cli.command.logout.desc': '退出登录并清除本地用户信息',
7668
7691
  'cli.command.auth.desc': '查看本地登录状态',
7669
- 'cli.command.auth.open.desc': '在系统浏览器打开已有 Vibe 任务的授权页',
7670
- 'cli.command.upload.vibeWeb': '网页授权就绪后自动上传到 PPE(--vibe;不能与 Mock 或 --no-open 同用)',
7692
+ 'cli.command.auth.open.desc': '重新打开已有 Vibe 任务的本地二维码页',
7693
+ 'cli.command.upload.vibeWeb': '已关闭;使用 --vibe 并在手机 TikTok 中扫码授权',
7671
7694
  'cli.command.game.desc': '查询平台支持的游戏资料',
7672
7695
  'cli.command.game.categories.desc': '查询游戏类型、子类型及玩法说明(离线、只读)',
7673
7696
  'cli.command.game.categories.category': '按类型的精确值筛选,例如 Puzzle',
7674
7697
  'cli.command.game.categories.unknown': '类型不存在。使用 ttmg game categories --format json 查询有效值。',
7675
7698
  'cli.command.game.categories.invalidFormat': '输出格式请使用 text、json 或 ndjson。',
7676
7699
  'cli.command.upload.vibeCategory': '可选游戏类型,须与 --subcategory 同传(--vibe);用 ttmg game categories --format json 查询',
7700
+ 'cli.command.upload.vibeDescription': '可选游戏描述(--vibe);默认值:Discover the fun of TikTok Mini Games.',
7677
7701
  'cli.command.upload.vibeSubcategory': '所属类型的可选子类型;两者均不传保留 Other/Other(--vibe)',
7678
7702
  'cli.command.upload.vibeWebGuide': `
7679
- Vibe 网页上传(真实 PPE 试用):
7680
- ttmg upload --vibe --web --dir ./game --title "游戏名称" --icon ./avatar.png --format ndjson
7703
+ Vibe 扫码上传(真实 PPE 试用):
7704
+ ttmg upload --vibe --dir ./game --title "游戏名称" --icon ./avatar.png --format ndjson
7681
7705
  已有 ZIP 时,用 --package ./game.zip 替换 --dir。
7682
7706
  只收集标题和头像;其他 app_info 字段由 CLI 使用 PPE 试用默认值补齐。
7707
+ Skill 可传 --description "游戏描述";省略时使用 Discover the fun of TikTok Mini Games.(旧资料文件中的描述保留)。
7683
7708
  Skill 可先查 ttmg game categories --format json,再成对传入 --category Puzzle --subcategory Physics。
7684
7709
  两者均不传保留 Other/Other(旧资料文件调用保留文件分类);无效组合在创建任务前报错。
7685
7710
  prepared 和终态事件返回最终 classification 及其来源。
7686
- 保持原进程运行。在 waiting_authorization 阶段,在 CLI 所在电脑的系统浏览器完成授权。
7687
- pageUrl 是本机绑定/进度页;重新打开授权页时,执行返回的 authorizationOpenCommand。
7711
+ 保持原进程运行。在 waiting_authorization 阶段,使用手机 TikTok 扫描 pageUrl 页面中的二维码。
7712
+ pageUrl 是 CLI 所在电脑的二维码/进度页;重新打开时,执行返回的 authorizationOpenCommand。
7688
7713
  CLI 负责轮询,授权后自动上传 avatar、app_info、package,不再启动第二次上传。
7689
7714
  真实成功:terminal=true、status=uploaded、uploaded=true、simulation=false。失败时读取 errorCode 和 nextAction。
7690
- --web 在所有输出格式下都会打开浏览器;--mock 是独立的模拟模式。
7715
+ 所有输出格式都会打开本地二维码页;--no-open 可关闭自动打开。
7716
+ 页面展示本次提交的标题、头像、描述、类型和子类型。
7691
7717
  上传完成不代表审核通过或正式上线。详见包内 vibe-upload.md 和 ttmg capabilities --format json。
7692
7718
  `,
7693
7719
  'cli.command.auth.open.pageUrl': '正在运行的 Vibe 上传命令返回的本地 pageUrl',
7694
- 'cli.command.auth.open.requested': '已请求浏览器打开授权页。请在页面完成授权,原上传进程继续轮询。',
7720
+ 'cli.command.auth.open.requested': '已请求打开二维码页。请用手机 TikTok 扫码授权,原上传进程继续轮询。',
7695
7721
  'cli.command.auth.open.invalidPage': '请传入正在运行的 Vibe 上传命令返回的本地 pageUrl,不是平台授权链接。',
7696
7722
  'cli.command.auth.open.unavailable': '授权页不可用、已过期或已不在等待授权。请查看原上传进程状态;未新建任务。',
7697
7723
  'cli.command.auth.open.invalidFormat': '输出格式请使用 text、json 或 ndjson。',
@@ -7890,9 +7916,9 @@ Vibe 网页上传(真实 PPE 试用):
7890
7916
  'cli.command.upload.dir': '小游戏项目根目录(默认为当前目录)',
7891
7917
  'cli.command.upload.preview': '等待平台处理并输出预览二维码原始内容',
7892
7918
  'cli.command.upload.servePreviewPage': '启动包含测试账号与预览二维码的临时本地页面',
7893
- 'cli.command.upload.noOpen': '不使用默认浏览器打开本地预览页面',
7919
+ 'cli.command.upload.noOpen': '不自动打开本地预览页面或 Vibe 二维码页',
7894
7920
  'cli.command.upload.format': '输出格式:text | json | ndjson',
7895
- 'cli.command.upload.vibe': '绑定上传:只收集标题和头像,CLI 自动补齐其他字段的 PPE 试用默认值(schema v2)',
7921
+ 'cli.command.upload.vibe': 'TikTok 扫码上传:只收集标题和头像,CLI 自动补齐其他字段的 PPE 试用默认值(schema v2)',
7896
7922
  'cli.command.upload.vibeAppInfo': '仅兼容旧资料文件;当前上传无需此参数,不应向用户索要',
7897
7923
  'cli.command.upload.vibeTitle': 'Skill 提供的游戏标题(--vibe)',
7898
7924
  'cli.command.upload.vibeIcon': '用户指定的本地 PNG/JPEG/WebP 图片(--vibe)',
@@ -16352,7 +16378,7 @@ async function authStatus(options = {}) {
16352
16378
  return result;
16353
16379
  }
16354
16380
 
16355
- var version = "0.4.6-vibe-beta.14";
16381
+ var version = "0.4.6-vibe-beta.16";
16356
16382
  var engines = {
16357
16383
  node: ">=20.19.1 <21 || >=22"
16358
16384
  };
@@ -16362,13 +16388,15 @@ var pkg = {
16362
16388
  };
16363
16389
 
16364
16390
  // Skill-facing v2 contract. Backend credentials never cross this boundary.
16391
+ const VIBE_DEFAULT_DESCRIPTION = 'Discover the fun of TikTok Mini Games.';
16365
16392
  const VIBE_INPUT_REQUIREMENTS = {
16366
16393
  requiredUserInputs: ['title', 'icon'],
16367
16394
  codeSource: ['dir', 'package'],
16368
16395
  additionalMetadataSource: 'cli-defaults',
16369
16396
  defaultMetadataProfile: 'ppe-trial',
16370
16397
  requestAdditionalMetadata: false,
16371
- optionalSkillInputs: ['category', 'subcategory'],
16398
+ optionalSkillInputs: ['category', 'subcategory', 'description'],
16399
+ defaultDescription: VIBE_DEFAULT_DESCRIPTION,
16372
16400
  categoryQuery: 'ttmg game categories --format json',
16373
16401
  categoryPairRequiredWhenProvided: true,
16374
16402
  defaultCategory: { category: 'Other', subcategory: 'Other' },
@@ -16963,9 +16991,10 @@ var categories = /*#__PURE__*/Object.freeze({
16963
16991
 
16964
16992
  // Current PPE trial defaults: the owner-approved metadata used by the successful
16965
16993
  // real upload fixture on 2026-09-14, with owner-specified legal URLs updated on
16966
- // 2026-09-15. This is not a production metadata profile.
16994
+ // 2026-09-15 and the owner-approved brand description on 2026-09-16.
16995
+ // This is not a production metadata profile.
16967
16996
  const VIBE_PPE_DEFAULT_APP_INFO = Object.freeze({
16968
- description: 'DevTool PPE integration test fixture. Not a production game.',
16997
+ description: VIBE_DEFAULT_DESCRIPTION,
16969
16998
  category: 'Other',
16970
16999
  subcategory: 'Other',
16971
17000
  terms_of_service: 'https://www.tiktok.com',
@@ -16979,6 +17008,14 @@ const requiredStrings = [
16979
17008
  'description', 'category', 'subcategory', 'terms_of_service', 'privacy_policy',
16980
17009
  ];
16981
17010
  const optionalStrings = ['apple_team_id', 'contact_email', 'region', 'min_sdk_version'];
17011
+ function applyVibeDescription(description, appInfo) {
17012
+ if (description === undefined)
17013
+ return appInfo;
17014
+ if (typeof description !== 'string' || !description.trim() ||
17015
+ [...description].some(char => /^[\uD800-\uDFFF]$/.test(char)))
17016
+ throw new VibeError('VIBE_INVALID_DESCRIPTION');
17017
+ return { ...(appInfo || VIBE_PPE_DEFAULT_APP_INFO), description: description.trim() };
17018
+ }
16982
17019
  function applyVibeCategory(options, appInfo) {
16983
17020
  if (options.category === undefined && options.subcategory === undefined)
16984
17021
  return appInfo;
@@ -17101,7 +17138,7 @@ function getCapabilities() {
17101
17138
  networkRequired: false,
17102
17139
  },
17103
17140
  vibeUpload: {
17104
- command: 'ttmg upload --vibe --web --dir <gameDir> --title <title> --icon <iconPath> --format ndjson',
17141
+ command: 'ttmg upload --vibe --dir <gameDir> --title <title> --icon <iconPath> --format ndjson',
17105
17142
  mockCommand: 'ttmg upload --vibe --mock --dir <gameDir> --title <title> --icon <iconPath> --format ndjson',
17106
17143
  guide: 'vibe-upload.md',
17107
17144
  option: '--vibe',
@@ -17124,7 +17161,7 @@ function getCapabilities() {
17124
17161
  host: 'developers.tiktok.com',
17125
17162
  taskCreation: true,
17126
17163
  statusPolling: true,
17127
- authorizationScheme: 'aweme://webview?url=<encoded landing_url>',
17164
+ authorizationScheme: 'landing_url',
17128
17165
  uploadEnabled: true,
17129
17166
  },
17130
17167
  terminalSuccess: 'uploaded',
@@ -17133,6 +17170,15 @@ function getCapabilities() {
17133
17170
  sourceProjectIdentity: '.ttmg/vibe-project.json',
17134
17171
  metadataInputs: ['title', 'icon', 'dir|package'],
17135
17172
  inputRequirements: VIBE_INPUT_REQUIREMENTS,
17173
+ description: {
17174
+ option: '--description',
17175
+ required: false,
17176
+ default: VIBE_PPE_DEFAULT_APP_INFO.description,
17177
+ precedence: ['argument', 'legacy-app-info', 'default'],
17178
+ invalidInput: 'reject-before-task-creation',
17179
+ resultField: 'description',
17180
+ sourceField: 'descriptionSource',
17181
+ },
17136
17182
  classification: {
17137
17183
  queryCommand: 'ttmg game categories --format json',
17138
17184
  options: ['--category', '--subcategory'],
@@ -17142,7 +17188,27 @@ function getCapabilities() {
17142
17188
  legacyAppInfo: 'preserved-when-options-omitted; explicit-pair-overrides-category-fields',
17143
17189
  resultField: 'classification',
17144
17190
  },
17145
- 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 },
17191
+ webAuthorization: { option: '--web', available: false, errorCode: 'VIBE_WEB_DISABLED', replacement: '--vibe', materialUploadEnabled: false },
17192
+ qrAuthorization: {
17193
+ command: 'ttmg upload --vibe --dir <gameDir> --title <title> --icon <iconPath> --format ndjson',
17194
+ defaultWithoutWeb: true,
17195
+ backend: 'ppe',
17196
+ defaultLane: 'ppe_op_vc',
17197
+ requiresEnvironmentConfiguration: false,
17198
+ simulation: false,
17199
+ opensLocalQrPage: true,
17200
+ formatIndependent: true,
17201
+ disablePageOpenOption: '--no-open',
17202
+ pageUrlField: 'pageUrl',
17203
+ authorizationMode: 'qr',
17204
+ nextAction: 'scan_qr',
17205
+ userAction: 'Open pageUrl on the CLI computer and scan the authorization QR with TikTok on the phone',
17206
+ continuesPolling: true,
17207
+ materialUploadEnabled: true,
17208
+ phoneAuthorizationVerified: true,
17209
+ authorizationQrPayload: 'landing_url',
17210
+ pageMetadata: ['title', 'icon', 'description', 'category', 'subcategory'],
17211
+ },
17146
17212
  skillWorkflow: {
17147
17213
  preferredFormat: 'ndjson',
17148
17214
  packageAlternative: '--package <zipPath> replaces --dir <gameDir>',
@@ -17151,7 +17217,7 @@ function getCapabilities() {
17151
17217
  uploadOwner: 'cli',
17152
17218
  waitingAuthorization: {
17153
17219
  status: 'waiting_authorization',
17154
- userAction: 'complete authorization in the system browser on the CLI computer',
17220
+ userAction: 'Open pageUrl on the CLI computer and scan the QR with TikTok on the phone',
17155
17221
  pageUrlField: 'pageUrl',
17156
17222
  pageUrlPurpose: 'local binding and upload progress page',
17157
17223
  reopenCommandField: 'authorizationOpenCommand',
@@ -17201,7 +17267,7 @@ function getCapabilities() {
17201
17267
  previewQr: false,
17202
17268
  realAuthorizationVerified: true,
17203
17269
  realUploadVerified: true,
17204
- verificationScope: { backend: 'ppe', assets: 'test-fixtures', date: '2026-09-15', production: false },
17270
+ verificationScope: { backend: 'ppe', authorizationMode: 'qr', assets: 'test-fixtures', date: '2026-09-16', production: false },
17205
17271
  autoUploadAfterAuthorization: true,
17206
17272
  boundedPolling: true,
17207
17273
  cancellation: true,
@@ -17219,7 +17285,7 @@ function getCapabilities() {
17219
17285
  requiresRunningUpload: true,
17220
17286
  createsTask: false,
17221
17287
  outputContainsLandingCredentials: false,
17222
- target: 'system-browser',
17288
+ target: 'local-qr-page',
17223
17289
  successStatus: 'open_requested',
17224
17290
  provesAuthorization: false,
17225
17291
  },
@@ -17270,6 +17336,20 @@ function getCapabilities() {
17270
17336
  projectIntegration: false,
17271
17337
  platformWrite: false,
17272
17338
  },
17339
+ wasmCollect: {
17340
+ available: true,
17341
+ experimental: true,
17342
+ authentication: 'local_cli_login',
17343
+ credentialOutput: false,
17344
+ nativeBindingRequired: false,
17345
+ platformVerified: false,
17346
+ outputFormats: ['text', 'json'],
17347
+ start: { command: 'ttmg wasm collect start', platformWrite: true,
17348
+ resetDefault: false, resetOption: '--reset', automaticRetry: false },
17349
+ export: { command: 'ttmg wasm collect export', platformWrite: false,
17350
+ collectionSchemaVersion: '1', originalInputRequired: true,
17351
+ outputPathField: 'collection.path', overwrite: false, strategy: 'union' },
17352
+ },
17273
17353
  dev: {
17274
17354
  command: 'ttmg dev --format json --no-open',
17275
17355
  eventsCommand: 'ttmg dev --events ndjson --no-open',
@@ -17861,6 +17941,8 @@ function getDesktopPath() {
17861
17941
  const program = new commander.Command();
17862
17942
  const isPreviewPageServerWorker = process.argv[2] === '__preview-page-server';
17863
17943
  const isVibePageServerWorker = process.argv[2] === '__vibe-page-server';
17944
+ const wasmArgumentIndex = process.argv.indexOf('wasm', 2);
17945
+ const isWasmCollectCommand = wasmArgumentIndex >= 2 && process.argv[wasmArgumentIndex + 1] === 'collect';
17864
17946
  const withVerboseOption = (command) => command.option('--verbose', t('cli.option.verbose'));
17865
17947
  const structuredFormatIndex = process.argv.indexOf('--format');
17866
17948
  const inlineStructuredFormat = process.argv.find(arg => arg.startsWith('--format='));
@@ -17871,6 +17953,7 @@ const structuredFormat = inlineStructuredFormat
17871
17953
  : '';
17872
17954
  if (!isPreviewPageServerWorker &&
17873
17955
  !isVibePageServerWorker &&
17956
+ !(isWasmCollectCommand && !structuredFormat) &&
17874
17957
  structuredFormat !== 'json' &&
17875
17958
  structuredFormat !== 'ndjson' &&
17876
17959
  !process.argv.includes('--implicit')) {
@@ -17940,9 +18023,10 @@ withVerboseOption(program
17940
18023
  .option('--serve-preview-page', t('cli.command.upload.servePreviewPage'))
17941
18024
  .option('--no-open', t('cli.command.upload.noOpen'))
17942
18025
  .option('--vibe', t('cli.command.upload.vibe'))
17943
- .option('--web', t('cli.command.upload.vibeWeb'))
18026
+ .addOption(new commander.Option('--web', t('cli.command.upload.vibeWeb')).hideHelp())
17944
18027
  // Vibe v2 backend selection (including opt-in ppe-mock) stays outside the Skill workflow.
17945
18028
  .option('--title <title>', t('cli.command.upload.vibeTitle'))
18029
+ .option('--description <description>', t('cli.command.upload.vibeDescription'))
17946
18030
  .option('--icon <path>', t('cli.command.upload.vibeIcon'))
17947
18031
  .option('--category <category>', t('cli.command.upload.vibeCategory'))
17948
18032
  .option('--subcategory <subcategory>', t('cli.command.upload.vibeSubcategory'))
@@ -17964,6 +18048,7 @@ withVerboseOption(program
17964
18048
  }
17965
18049
  if ([
17966
18050
  cmd.title,
18051
+ cmd.description,
17967
18052
  cmd.icon,
17968
18053
  cmd.appInfo,
17969
18054
  cmd.category,
@@ -18310,6 +18395,25 @@ devSessionCmd
18310
18395
  });
18311
18396
  });
18312
18397
  const wasmCmd = program.command('wasm').description(t('cli.command.wasm.desc'));
18398
+ const collectCmd = wasmCmd.command('collect').description(t('cli.command.wasm.collect'));
18399
+ for (const operation of ['start', 'export']) {
18400
+ const command = collectCmd.command(operation)
18401
+ .description(t(operation === 'start' ? 'cli.command.wasm.collect.start' : 'cli.command.wasm.collect.export'))
18402
+ .allowExcessArguments(false)
18403
+ .option('--client-key <key>', t('cli.command.wasm.collect.clientKey'))
18404
+ .option('--wasm-md5 <md5>', t('cli.command.wasm.collect.md5'))
18405
+ .option('--timeout <seconds>', t('cli.command.wasm.collect.timeout'), '30')
18406
+ .option('--format <format>', t('cli.command.wasm.format'), 'json');
18407
+ if (operation === 'start')
18408
+ command.option('--reset', t('cli.command.wasm.collect.reset'));
18409
+ else
18410
+ command.option('--input <path>', t('cli.command.wasm.input'))
18411
+ .option('--output <path>', t('cli.command.wasm.collect.output'));
18412
+ command.action(async (options) => {
18413
+ const Native = await Promise.resolve().then(function () { return index; });
18414
+ await Native.wasmCollectCommand(operation, options);
18415
+ });
18416
+ }
18313
18417
  for (const operation of ['prepare', 'split']) {
18314
18418
  const command = wasmCmd.command(operation)
18315
18419
  .description(t(operation === 'prepare' ? 'cli.command.wasm.prepare' : 'cli.command.wasm.split'))
@@ -18473,7 +18577,7 @@ async function executeVibeAuthOpen(options, deps = { open: openVibeBrowser }) {
18473
18577
  const url = authorizationOpenUrl(options.pageUrl);
18474
18578
  await checkAuthorizationPage(url);
18475
18579
  try {
18476
- await deps.open(url);
18580
+ await deps.open(options.pageUrl);
18477
18581
  }
18478
18582
  catch {
18479
18583
  throw new VibeError('VIBE_BROWSER_OPEN_FAILED');
@@ -18888,8 +18992,9 @@ function createMockBackend(scenario = 'success') {
18888
18992
  }
18889
18993
 
18890
18994
  function createAuthorizationScheme(landingUrl) {
18891
- // Encode the entire landing URL once so its token/query stays inside the WebView.
18892
- return `aweme://webview?url=${encodeURIComponent(safeWebUrl(landingUrl))}`;
18995
+ // QR and browser authorization use the same backend-provided landing URL.
18996
+ // Preserve its query exactly; do not wrap it in an app scheme or re-encode it.
18997
+ return safeWebUrl(landingUrl);
18893
18998
  }
18894
18999
 
18895
19000
  const DEV_PORT = 3000;
@@ -19126,7 +19231,7 @@ async function prepareVibeInput(options, onPackaging, signal, deps = { check: ex
19126
19231
  const title = options.title?.trim();
19127
19232
  if (!title)
19128
19233
  throw new VibeError('VIBE_TITLE_REQUIRED');
19129
- const appInfo = applyVibeCategory(options, readVibeAppInfo(options.appInfo));
19234
+ const appInfo = applyVibeCategory(options, applyVibeDescription(options.description, readVibeAppInfo(options.appInfo)));
19130
19235
  if (options.package && options.dir)
19131
19236
  throw new VibeError('VIBE_INPUT_CONFLICT');
19132
19237
  const image = readFile(options.icon, VIBE_ICON_LIMIT, 'VIBE_INVALID_ICON');
@@ -19175,20 +19280,54 @@ async function prepareVibeInput(options, onPackaging, signal, deps = { check: ex
19175
19280
  }
19176
19281
 
19177
19282
  const vibePageStyles = `
19178
- [data-phase="failed"] .pending-symbol{background:#fff1ef;color:#b84c3a;font-size:26px;font-weight:600}
19179
- :root{font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;color:#202329;background:#f5f6f8;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased}
19180
- *{box-sizing:border-box}body{margin:0;min-height:100vh;min-height:100svh;display:flex;align-items:center;justify-content:center;padding:28px 16px;font-size:14px;line-height:1.5}
19181
- .shell{width:100%;max-width:424px}.brand{display:flex;align-items:center;gap:9px;margin:0 4px 18px}.brand-mark{width:25px;height:25px;display:grid;place-items:center;background:#202329;color:#fff;border-radius:7px}.brand-name{font-size:14px;font-weight:650;letter-spacing:-.3px}.brand-tool{font-size:12px;color:#8b909a;border-left:1px solid #daddE3;padding-left:9px;margin-left:1px}.mock-badge{margin-left:auto;font-size:10px;line-height:20px;padding:0 7px;border:1px solid #dfe2e7;border-radius:5px;color:#707783;letter-spacing:.03em}
19182
- .card{background:#fff;border:1px solid #e5e7eb;border-radius:20px;padding:26px 28px 24px;box-shadow:0 4px 8px #19223c03,0 16px 40px #19223c05}.steps{display:flex;align-items:center;gap:9px;margin:0 0 25px;padding:0;list-style:none}.step{display:flex;align-items:center;gap:6px;font-size:11px;color:#9298a2;white-space:nowrap}.step:after{content:"";height:1px;width:27px;margin-left:3px;background:#e9ebef}.step:last-child:after{display:none}.step-number{height:18px;min-width:18px;border-radius:50%;border:1px solid #e2e5e9;display:grid;place-items:center;font-size:10px}.step.active{color:#272c34;font-weight:600}.step.active .step-number{background:#272c34;color:white;border-color:#272c34}.step.done{color:#537565}.step.done .step-number{background:#edf6f0;color:#287c54;border-color:#edf6f0}
19183
- .heading{text-align:center}h1{font-size:24px;font-weight:650;line-height:1.35;letter-spacing:-.7px;margin:0 0 9px}.subtitle{color:#777e88;font-size:13px;line-height:1.75;margin:0 auto;max-width:330px;overflow-wrap:anywhere}.qr-area{display:flex;flex-direction:column;align-items:center;margin-top:22px}.qr-frame{background:white;border:1px solid #eceef1;border-radius:15px;padding:10px;width:214px;height:214px;box-shadow:0 3px 12px #20232904}.qr-frame img{width:192px;height:192px;display:block;max-width:100%}.qr-caption{font-size:11px;color:#8c929b;margin:10px 0 0}.status{display:flex;justify-content:center;align-items:center;gap:7px;font-size:12px;color:#6d7580;margin:15px 0 0}.status.complete{color:#2d7b58}.spinner{height:12px;width:12px;border:1.5px solid #d8dce2;border-top-color:#747f8e;border-radius:50%;animation:turn 1s linear infinite;flex-shrink:0}.success-icon{height:14px;width:14px;display:inline-flex;align-items:center;justify-content:center}
19184
- .game{display:flex;align-items:center;gap:11px;margin-top:24px;padding:12px;background:#f7f8fa;border-radius:11px}.game-icon{height:32px;width:32px;display:grid;place-items:center;background:#eceff3;border:1px solid #e4e7ed;border-radius:9px;color:#707b8b;flex-shrink:0}.game-text{min-width:0}.game-label{font-size:10px;color:#9298a2;margin:0 0 1px}.game-title{margin:0;font-size:13px;font-weight:550;white-space:nowrap;text-overflow:ellipsis;overflow:hidden}.footer{text-align:center;color:#8a919d;font-size:11px;margin:15px 0 0;line-height:1.7}.footer .dot{display:inline-block;height:5px;width:5px;border-radius:50%;background:#c1995e;vertical-align:middle;margin-right:5px}
19185
- .upload-area{height:268px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:20px}.upload-symbol{display:grid;place-items:center;height:52px;width:52px;border-radius:16px;background:#f1f4f7;color:#64748b}.percentage{font-size:42px;line-height:1;letter-spacing:-2px;font-variant-numeric:tabular-nums;font-weight:550}.percentage span{font-size:19px;letter-spacing:0;color:#858e9d;padding-left:3px}.progress-wrap{width:100%}progress{display:block;width:100%;height:5px;border:none;border-radius:20px;overflow:hidden;background:#e9edf1;accent-color:#4c6378}progress::-webkit-progress-bar{background:#edf0f4;border-radius:20px}progress::-webkit-progress-value{background:#4c6378;border-radius:20px}progress::-moz-progress-bar{background:#4c6378;border-radius:20px}.transfer-label{text-align:center;font-size:11px;color:#8b939f;margin:8px 0 0}.pending-symbol{display:grid;place-items:center;height:64px;width:64px;border-radius:50%;background:#eef6f1;color:#38805b}
19186
- .game{margin:20px 0;padding:12px 14px}.game-icon,.game-avatar{width:44px;height:44px;border-radius:10px;flex-shrink:0}.game-avatar{object-fit:cover;background:#eceff3;border:1px solid #e4e7ed}.game-label{font-size:11px;color:#717985}.game-title{font-size:15px;font-weight:600}.scan-action{font-size:15px;font-weight:600;margin:0 0 6px}.subtitle{color:#656e7b;font-size:12px}.qr-area{margin-top:18px}.footer{color:#727b89}
19187
- @keyframes turn{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.spinner{animation:none}}@media(max-width:420px){body{padding:20px 14px}.card{padding:24px 20px 21px;border-radius:17px}h1{font-size:22px}.brand{margin-bottom:14px}.steps{gap:7px;justify-content:center}.step:after{width:20px}}@media(max-height:700px){body{align-items:flex-start;padding-top:20px;padding-bottom:20px}}`;
19283
+
19284
+ :root{font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;color:#24282f;background:#fff;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased}
19285
+ *{box-sizing:border-box}
19286
+ body{margin:0;min-height:100vh;min-height:100svh;display:flex;align-items:center;justify-content:center;padding:36px 24px;font-size:14px;line-height:1.5}
19287
+ .shell{width:100%;max-width:400px}
19288
+ .steps{display:flex;align-items:center;justify-content:center;gap:10px;margin:0 0 32px;padding:0;list-style:none}
19289
+ .step{display:flex;align-items:center;gap:6px;font-size:11px;color:#9298a2;white-space:nowrap}
19290
+ .step:after{content:"";height:1px;width:25px;margin-left:4px;background:#e9ebef}.step:last-child:after{display:none}
19291
+ .step-number{height:18px;min-width:18px;border-radius:50%;border:1px solid #e2e5e9;display:grid;place-items:center;font-size:10px}
19292
+ .step.active{color:#272c34;font-weight:600}.step.active .step-number{background:#272c34;color:#fff;border-color:#272c34}
19293
+ .step.done{color:#537565}.step.done .step-number{background:#edf6f0;color:#287c54;border-color:#edf6f0}
19294
+ .heading{text-align:center}h1{font-size:26px;font-weight:650;line-height:1.35;letter-spacing:-.6px;margin:0 0 10px}
19295
+ .subtitle{color:#7a818c;font-size:12px;line-height:1.7;margin:0;overflow-wrap:anywhere}
19296
+ .game{margin:28px 0 0;padding-top:24px;border-top:1px solid #eceef1}
19297
+ .game-summary{display:flex;align-items:center;gap:12px}.game-title{margin:0;font-size:15px;font-weight:600;overflow-wrap:anywhere;min-width:0}
19298
+ .game-icon,.game-avatar{width:44px;height:44px;border-radius:10px;flex-shrink:0;background:#f0f2f5}.game-avatar{object-fit:cover}.game-icon{display:grid;place-items:center;color:#707b8b}
19299
+ .game-details{margin:20px 0 0;font-size:13px}.game-details dt{font-size:11px;color:#9298a2;margin-bottom:4px}.game-details dd{margin:0;overflow-wrap:anywhere}
19300
+ .game-description{white-space:pre-wrap;line-height:1.65}.game-categories{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-top:18px}
19301
+ .scan-action{text-align:center;font-size:13px;font-weight:500;margin:26px 0 10px}.qr-area{display:flex;justify-content:center}.qr-frame{padding:8px;width:208px;height:208px}.qr-frame img{display:block;width:192px;height:192px;max-width:100%}
19302
+ .upload-area{display:flex;align-items:center;flex-direction:column;gap:16px;padding-top:26px}.percentage{font-size:34px;line-height:1;font-variant-numeric:tabular-nums;font-weight:550}.percentage span{font-size:16px;color:#858e9d;padding-left:3px}
19303
+ .progress-wrap{width:100%}progress{display:block;width:100%;height:4px;border:none;border-radius:20px;overflow:hidden;background:#edf0f4;accent-color:#4c6378}progress::-webkit-progress-bar{background:#edf0f4;border-radius:20px}progress::-webkit-progress-value{background:#4c6378;border-radius:20px}progress::-moz-progress-bar{background:#4c6378;border-radius:20px}.transfer-label{text-align:center;font-size:11px;color:#8b939f;margin:8px 0 0}
19304
+ .footer{text-align:center;font-size:10px;line-height:1.7;color:#a0a5ae;margin:28px 0 0}.footer .dot{display:inline-block;height:5px;width:5px;border-radius:50%;background:#c1995e;vertical-align:middle;margin-right:5px}.mock-badge{width:fit-content;margin:0 0 10px auto;font-size:10px;padding:2px 7px;border:1px solid #dfe2e7;border-radius:5px;color:#707783}
19305
+ @media(max-width:380px){body{padding:28px 20px}.steps{gap:7px}.step:after{width:16px;margin-left:1px}h1{font-size:24px}}
19306
+ @media(max-height:640px){body{align-items:flex-start;padding-top:28px;padding-bottom:28px}}
19307
+ `;
19188
19308
 
19189
19309
  const VIBE_PAGE_WORKER = '__vibe-page-server';
19190
19310
  // Includes the base64 form of the locally validated image (up to 10 MiB).
19191
19311
  const VIBE_PAGE_HTML_LIMIT = 16 * 1024 * 1024;
19312
+ const pageUpdates = `(() => {
19313
+ let etag;
19314
+ const terminal = () => ['complete', 'failed'].includes(document.querySelector('main')?.dataset.phase);
19315
+ async function refresh() {
19316
+ if (terminal()) return;
19317
+ try {
19318
+ const response = await fetch(location.pathname, {cache: 'no-store', headers: etag ? {'If-None-Match': etag} : {}});
19319
+ if (response.ok) {
19320
+ etag = response.headers.get('etag');
19321
+ const next = new DOMParser().parseFromString(await response.text(), 'text/html');
19322
+ const current = document.querySelector('main'), updated = next.querySelector('main');
19323
+ if (current && updated && current.outerHTML !== updated.outerHTML) current.replaceWith(updated);
19324
+ document.title = next.title;
19325
+ }
19326
+ } catch { /* Keep the current page readable during a temporary connection failure. */ }
19327
+ if (!terminal()) setTimeout(refresh, 2000);
19328
+ }
19329
+ if (!terminal()) setTimeout(refresh, 2000);
19330
+ })();`;
19192
19331
  const escape = (s) => s.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
19193
19332
  async function renderVibePage(input) {
19194
19333
  const zh = input.language !== 'en-US';
@@ -19198,7 +19337,6 @@ async function renderVibePage(input) {
19198
19337
  ? {
19199
19338
  steps: ['扫码授权', '上传游戏', '完成'],
19200
19339
  mock: input.hybrid ? 'PPE + Mock' : '模拟',
19201
- game: '当前游戏',
19202
19340
  gameIcon: '游戏头像',
19203
19341
  bindingTitle: '上传到 TikTok',
19204
19342
  bindingAction: input.hybrid ? '真实授权二维码 · 混合联调' : '使用 TikTok 扫描二维码',
@@ -19211,7 +19349,6 @@ async function renderVibePage(input) {
19211
19349
  uploadingTitle: '正在上传小游戏',
19212
19350
  uploadingSubtitle: input.simulation ? '正在模拟授权后的上传流程,不会上传游戏内容。' : '授权已完成,正在上传本次游戏内容。',
19213
19351
  uploadLabel: input.simulation ? '模拟上传进度' : '上传进度',
19214
- uploadStatus: '正在上传游戏,请稍候',
19215
19352
  completeTitle: input.simulation ? '模拟上传完成' : '上传完成',
19216
19353
  completeSubtitle: input.simulation
19217
19354
  ? '绑定和上传流程模拟完成,未向 TikTok 上传游戏。'
@@ -19225,7 +19362,6 @@ async function renderVibePage(input) {
19225
19362
  : {
19226
19363
  steps: ['Authorize', 'Upload', 'Done'],
19227
19364
  mock: input.hybrid ? 'PPE + Mock' : 'MOCK',
19228
- game: 'YOUR GAME',
19229
19365
  gameIcon: 'Game icon',
19230
19366
  bindingTitle: 'Upload to TikTok',
19231
19367
  bindingAction: input.hybrid ? 'Real authorization QR · integration demo' : 'Scan the QR code with TikTok',
@@ -19242,7 +19378,6 @@ async function renderVibePage(input) {
19242
19378
  uploadLabel: input.simulation
19243
19379
  ? 'Simulated upload progress'
19244
19380
  : 'Upload progress',
19245
- uploadStatus: 'Uploading your game. Please wait.',
19246
19381
  completeTitle: input.simulation ? 'Simulated upload complete' : 'Upload complete',
19247
19382
  completeSubtitle: input.simulation
19248
19383
  ? 'Binding and upload simulation complete. Nothing was uploaded to TikTok.'
@@ -19268,7 +19403,7 @@ async function renderVibePage(input) {
19268
19403
  /^data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/]+=*$/.test(input.iconDataUrl)
19269
19404
  ? `<img class="game-avatar" width="44" height="44" alt="${copy.gameIcon}" src="${input.iconDataUrl}">`
19270
19405
  : '<span class="game-icon" aria-hidden="true"><svg width="22" height="22" viewBox="0 0 24 24" fill="none"><path d="M7 7h10c2 0 3 2 3 4l1 5c.4 2-1 3-2.5 2l-3-2h-7l-3 2C4 19 2.6 18 3 16l1-5c0-2 1-4 3-4Z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/><path d="M8 10v4m-2-2h4m6-1h.01M18 13h.01" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></span>';
19271
- const game = `<div class="game">${icon}<div class="game-text"><p class="game-label">${copy.game}</p><p class="game-title" title="${escape(input.title)}">${escape(input.title)}</p></div></div>`;
19406
+ const game = `<div class="game"><div class="game-summary">${icon}<p class="game-title">${escape(input.title)}</p></div><dl class="game-details"><div><dt>${zh ? '描述' : 'Description'}</dt><dd class="game-description">${escape(input.description ?? VIBE_PPE_DEFAULT_APP_INFO.description)}</dd></div><div class="game-categories"><div><dt>${zh ? '类型' : 'Category'}</dt><dd>${escape(input.category ?? VIBE_PPE_DEFAULT_APP_INFO.category)}</dd></div><div><dt>${zh ? '子类型' : 'Subcategory'}</dt><dd>${escape(input.subcategory ?? VIBE_PPE_DEFAULT_APP_INFO.subcategory)}</dd></div></div></dl></div>`;
19272
19407
  const checkIcon = '<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="m3 8 3 3 7-7" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';
19273
19408
  const title = phase === 'binding'
19274
19409
  ? copy.bindingTitle
@@ -19296,50 +19431,43 @@ async function renderVibePage(input) {
19296
19431
  Number.isFinite(input.progressPercent)
19297
19432
  ? Math.max(0, Math.min(100, Math.round(input.progressPercent)))
19298
19433
  : undefined;
19299
- const status = phase === 'binding'
19300
- ? copy.bindingStatus
19301
- : phase === 'uploading'
19302
- ? copy.uploadStatus
19303
- : phase === 'failed'
19304
- ? copy.failedTitle
19305
- : copy.accepted;
19306
- const content = phase === 'binding' && input.web
19307
- ? '<div class="upload-area"><span class="spinner" aria-hidden="true"></span></div>'
19308
- : qr
19309
- ? `<div class="qr-area"><div class="qr-frame"><img width="192" height="192" alt="${purpose}" src="${qr}"></div></div>`
19310
- : phase === 'uploading'
19311
- ? `<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>`
19312
- : `<div class="upload-area"><div class="pending-symbol">${phase === 'failed' ? '!' : checkIcon}</div></div>`;
19313
- 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>
19314
- <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>
19315
- <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>`}
19316
- <div class="heading"><h1 id="page-title">${title}</h1></div>${game}<div class="heading">${phase === 'binding' ? `<p class="scan-action">${copy.bindingAction}</p>` : ''}<p class="subtitle">${subtitle}</p></div>${content}
19317
- <p class="status${phase === 'complete' ? ' complete' : ''}" role="status">${phase === 'complete' ? `<span class="success-icon">${checkIcon}</span>` : phase === 'failed' ? '' : '<span class="spinner" aria-hidden="true"></span>'}${status}</p>
19434
+ const content = qr
19435
+ ? `<div class="qr-area"><div class="qr-frame"><img width="192" height="192" alt="${purpose}" src="${qr}"></div></div>`
19436
+ : phase === 'uploading' && progress !== undefined
19437
+ ? `<div class="upload-area"><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></div>`
19438
+ : '';
19439
+ return `<!doctype html><html lang="${zh ? 'zh-CN' : 'en'}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(title)} · ${escape(input.title)}</title><style>${vibePageStyles}</style><script src="?updates" defer></script></head>
19440
+ <body><main class="shell" data-phase="${phase}">${input.simulation ? `<p class="mock-badge">${copy.mock}</p>` : ''}
19441
+ <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 ${phase === 'complete' || i < step ? 'done' : i === step ? 'active' : ''}"${phase !== 'complete' && i === step ? ' aria-current="step"' : ''}><span class="step-number">${phase === 'complete' || i < step ? checkIcon : i + 1}</span>${label}</li>`).join('')}</ol>`}
19442
+ <div class="heading"><h1 id="page-title">${title}</h1><p class="subtitle" role="status">${subtitle}</p></div>${game}${phase === 'binding' ? `<p class="scan-action">${copy.bindingAction}</p>` : ''}${content}
19318
19443
  </section>
19319
19444
  <p class="footer">${input.simulation ? `<span class="dot" aria-hidden="true"></span>${copy.mockNote}` : copy.footer}</p></main></body></html>`;
19320
19445
  }
19321
19446
  async function createVibePageServer() {
19322
19447
  const route = `/${crypto.randomBytes(24).toString('hex')}`;
19323
19448
  let html = 'Starting…';
19449
+ let etag = '';
19324
19450
  let landingUrl;
19325
19451
  const server = http.createServer((req, res) => {
19326
19452
  const address = server.address();
19327
19453
  const host = address && typeof address !== 'string' ? `127.0.0.1:${address.port}` : '';
19454
+ if (req.method === 'GET' && req.url === `${route}?updates` && req.headers.host === host) {
19455
+ res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
19456
+ res.end(pageUpdates);
19457
+ return;
19458
+ }
19328
19459
  if (req.url === `${route}/authorize` && req.headers.host === host &&
19329
19460
  ['GET', 'HEAD'].includes(req.method || '') &&
19330
19461
  (!req.headers.origin || req.headers.origin === `http://${host}`) &&
19331
19462
  req.headers['sec-fetch-site'] !== 'cross-site') {
19332
19463
  const headers = { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' };
19333
- if (!landingUrl) {
19464
+ if (!landingUrl || req.method === 'GET') {
19334
19465
  res.writeHead(410, { ...headers, 'Content-Type': 'text/plain; charset=utf-8' });
19335
- res.end('Authorization is no longer available. Return to the upload process.');
19466
+ res.end('Web authorization is disabled. Open the local QR page and scan with TikTok.');
19336
19467
  }
19337
19468
  else if (req.method === 'HEAD') {
19338
19469
  res.writeHead(204, { ...headers, 'X-TTMG-Authorization': 'ready' }).end();
19339
19470
  }
19340
- else {
19341
- res.writeHead(302, { ...headers, Location: landingUrl }).end();
19342
- }
19343
19471
  return;
19344
19472
  }
19345
19473
  if (req.method !== 'GET' ||
@@ -19348,12 +19476,17 @@ async function createVibePageServer() {
19348
19476
  res.writeHead(404).end();
19349
19477
  return;
19350
19478
  }
19479
+ if (etag && req.headers['if-none-match'] === etag) {
19480
+ res.writeHead(304, { 'ETag': etag, 'Cache-Control': 'no-store' }).end();
19481
+ return;
19482
+ }
19351
19483
  res.writeHead(200, {
19484
+ 'ETag': etag,
19352
19485
  'Content-Type': 'text/html; charset=utf-8',
19353
19486
  'Cache-Control': 'no-store',
19354
19487
  'Referrer-Policy': 'no-referrer',
19355
19488
  'X-Content-Type-Options': 'nosniff',
19356
- 'Content-Security-Policy': "default-src 'none'; img-src data:; style-src 'unsafe-inline'; frame-ancestors 'none'",
19489
+ 'Content-Security-Policy': "default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'",
19357
19490
  });
19358
19491
  res.end(html);
19359
19492
  });
@@ -19366,6 +19499,7 @@ async function createVibePageServer() {
19366
19499
  url: `http://127.0.0.1:${address.port}${route}`,
19367
19500
  update: (value, authorization) => {
19368
19501
  html = value;
19502
+ etag = '"' + crypto.createHash('sha256').update(value).digest('hex') + '"';
19369
19503
  landingUrl = browserLandingUrl(authorization);
19370
19504
  },
19371
19505
  close: () => server.close(),
@@ -19572,6 +19706,7 @@ async function executeVibeUpload(options, onEvent = () => { }, dependencies = {}
19572
19706
  let ppeLane;
19573
19707
  let ppeSelected = false;
19574
19708
  let hybridSelected = false;
19709
+ let pageMetadata;
19575
19710
  let result = {
19576
19711
  schemaVersion: '2',
19577
19712
  command: 'upload',
@@ -19607,20 +19742,11 @@ async function executeVibeUpload(options, onEvent = () => { }, dependencies = {}
19607
19742
  throw new VibeError('VIBE_CANCELLED');
19608
19743
  };
19609
19744
  try {
19610
- // --mock is the stable trial entry: working steps use the built-in PPE lane,
19611
- // blocked steps remain simulated. No environment setup is required.
19612
- // Explicit command-line selection wins; real failures never fall back to mock.
19613
- if (options.web) {
19614
- if (options.mock || options.mockScenario || options.vibeApi || options.open === false)
19615
- throw new VibeError('VIBE_WEB_OPTION_CONFLICT');
19616
- ppeSelected = true;
19617
- ppeLane = process.env.TTMG_VIBE_PPE || 'ppe_op_vc';
19618
- if (!/^ppe_[a-zA-Z0-9_-]{1,64}$/.test(ppeLane))
19619
- throw new VibeError('VIBE_INVALID_PPE_ENV');
19620
- if (deps.backend && deps.backend.mode !== 'ppe')
19621
- throw new VibeError('VIBE_WEB_OPTION_CONFLICT');
19622
- }
19623
- else if (!options.mock && !options.vibeApi && process.env.TTMG_VIBE_BACKEND) {
19745
+ if (options.web)
19746
+ throw new VibeError('VIBE_WEB_DISABLED');
19747
+ // Real QR uploads use the release-owned PPE route.
19748
+ // Explicit test backends remain opt-in; real failures never fall back to them.
19749
+ if (!options.mock && !options.vibeApi && process.env.TTMG_VIBE_BACKEND) {
19624
19750
  const configured = process.env.TTMG_VIBE_BACKEND;
19625
19751
  if (configured === 'mock')
19626
19752
  options = { ...options, mock: true };
@@ -19638,6 +19764,12 @@ async function executeVibeUpload(options, onEvent = () => { }, dependencies = {}
19638
19764
  else
19639
19765
  throw new VibeError('VIBE_BACKEND_NOT_CONFIGURED');
19640
19766
  }
19767
+ else if (!options.mock && !options.vibeApi) {
19768
+ ppeSelected = true;
19769
+ ppeLane = process.env.TTMG_VIBE_PPE || 'ppe_op_vc';
19770
+ if (!/^ppe_[a-zA-Z0-9_-]{1,64}$/.test(ppeLane))
19771
+ throw new VibeError('VIBE_INVALID_PPE_ENV');
19772
+ }
19641
19773
  if (!['text', 'json', 'ndjson'].includes(options.format || 'text'))
19642
19774
  throw new VibeError('VIBE_INVALID_FORMAT');
19643
19775
  if (options.clientKey ||
@@ -19681,10 +19813,12 @@ async function executeVibeUpload(options, onEvent = () => { }, dependencies = {}
19681
19813
  terminal: !!options.dryRun,
19682
19814
  nextAction: options.dryRun ? 'none' : 'wait',
19683
19815
  title: input.title,
19816
+ description: (input.appInfo || VIBE_PPE_DEFAULT_APP_INFO).description,
19817
+ descriptionSource: options.description !== undefined ? 'arguments' : options.appInfo !== undefined ? 'app-info' : 'defaults',
19684
19818
  classification: {
19685
19819
  category: (input.appInfo || VIBE_PPE_DEFAULT_APP_INFO).category,
19686
19820
  subcategory: (input.appInfo || VIBE_PPE_DEFAULT_APP_INFO).subcategory,
19687
- source: options.category !== undefined ? 'arguments' : input.appInfo ? 'app-info' : 'defaults',
19821
+ source: options.category !== undefined ? 'arguments' : options.appInfo !== undefined ? 'app-info' : 'defaults',
19688
19822
  catalogVersion: GAME_CATEGORY_CATALOG_VERSION,
19689
19823
  },
19690
19824
  iconSizeBytes: input.icon.bytes.length,
@@ -19695,12 +19829,18 @@ async function executeVibeUpload(options, onEvent = () => { }, dependencies = {}
19695
19829
  });
19696
19830
  if (options.dryRun)
19697
19831
  return result;
19832
+ const submittedInfo = input.appInfo || VIBE_PPE_DEFAULT_APP_INFO;
19833
+ pageMetadata = {
19834
+ iconDataUrl: `data:${input.icon.contentType};base64,${input.icon.bytes.toString('base64')}`,
19835
+ description: submittedInfo.description,
19836
+ category: submittedInfo.category,
19837
+ subcategory: submittedInfo.subcategory,
19838
+ };
19698
19839
  const pageGame = {
19699
19840
  title: input.title,
19700
- iconDataUrl: `data:${input.icon.contentType};base64,${input.icon.bytes.toString('base64')}`,
19841
+ ...pageMetadata,
19701
19842
  uploadAvailable: backend.uploadAvailable !== false,
19702
19843
  hybrid: backend.mode === 'ppe-mock',
19703
- web: !!options.web,
19704
19844
  };
19705
19845
  const sourceProjectId = deps.projectId(options);
19706
19846
  emit('creating_task', { platformWrite: backend.mode === 'ppe' || backend.mode === 'ppe-mock' });
@@ -19712,8 +19852,6 @@ async function executeVibeUpload(options, onEvent = () => { }, dependencies = {}
19712
19852
  }, controller.signal);
19713
19853
  ensureActive();
19714
19854
  const authorizationUrl = createAuthorizationScheme(task.landingUrl);
19715
- if (options.web && !browserLandingUrl(task.landingUrl))
19716
- throw new VibeError('VIBE_INVALID_RESPONSE');
19717
19855
  const pollMs = seconds(options.pollInterval, task.pollIntervalSeconds);
19718
19856
  result = {
19719
19857
  ...result,
@@ -19722,33 +19860,30 @@ async function executeVibeUpload(options, onEvent = () => { }, dependencies = {}
19722
19860
  };
19723
19861
  page = await deps.startPage();
19724
19862
  ensureActive();
19725
- const bindingMessage = options.web
19863
+ const bindingMessage = backend.mode === 'ppe-mock'
19726
19864
  ? (getCurrentLanguage() === 'zh-CN'
19727
- ? '请在浏览器完成真实授权,当前命令会继续查询绑定状态。'
19728
- : 'Complete real authorization in the browser; this command keeps polling the binding status.')
19729
- : backend.mode === 'ppe-mock'
19730
- ? (getCurrentLanguage() === 'zh-CN'
19731
- ? '真实 PPE 任务和授权二维码;查询三次后模拟授权、上传,无需完成手机授权。'
19732
- : 'Real PPE task and authorization QR. After three polls, authorization and upload are simulated; phone authorization is not required.')
19733
- : backend.uploadAvailable === false
19734
- ? (getCurrentLanguage() === 'zh-CN' ? '请扫码完成真实 PPE 授权;上传尚未开放。' : 'Scan to authorize in PPE. Upload is not enabled yet.')
19735
- : text('waiting_authorization', result.simulation);
19865
+ ? '真实 PPE 任务和授权二维码;查询三次后模拟授权、上传,无需完成手机授权。'
19866
+ : 'Real PPE task and authorization QR. After three polls, authorization and upload are simulated; phone authorization is not required.')
19867
+ : backend.uploadAvailable === false
19868
+ ? (getCurrentLanguage() === 'zh-CN' ? '请扫码完成真实 PPE 授权;上传尚未开放。' : 'Scan to authorize in PPE. Upload is not enabled yet.')
19869
+ : text('waiting_authorization', result.simulation);
19736
19870
  await page.update({
19737
19871
  ...pageGame,
19738
19872
  language: getCurrentLanguage(),
19739
19873
  phase: 'binding',
19740
19874
  simulation: result.simulation,
19741
19875
  message: bindingMessage,
19742
- qrUrl: options.web ? undefined : authorizationUrl,
19876
+ qrUrl: authorizationUrl,
19743
19877
  browserLandingUrl: task.landingUrl,
19744
19878
  purpose: 'authorization_binding',
19745
19879
  });
19746
19880
  ensureActive();
19747
19881
  let browserOpenRequested = false;
19748
19882
  let browserOpenErrorCode;
19749
- if (options.web) {
19883
+ const opensRealPage = backend.mode === 'ppe' && options.open !== false;
19884
+ if (opensRealPage) {
19750
19885
  try {
19751
- await deps.open(authorizationOpenUrl(page.url));
19886
+ await deps.open(page.url);
19752
19887
  browserOpenRequested = true;
19753
19888
  }
19754
19889
  catch {
@@ -19758,19 +19893,19 @@ async function executeVibeUpload(options, onEvent = () => { }, dependencies = {}
19758
19893
  }
19759
19894
  emit('waiting_authorization', {
19760
19895
  message: browserOpenErrorCode
19761
- ? (getCurrentLanguage() === 'zh-CN' ? '浏览器未能打开,请使用 authorizationOpenCommand 重试;当前任务仍在等待授权。' : 'Browser opening failed. Retry authorizationOpenCommand; this task is still waiting for authorization.')
19896
+ ? (getCurrentLanguage() === 'zh-CN' ? '二维码页面未能打开,请在 CLI 所在电脑打开 pageUrl 并使用 TikTok 扫码;当前任务仍在等待授权。' : 'Could not open the QR page. Open pageUrl on the CLI computer and scan with TikTok; this task is still waiting for authorization.')
19762
19897
  : bindingMessage,
19763
19898
  pageUrl: page.url,
19764
- authorizationMode: options.web ? 'web' : 'qr',
19765
- ...(options.web ? { browserOpenRequested, browserOpenErrorCode } : {}),
19899
+ authorizationMode: 'qr',
19900
+ ...(backend.mode === 'ppe' ? { browserOpenRequested, browserOpenErrorCode } : {}),
19766
19901
  authorizationOpenCommand: browserLandingUrl(task.landingUrl)
19767
19902
  ? `ttmg auth open --page-url ${page.url} --format json` : undefined,
19768
- qrPurpose: options.web ? undefined : 'authorization_binding',
19769
- nextAction: result.simulation ? 'wait' : options.web ? 'open_browser' : 'scan_qr',
19903
+ qrPurpose: 'authorization_binding',
19904
+ nextAction: result.simulation ? 'wait' : 'scan_qr',
19770
19905
  readyForInput: false,
19771
19906
  });
19772
19907
  // Opening a browser is presentation only and never gates token polling.
19773
- if (!options.web && (options.format || 'text') === 'text' && options.open !== false)
19908
+ if (!opensRealPage && backend.mode !== 'ppe' && (options.format || 'text') === 'text' && options.open !== false)
19774
19909
  void deps.open(page.url).catch(() => { });
19775
19910
  while (true) {
19776
19911
  ensureActive();
@@ -19880,7 +20015,7 @@ async function executeVibeUpload(options, onEvent = () => { }, dependencies = {}
19880
20015
  const messages = {
19881
20016
  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.'],
19882
20017
  VIBE_INVALID_CATEGORY: ['类型或子类型无效,或两者不匹配。使用 ttmg game categories --format json 查询有效组合。', 'Invalid category/subcategory pair. Query valid values with ttmg game categories --format json.'],
19883
- 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.'],
20018
+ VIBE_INVALID_DESCRIPTION: ['描述必须是非空的有效 Unicode 文本;省略 --description 可使用默认描述。', 'Description must be non-empty valid Unicode text; omit --description to use the default.'],
19884
20019
  VIBE_AUTH_DENIED: [
19885
20020
  '已取消授权,本次未上传。',
19886
20021
  'Authorization cancelled. No upload was performed.',
@@ -19912,6 +20047,10 @@ async function executeVibeUpload(options, onEvent = () => { }, dependencies = {}
19912
20047
  '请检查本地 ZIP 代码包。',
19913
20048
  'Check the local ZIP package.',
19914
20049
  ],
20050
+ VIBE_WEB_DISABLED: [
20051
+ '网页授权已关闭。请移除 --web,使用 ttmg upload --vibe 并在手机 TikTok 中扫码授权。',
20052
+ 'Web authorization is disabled. Remove --web and use ttmg upload --vibe to scan with TikTok on your phone.',
20053
+ ],
19915
20054
  VIBE_BACKEND_NOT_CONFIGURED: [
19916
20055
  '尚未配置服务;本地模拟请使用 --mock。',
19917
20056
  'No backend configured. Use --mock for local simulation.',
@@ -19961,6 +20100,7 @@ async function executeVibeUpload(options, onEvent = () => { }, dependencies = {}
19961
20100
  if (page && code !== 'VIBE_CANCELLED') {
19962
20101
  try {
19963
20102
  await page.update({
20103
+ ...pageMetadata,
19964
20104
  title: result.title || '',
19965
20105
  language: getCurrentLanguage(),
19966
20106
  simulation: result.simulation,
@@ -31195,12 +31335,17 @@ async function snapshotWasm(input, output) {
31195
31335
  callback(bytes > 512 * 1024 * 1024 ? fail('WASM_INPUT_INVALID', 'Expanded Wasm exceeds 512 MiB.') : null, chunk);
31196
31336
  } });
31197
31337
  const source = fs$1.createReadStream(input);
31338
+ const fileMd5 = crypto$1.createHash('md5');
31339
+ const identity = new node_stream.Transform({ transform(chunk, _encoding, callback) {
31340
+ fileMd5.update(chunk);
31341
+ callback(null, chunk);
31342
+ } });
31198
31343
  const target = fs$1.createWriteStream(output, { flags: 'wx', mode: 0o600 });
31199
31344
  try {
31200
31345
  if (input.endsWith('.br'))
31201
- await promises$2.pipeline(source, zlib$1.createBrotliDecompress(), bound, target);
31346
+ await promises$2.pipeline(source, identity, zlib$1.createBrotliDecompress(), bound, target);
31202
31347
  else
31203
- await promises$2.pipeline(source, bound, target);
31348
+ await promises$2.pipeline(source, identity, bound, target);
31204
31349
  }
31205
31350
  catch (error) {
31206
31351
  throw fail(error.code === 'WASM_INPUT_INVALID' ? error.code : 'WASM_INPUT_INVALID', 'Could not read the original Wasm input.');
@@ -31217,7 +31362,11 @@ async function snapshotWasm(input, output) {
31217
31362
  throw fail('WASM_INPUT_INVALID', 'Expected a WebAssembly version 1 binary.');
31218
31363
  if (!WebAssembly.validate(await fsp.readFile(output)))
31219
31364
  throw fail('WASM_INPUT_INVALID', 'Original Wasm is invalid or unsupported by this Node.js WebAssembly engine.');
31220
- return { bytes, wasmSha256: await digest(output) };
31365
+ const rawMd5 = crypto$1.createHash('md5');
31366
+ for await (const chunk of fs$1.createReadStream(output))
31367
+ rawMd5.update(chunk);
31368
+ return { bytes, wasmSha256: await digest(output), wasmMd5: fileMd5.digest('hex'),
31369
+ uncompressedWasmMd5: rawMd5.digest('hex') };
31221
31370
  }
31222
31371
  async function executeWasm(operation, options, dependencies = {}) {
31223
31372
  const base = { schemaVersion: '1', command: `wasm.${operation}`, operationId: `wasm_${crypto$1.randomUUID()}`,
@@ -31325,6 +31474,149 @@ async function wasmCommand(operation, options) {
31325
31474
  process.exitCode = result.status === 'failed' ? 1 : 0;
31326
31475
  }
31327
31476
 
31477
+ class CollectError extends Error {
31478
+ constructor(code, message) {
31479
+ super(message);
31480
+ this.code = code;
31481
+ }
31482
+ }
31483
+ const invalid = (code, message) => { throw new CollectError(code, message); };
31484
+ async function executeWasmCollect(operation, options, overrides = {}) {
31485
+ const deps = { authenticated: () => Boolean(getCurrentUser()?.cookie), request, ...overrides };
31486
+ const base = { schemaVersion: '1', command: `wasm.collect.${operation}`,
31487
+ operationId: `wasm_collect_${crypto$1.randomUUID()}`, platformWrite: operation === 'start',
31488
+ platformWriteAttempted: false, sourceModified: false };
31489
+ let temporary;
31490
+ let staging;
31491
+ let timer;
31492
+ try {
31493
+ const timeout = Number(options.timeout ?? 30);
31494
+ if (!['start', 'export'].includes(operation) || !['json', 'text'].includes(options.format || 'json') ||
31495
+ !/^[A-Za-z0-9_-]{1,128}$/.test(options.clientKey || '') || !/^[a-fA-F0-9]{32}$/.test(options.wasmMd5 || '') ||
31496
+ !Number.isInteger(timeout) || timeout < 1 || timeout > 300 ||
31497
+ (options.reset !== undefined && typeof options.reset !== 'boolean') ||
31498
+ (operation === 'export' && (!options.input || !options.output || options.reset !== undefined)) ||
31499
+ (operation === 'start' && (options.input || options.output))) {
31500
+ 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.');
31501
+ }
31502
+ const clientKey = options.clientKey;
31503
+ const wasmMd5 = options.wasmMd5.toLowerCase();
31504
+ const output = options.output ? path$1.resolve(options.output) : undefined;
31505
+ let source;
31506
+ if (operation === 'export') {
31507
+ // lstat detects dangling links as well as existing files. Never overwrite.
31508
+ if (await fsp.lstat(output).catch((e) => { if (e.code !== 'ENOENT')
31509
+ throw e; return null; }))
31510
+ invalid('WASM_OUTPUT_EXISTS', 'Collection output already exists; choose a new file.');
31511
+ temporary = await fsp.mkdtemp(path$1.join(os$1.tmpdir(), 'ttmg-collect-'));
31512
+ try {
31513
+ source = await snapshotWasm(path$1.resolve(options.input), path$1.join(temporary, 'original.wasm'));
31514
+ }
31515
+ catch {
31516
+ return invalid('WASM_INPUT_INVALID', 'Expected a valid original Wasm or Brotli Wasm file.');
31517
+ }
31518
+ if (wasmMd5 !== source.wasmMd5 && wasmMd5 !== source.uncompressedWasmMd5)
31519
+ invalid('WASM_COLLECTION_MISMATCH', 'Collection MD5 does not match this original Wasm (file bytes or uncompressed bytes).');
31520
+ }
31521
+ if (!deps.authenticated())
31522
+ invalid('WASM_AUTH_REQUIRED', 'Run ttmg login on this machine before collecting or exporting.');
31523
+ const controller = new AbortController();
31524
+ const deadline = new Promise((_, reject) => {
31525
+ timer = setTimeout(() => {
31526
+ controller.abort();
31527
+ reject(new CollectError('WASM_COLLECT_TIMEOUT', 'Platform request timed out. A start may have reached the server; retry without --reset to preserve collection.'));
31528
+ }, timeout * 1000);
31529
+ });
31530
+ base.platformWriteAttempted = operation === 'start';
31531
+ const response = await Promise.race([deps.request({
31532
+ url: `${WASM_COLLECT_BASE_URL}/${operation === 'start' ? 'start' : 'export'}`,
31533
+ method: operation === 'start' ? 'POST' : 'GET',
31534
+ ...(operation === 'start'
31535
+ ? { data: { app_id: clientKey, wasm_md5: wasmMd5, reset: options.reset ?? false } }
31536
+ : { params: { app_id: clientKey, wasm_md5: wasmMd5, strategy: 'union' } }),
31537
+ signal: controller.signal,
31538
+ logRequestBody: false, logApiCalls: false,
31539
+ }), deadline]);
31540
+ clearTimeout(timer);
31541
+ const code = response.error?.code ?? response.error?.StatusCode ?? response.data?.code;
31542
+ const httpStatus = response.ctx?.httpStatusCode;
31543
+ // Upstream bodies/errors are deliberately never included in output: they
31544
+ // can contain credentials or gateway HTML, even with verbose enabled.
31545
+ if ([401, -401].includes(Number(code)) || httpStatus === 401 ||
31546
+ (Number(code) === -1 && /login|log.?in|登录|未登|auth/i.test(String(response.error?.message ?? response.error?.StatusMessage ?? response.data?.message ?? ''))))
31547
+ invalid('WASM_AUTH_REQUIRED', 'Platform login expired. Run ttmg login and retry.');
31548
+ if ([403, -403].includes(Number(code)) || httpStatus === 403)
31549
+ invalid('WASM_COLLECT_FORBIDDEN', 'This account cannot access collection for this game.');
31550
+ if (response.error || response.data?.code !== 0 || (httpStatus && (httpStatus < 200 || httpStatus >= 300)))
31551
+ invalid('WASM_COLLECT_API_FAILED', 'Platform collection request failed; no collection file was written.');
31552
+ if (operation === 'start') {
31553
+ if (response.data.collect_state !== 'open')
31554
+ invalid('WASM_COLLECT_INVALID_RESPONSE', 'Platform did not confirm an open collection window.');
31555
+ return { ...base, status: 'succeeded', clientKey, wasmMd5, reset: options.reset ?? false,
31556
+ collectState: 'open', nextAction: 'run_instrumented_game_and_collect', errorCode: null, error: null };
31557
+ }
31558
+ if (Array.isArray(response.data.func_ids) && response.data.func_ids.length === 0)
31559
+ invalid('WASM_COLLECTION_EMPTY', 'No function IDs collected. Run the instrumented game and export again.');
31560
+ let ids;
31561
+ try {
31562
+ ids = parseCollection({ schemaVersion: '1', wasmSha256: source.wasmSha256,
31563
+ funcIds: response.data.func_ids, bootFuncIds: response.data.boot_func_ids }, source.wasmSha256);
31564
+ }
31565
+ catch {
31566
+ return invalid('WASM_COLLECTION_INVALID', 'Platform returned invalid function IDs; no collection file was written.');
31567
+ }
31568
+ const collection = { schemaVersion: '1', wasmSha256: source.wasmSha256, ...ids,
31569
+ clientKey, wasmMd5, strategy: 'union', exportedAt: new Date().toISOString() };
31570
+ const bytes = Buffer.from(JSON.stringify(collection, null, 2) + '\n');
31571
+ if (bytes.length > 32 * 1024 * 1024)
31572
+ invalid('WASM_COLLECTION_INVALID', 'Collection JSON exceeds 32 MiB.');
31573
+ await fsp.mkdir(path$1.dirname(output), { recursive: true });
31574
+ staging = path$1.join(path$1.dirname(output), `.ttmg-collection-${crypto$1.randomUUID()}.tmp`);
31575
+ await fsp.writeFile(staging, bytes, { flag: 'wx', mode: 0o600 });
31576
+ // Publish a complete file atomically, with exclusive no-clobber semantics.
31577
+ try {
31578
+ await fsp.link(staging, output);
31579
+ }
31580
+ catch (e) {
31581
+ if (e.code === 'EEXIST')
31582
+ invalid('WASM_OUTPUT_EXISTS', 'Collection output already exists; choose a new file.');
31583
+ throw e;
31584
+ }
31585
+ return { ...base, status: 'succeeded', clientKey, wasmMd5,
31586
+ collection: { path: output, schemaVersion: '1', wasmSha256: source.wasmSha256,
31587
+ funcCount: ids.funcIds.length, bootFuncCount: ids.bootFuncIds.length,
31588
+ bytes: bytes.length, sha256: crypto$1.createHash('sha256').update(bytes).digest('hex') },
31589
+ nextAction: 'split_with_collection', errorCode: null, error: null };
31590
+ }
31591
+ catch (error) {
31592
+ const known = error instanceof CollectError;
31593
+ return { ...base, status: 'failed', errorCode: known ? error.code : 'WASM_COLLECT_FAILED',
31594
+ error: known ? error.message : 'Collection operation failed; check the original Wasm, output directory and connection.',
31595
+ nextAction: known && error.code === 'WASM_AUTH_REQUIRED' ? 'login' : 'fix_and_retry' };
31596
+ }
31597
+ finally {
31598
+ if (timer)
31599
+ clearTimeout(timer);
31600
+ if (staging)
31601
+ await fsp.unlink(staging).catch(() => { });
31602
+ if (temporary)
31603
+ await fsp.rm(temporary, { recursive: true, force: true });
31604
+ }
31605
+ }
31606
+ async function wasmCollectCommand(operation, options) {
31607
+ const result = await executeWasmCollect(operation, options);
31608
+ if ((options.format || 'json') === 'json')
31609
+ console.log(JSON.stringify(result));
31610
+ else {
31611
+ console.log(t(result.status === 'failed' ? 'wasm.collect.failed' : 'wasm.collect.succeeded'));
31612
+ if (result.error)
31613
+ console.error(result.error);
31614
+ if ('collection' in result)
31615
+ console.log(result.collection.path);
31616
+ }
31617
+ process.exitCode = result.status === 'failed' ? 1 : 0;
31618
+ }
31619
+
31328
31620
  const problem = (code, message) => Object.assign(new Error(message), { code });
31329
31621
  const MAX_BYTES = 64 * 1024 * 1024;
31330
31622
  // Export one finite Runtime interval. Sequence numbers are global, so gaps in
@@ -31528,6 +31820,7 @@ var index = /*#__PURE__*/Object.freeze({
31528
31820
  devSessionStopCommand: devSessionStopCommand,
31529
31821
  devWorkflowCommand: devWorkflowCommand,
31530
31822
  doctor: doctor,
31823
+ wasmCollectCommand: wasmCollectCommand,
31531
31824
  wasmCommand: wasmCommand
31532
31825
  });
31533
31826
  //# sourceMappingURL=index.js.map