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

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/README.md CHANGED
@@ -29,6 +29,16 @@ npm install @ttmg/cli --save-dev
29
29
 
30
30
  #### Login 登录
31
31
 
32
+ To open an existing Vibe task in the system browser, use the `pageUrl` from its
33
+ `waiting_authorization` event: `ttmg auth open --page-url <pageUrl> --format json`.
34
+ Keep the upload process running; this command creates no task and reports only
35
+ `open_requested`, not authorization success. It runs on the CLI computer.
36
+
37
+ Skill 如需手动打开网页授权,读取上传进程 `waiting_authorization` 事件的 `pageUrl`,
38
+ 执行 `ttmg auth open --page-url <pageUrl> --format json`。保持原上传进程运行;
39
+ 新命令在 CLI 所在电脑打开系统浏览器,不新建任务,`open_requested` 不代表授权成功。
40
+ 该功能需使用包含新命令的本地构建;已发布旧版不会因此自动增加命令。
41
+
32
42
  The experimental `upload --vibe` local prototype does not require platform login.
33
43
  Use `ttmg upload --vibe --mock` for the integration trial. No environment setup:
34
44
  task creation/polling use the built-in PPE lane; authorization and uploads are
@@ -369,12 +379,17 @@ specified with `--evidence-dir`. Screenshot output prints the saved file path;
369
379
  use `--output <path>` for a custom destination. Log follow ends after 30 seconds
370
380
  by default without stopping preview; the owner terminal streams logs until you
371
381
  stop preview with Ctrl+C. Both logs and screenshots require a compatible client.
382
+ Keep custom evidence outside the project or under its root `.ttmg` directory;
383
+ other in-project destinations fail before the session starts. CLI state is
384
+ excluded from game builds, package-size checks and directory uploads.
372
385
  快捷命令自动找到当前工程(含子目录)唯一的运行中真机预览会话;多个会话时列出
373
386
  候选,需用 `--session <session-id>` 明确选择,不会自动连接任意设备。
374
387
  自定义证据目录使用 `--sessions-dir <path>`,可以指定会话集合目录或启动时的
375
388
  `--evidence-dir`。截图完成后输出本地保存路径,支持 `--output <path>`。
376
389
  `logs --follow` 默认读取 30 秒后退出,不停止预览;主终端持续输出日志,Ctrl+C
377
390
  停止预览。日志和截图均需要客户端支持对应协议。
391
+ 自定义证据目录须位于工程外或工程根目录的 `.ttmg` 下;其他工程内目录会在启动前
392
+ 被拒绝。CLI 状态目录不进入游戏构建、包体积检查和目录上传。
378
393
 
379
394
  For automation, use `dev logs --format json`, `dev logs --follow --format ndjson`,
380
395
  or `dev screenshot --format json`. Successful results reuse the existing
package/dist/index.js CHANGED
@@ -38,6 +38,7 @@ 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');
@@ -7231,6 +7231,13 @@ const messages = {
7231
7231
  'cli.command.login.password': 'Developer account password (implicit mode)',
7232
7232
  'cli.command.logout.desc': 'Logout and clear local user data',
7233
7233
  '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.auth.open.pageUrl': 'Local pageUrl returned by the running Vibe upload command',
7236
+ 'cli.command.auth.open.requested': 'Browser open requested. Complete authorization there; the existing upload process continues polling.',
7237
+ 'cli.command.auth.open.invalidPage': 'Pass the local pageUrl from the running Vibe upload command, not the platform URL.',
7238
+ 'cli.command.auth.open.unavailable': 'Authorization page is unavailable, expired, or no longer waiting. Check the existing upload process; no new task was created.',
7239
+ 'cli.command.auth.open.invalidFormat': 'Use text, json, or ndjson output.',
7240
+ 'cli.command.auth.open.failed': 'Could not open the system browser. Retry this command on the CLI computer.',
7234
7241
  'cli.command.auth.status.desc': 'Return whether the local developer session is reusable',
7235
7242
  'cli.command.auth.status.format': 'Output format: text | json',
7236
7243
  'cli.command.auth.status.requireAuth': 'Exit non-zero when no reusable local session exists',
@@ -7258,7 +7265,8 @@ const messages = {
7258
7265
  'cli.command.dev.format': 'Session output format: text | json',
7259
7266
  'cli.command.dev.events': 'Lifecycle event stream: none | ndjson',
7260
7267
  'cli.command.dev.noOpen': 'Do not automatically open the IDE or scan page',
7261
- 'cli.command.dev.evidenceDir': 'Session evidence output directory',
7268
+ 'cli.command.dev.evidenceDir': 'Session evidence output directory (outside the project or under its .ttmg directory)',
7269
+ 'dev.evidence.unsafeDirectory': 'Keep session evidence outside the game project or under its root .ttmg directory so it cannot enter game packages.',
7262
7270
  'dev.logs.recordModeRequired': '--record-logs requires --mode client-preview.',
7263
7271
  'dev.logs.archiveFailed': 'Local log recording stopped [DEV_LOG_ARCHIVE_WRITE_FAILED]. Live debugging is still available.',
7264
7272
  'cli.command.dev.recordLogs': 'Save redacted Runtime logs locally (client-preview only; bounded retention)',
@@ -7635,6 +7643,13 @@ const messages = {
7635
7643
  'cli.command.login.password': '开发者账号密码(implicit 模式)',
7636
7644
  'cli.command.logout.desc': '退出登录并清除本地用户信息',
7637
7645
  'cli.command.auth.desc': '查看本地登录状态',
7646
+ 'cli.command.auth.open.desc': '在系统浏览器打开已有 Vibe 任务的授权页',
7647
+ 'cli.command.auth.open.pageUrl': '正在运行的 Vibe 上传命令返回的本地 pageUrl',
7648
+ 'cli.command.auth.open.requested': '已请求浏览器打开授权页。请在页面完成授权,原上传进程继续轮询。',
7649
+ 'cli.command.auth.open.invalidPage': '请传入正在运行的 Vibe 上传命令返回的本地 pageUrl,不是平台授权链接。',
7650
+ 'cli.command.auth.open.unavailable': '授权页不可用、已过期或已不在等待授权。请查看原上传进程状态;未新建任务。',
7651
+ 'cli.command.auth.open.invalidFormat': '输出格式请使用 text、json 或 ndjson。',
7652
+ 'cli.command.auth.open.failed': '无法打开系统浏览器,请在 CLI 所在电脑重试此命令。',
7638
7653
  'cli.command.auth.status.desc': '返回本地开发者会话是否可复用',
7639
7654
  'cli.command.auth.status.format': '输出格式:text | json',
7640
7655
  'cli.command.auth.status.requireAuth': '没有可复用本地会话时以非零状态退出',
@@ -7662,7 +7677,8 @@ const messages = {
7662
7677
  'cli.command.dev.format': '会话输出格式:text | json',
7663
7678
  'cli.command.dev.events': '生命周期事件流:none | ndjson',
7664
7679
  'cli.command.dev.noOpen': '不自动打开 IDE 或扫码网页',
7665
- 'cli.command.dev.evidenceDir': '会话证据输出目录',
7680
+ 'cli.command.dev.evidenceDir': '会话证据输出目录(工程外或工程根目录的 .ttmg 下)',
7681
+ 'dev.evidence.unsafeDirectory': '请将会话证据放在游戏工程外,或工程根目录的 .ttmg 下,避免日志、截图和构建输出进入游戏包。',
7666
7682
  'dev.logs.recordModeRequired': '--record-logs 仅支持 --mode client-preview。',
7667
7683
  'dev.logs.archiveFailed': '本地日志留存失败 [DEV_LOG_ARCHIVE_WRITE_FAILED],已停止留存;实时调试仍可继续。',
7668
7684
  'cli.command.dev.recordLogs': '将脱敏后的 Runtime 日志保存在本地(仅 client-preview,有容量上限)',
@@ -8606,6 +8622,8 @@ const zipCwdToBuffer = (customIgnores = [], targetDir = process.cwd()) => {
8606
8622
  // 推到设备,体积虚胖外,host 在 fallback 路径下还可能错误命中未插桩 wasm。
8607
8623
  // 同时保留裸名 `TTMG_TEMP_DIR` 以兜底空目录场景。
8608
8624
  const defaultIgnores = [
8625
+ '.ttmg',
8626
+ '.ttmg/**',
8609
8627
  'node_modules/**',
8610
8628
  '.git/**',
8611
8629
  '.agents/**',
@@ -16288,7 +16306,7 @@ async function authStatus(options = {}) {
16288
16306
  return result;
16289
16307
  }
16290
16308
 
16291
- var version = "0.4.6-agent-beta.1";
16309
+ var version = "0.4.6-agent-beta.2";
16292
16310
  var engines = {
16293
16311
  node: ">=20.19.1 <21 || >=22"
16294
16312
  };
@@ -16380,6 +16398,18 @@ function getCapabilities() {
16380
16398
  available: true,
16381
16399
  platformWrite: false,
16382
16400
  },
16401
+ vibeAuthOpen: {
16402
+ command: 'ttmg auth open --page-url <pageUrl> --format json',
16403
+ available: true,
16404
+ schemaVersion: '1',
16405
+ platformWrite: false,
16406
+ requiresRunningUpload: true,
16407
+ createsTask: false,
16408
+ outputContainsLandingCredentials: false,
16409
+ target: 'system-browser',
16410
+ successStatus: 'open_requested',
16411
+ provesAuthorization: false,
16412
+ },
16383
16413
  loginImplicit: {
16384
16414
  command: 'ttmg login --implicit',
16385
16415
  available: true,
@@ -16511,6 +16541,8 @@ function getCapabilities() {
16511
16541
  verifiedOnDevice: false,
16512
16542
  },
16513
16543
  sessionLogs: {
16544
+ evidenceDirectoryLocations: ['outside-project', 'project-root-.ttmg'],
16545
+ unsafeEvidenceDirectoryError: 'DEV_EVIDENCE_DIRECTORY_UNSAFE',
16514
16546
  sources: ['live', 'archive'],
16515
16547
  defaultSource: 'live',
16516
16548
  runtimeSessionOption: '--runtime-session',
@@ -17054,6 +17086,13 @@ withVerboseOption(program.command('logout').description(t('cli.command.logout.de
17054
17086
  await logout();
17055
17087
  });
17056
17088
  const authCmd = program.command('auth').description(t('cli.command.auth.desc'));
17089
+ withVerboseOption(authCmd.command('open')
17090
+ .description(t('cli.command.auth.open.desc'))
17091
+ .option('--page-url <url>', t('cli.command.auth.open.pageUrl'))
17092
+ .option('--format <format>', t('cli.command.upload.format'), 'text')).action(async (cmd) => {
17093
+ const { vibeAuthOpen } = await Promise.resolve().then(function () { return authOpen; });
17094
+ await vibeAuthOpen(cmd);
17095
+ });
17057
17096
  withVerboseOption(authCmd
17058
17097
  .command('status')
17059
17098
  .description(t('cli.command.auth.status.desc'))
@@ -17539,6 +17578,115 @@ class VibeError extends Error {
17539
17578
  }
17540
17579
  }
17541
17580
 
17581
+ // Only the backend-owned authorization page can be a browser redirect target.
17582
+ // Never return its import_token to Skill stdout or put it in a CLI argument.
17583
+ function browserLandingUrl(value) {
17584
+ if (typeof value !== 'string' || value.length > 16384 || /[\r\n]/.test(value))
17585
+ return;
17586
+ try {
17587
+ const url = new URL(value);
17588
+ if (url.origin !== 'https://developers.tiktok.com' || url.username || url.password || url.hash ||
17589
+ url.pathname !== '/portal_h5/minis_import' ||
17590
+ url.searchParams.getAll('task_id').length !== 1 ||
17591
+ url.searchParams.getAll('import_token').length !== 1 ||
17592
+ !/^\d{1,19}$/.test(url.searchParams.get('task_id') || '') ||
17593
+ !url.searchParams.get('import_token'))
17594
+ return;
17595
+ return url.href;
17596
+ }
17597
+ catch {
17598
+ return;
17599
+ }
17600
+ }
17601
+ function authorizationOpenUrl(pageUrl) {
17602
+ try {
17603
+ if (typeof pageUrl !== 'string')
17604
+ throw new Error();
17605
+ const url = new URL(pageUrl);
17606
+ if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port ||
17607
+ url.username || url.password || url.search || url.hash ||
17608
+ !/^\/[a-f0-9]{48}$/.test(url.pathname))
17609
+ throw new Error();
17610
+ return `${url.origin}${url.pathname}/authorize`;
17611
+ }
17612
+ catch {
17613
+ throw new VibeError('VIBE_INVALID_PAGE_URL');
17614
+ }
17615
+ }
17616
+ function checkAuthorizationPage(url) {
17617
+ return new Promise((resolve, reject) => {
17618
+ // HEAD never follows the redirect or requests the platform. The browser GET
17619
+ // rechecks readiness, covering state changes between the probe and opening.
17620
+ const req = http.request(url, { method: 'HEAD' }, res => {
17621
+ res.resume();
17622
+ if (res.statusCode === 204 && res.headers['x-ttmg-authorization'] === 'ready')
17623
+ resolve();
17624
+ else
17625
+ reject(new VibeError('VIBE_AUTH_PAGE_UNAVAILABLE'));
17626
+ });
17627
+ const timer = setTimeout(() => req.destroy(new Error('timeout')), 5000);
17628
+ req.once('close', () => clearTimeout(timer));
17629
+ req.once('error', () => reject(new VibeError('VIBE_AUTH_PAGE_UNAVAILABLE')));
17630
+ req.end();
17631
+ });
17632
+ }
17633
+ async function executeVibeAuthOpen(options, deps = { open: async (url) => {
17634
+ const child = await (await import('open')).default(url);
17635
+ return new Promise((resolve, reject) => {
17636
+ child.once('error', reject);
17637
+ if (child.pid)
17638
+ resolve(child);
17639
+ else
17640
+ child.once('spawn', () => resolve(child));
17641
+ });
17642
+ } }) {
17643
+ const result = {
17644
+ schemaVersion: '1', command: 'auth open', operationId: `auth_open_${crypto.randomUUID()}`,
17645
+ status: 'failed', platformWrite: false, browserOpenRequested: false,
17646
+ errorCode: null, error: null,
17647
+ };
17648
+ try {
17649
+ if (!['text', 'json', 'ndjson'].includes(options.format || 'text'))
17650
+ throw new VibeError('VIBE_INVALID_FORMAT');
17651
+ const url = authorizationOpenUrl(options.pageUrl);
17652
+ await checkAuthorizationPage(url);
17653
+ try {
17654
+ await deps.open(url);
17655
+ }
17656
+ catch {
17657
+ throw new VibeError('VIBE_BROWSER_OPEN_FAILED');
17658
+ }
17659
+ result.status = 'open_requested';
17660
+ result.browserOpenRequested = true;
17661
+ }
17662
+ catch (error) {
17663
+ result.errorCode = error instanceof VibeError ? error.code : 'VIBE_BROWSER_OPEN_FAILED';
17664
+ const key = result.errorCode === 'VIBE_INVALID_PAGE_URL' ? 'invalidPage'
17665
+ : result.errorCode === 'VIBE_AUTH_PAGE_UNAVAILABLE' ? 'unavailable'
17666
+ : result.errorCode === 'VIBE_INVALID_FORMAT' ? 'invalidFormat' : 'failed';
17667
+ result.error = t(`cli.command.auth.open.${key}`);
17668
+ }
17669
+ return result;
17670
+ }
17671
+ async function vibeAuthOpen(options) {
17672
+ const result = await executeVibeAuthOpen(options);
17673
+ if (options.format === 'json' || options.format === 'ndjson')
17674
+ console.log(JSON.stringify(result));
17675
+ else
17676
+ console.log(result.error || t('cli.command.auth.open.requested'));
17677
+ if (result.errorCode)
17678
+ process.exitCode = 1;
17679
+ return result;
17680
+ }
17681
+
17682
+ var authOpen = /*#__PURE__*/Object.freeze({
17683
+ __proto__: null,
17684
+ authorizationOpenUrl: authorizationOpenUrl,
17685
+ browserLandingUrl: browserLandingUrl,
17686
+ executeVibeAuthOpen: executeVibeAuthOpen,
17687
+ vibeAuthOpen: vibeAuthOpen
17688
+ });
17689
+
17542
17690
  // BFF IDL: feat-minis-import. PPE task creation/polling only; no production adapter.
17543
17691
  const prefix = '/bff/app_info/minis_import/';
17544
17692
  const MINIS_IMPORT_PATHS = {
@@ -18427,9 +18575,27 @@ async function renderVibePage(input) {
18427
18575
  async function createVibePageServer() {
18428
18576
  const route = `/${crypto.randomBytes(24).toString('hex')}`;
18429
18577
  let html = 'Starting…';
18578
+ let landingUrl;
18430
18579
  const server = http.createServer((req, res) => {
18431
18580
  const address = server.address();
18432
18581
  const host = address && typeof address !== 'string' ? `127.0.0.1:${address.port}` : '';
18582
+ if (req.url === `${route}/authorize` && req.headers.host === host &&
18583
+ ['GET', 'HEAD'].includes(req.method || '') &&
18584
+ (!req.headers.origin || req.headers.origin === `http://${host}`) &&
18585
+ req.headers['sec-fetch-site'] !== 'cross-site') {
18586
+ const headers = { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' };
18587
+ if (!landingUrl) {
18588
+ res.writeHead(410, { ...headers, 'Content-Type': 'text/plain; charset=utf-8' });
18589
+ res.end('Authorization is no longer available. Return to the upload process.');
18590
+ }
18591
+ else if (req.method === 'HEAD') {
18592
+ res.writeHead(204, { ...headers, 'X-TTMG-Authorization': 'ready' }).end();
18593
+ }
18594
+ else {
18595
+ res.writeHead(302, { ...headers, Location: landingUrl }).end();
18596
+ }
18597
+ return;
18598
+ }
18433
18599
  if (req.method !== 'GET' ||
18434
18600
  req.url !== route ||
18435
18601
  req.headers.host !== host) {
@@ -18452,8 +18618,9 @@ async function createVibePageServer() {
18452
18618
  const address = server.address();
18453
18619
  return {
18454
18620
  url: `http://127.0.0.1:${address.port}${route}`,
18455
- update: (value) => {
18621
+ update: (value, authorization) => {
18456
18622
  html = value;
18623
+ landingUrl = browserLandingUrl(authorization);
18457
18624
  },
18458
18625
  close: () => server.close(),
18459
18626
  };
@@ -18475,7 +18642,7 @@ async function runVibePageWorker() {
18475
18642
  if (message?.type === 'render' &&
18476
18643
  typeof message.html === 'string' &&
18477
18644
  message.html.length < VIBE_PAGE_HTML_LIMIT) {
18478
- server.update(message.html);
18645
+ server.update(message.html, message.browserLandingUrl);
18479
18646
  process.send?.({ type: 'rendered', id: message.id });
18480
18647
  }
18481
18648
  if (message?.type === 'close') {
@@ -18523,7 +18690,8 @@ async function startVibePage() {
18523
18690
  const html = await renderVibePage(input);
18524
18691
  const current = ++id;
18525
18692
  const ack = receive(m => m?.type === 'rendered' && m.id === current);
18526
- child.send({ type: 'render', id: current, html }, error => {
18693
+ const landingUrl = input.phase === 'binding' ? browserLandingUrl(input.browserLandingUrl) : undefined;
18694
+ child.send({ type: 'render', id: current, html, browserLandingUrl: landingUrl }, error => {
18527
18695
  if (error)
18528
18696
  child.emit('error', error);
18529
18697
  });
@@ -18802,12 +18970,15 @@ async function executeVibeUpload(options, onEvent = () => { }, dependencies = {}
18802
18970
  simulation: result.simulation,
18803
18971
  message: bindingMessage,
18804
18972
  qrUrl: authorizationUrl,
18973
+ browserLandingUrl: task.landingUrl,
18805
18974
  purpose: 'authorization_binding',
18806
18975
  });
18807
18976
  ensureActive();
18808
18977
  emit('waiting_authorization', {
18809
18978
  message: bindingMessage,
18810
18979
  pageUrl: page.url,
18980
+ authorizationOpenCommand: browserLandingUrl(task.landingUrl)
18981
+ ? `ttmg auth open --page-url ${page.url} --format json` : undefined,
18811
18982
  qrPurpose: 'authorization_binding',
18812
18983
  nextAction: result.simulation ? 'wait' : 'scan_qr',
18813
18984
  readyForInput: false,
@@ -19913,7 +20084,12 @@ async function watch(options = {}) {
19913
20084
  /**
19914
20085
  * 忽略 wasmcode/cache 目录
19915
20086
  */
19916
- ignored: /(^|[\/\\])(\.git|\.ttmg|node_modules|__TTMG_TEMP__)/,
20087
+ ignored: file => {
20088
+ if (/(^|[\/\\])(\.git|node_modules|__TTMG_TEMP__)/.test(file))
20089
+ return true;
20090
+ const relative = path$1.relative(process.cwd(), file);
20091
+ return relative === '.ttmg' || relative.startsWith(`.ttmg${path$1.sep}`);
20092
+ },
19917
20093
  ignoreInitial: true, // 忽略初始添加事件
19918
20094
  persistent: true,
19919
20095
  awaitWriteFinish: {
@@ -27737,7 +27913,7 @@ function requireOperationId(value) {
27737
27913
  async function snapshotProject(source, signal) {
27738
27914
  const directory = await fsp.mkdtemp(path$1.join(os$1.tmpdir(), 'ttmg-iteration-'));
27739
27915
  const hash = crypto$1.createHash('sha256');
27740
- const ignored = new Set(['.git', '.ttmg', 'node_modules', '__TTMG_TEMP__']);
27916
+ const ignored = new Set(['.git', 'node_modules', '__TTMG_TEMP__']);
27741
27917
  const dispose = () => fs$1.rmSync(directory, { recursive: true, force: true });
27742
27918
  try {
27743
27919
  async function copy(relative) {
@@ -27746,7 +27922,7 @@ async function snapshotProject(source, signal) {
27746
27922
  });
27747
27923
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
27748
27924
  signal?.throwIfAborted();
27749
- if (ignored.has(entry.name))
27925
+ if (ignored.has(entry.name) || (!relative && entry.name === '.ttmg'))
27750
27926
  continue;
27751
27927
  const name = path$1.join(relative, entry.name);
27752
27928
  if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile()))
@@ -28047,6 +28223,24 @@ class PreviewWorkflow {
28047
28223
  }
28048
28224
  }
28049
28225
 
28226
+ function physicalPath(directory) {
28227
+ const absolute = path$1.resolve(directory);
28228
+ if (fs$1.existsSync(absolute))
28229
+ return fs$1.realpathSync(absolute);
28230
+ const parent = path$1.dirname(absolute);
28231
+ if (parent === absolute)
28232
+ return absolute;
28233
+ return path$1.join(physicalPath(parent), path$1.basename(absolute));
28234
+ }
28235
+ // Packaging reserves only the project-root .ttmg directory. Never silently put
28236
+ // session output into an ordinary game asset directory (including via symlinks).
28237
+ function isSafeEvidenceDirectory(project, directory) {
28238
+ const relative = path$1.relative(physicalPath(project), physicalPath(directory));
28239
+ return path$1.isAbsolute(relative) || relative === '..' ||
28240
+ relative.startsWith(`..${path$1.sep}`) || relative === '.ttmg' ||
28241
+ relative.startsWith(`.ttmg${path$1.sep}`);
28242
+ }
28243
+
28050
28244
  function normalizeMode(mode) {
28051
28245
  const value = (mode || 'default').toLowerCase();
28052
28246
  if (value !== 'default' && value !== 'client-preview') {
@@ -28116,6 +28310,17 @@ async function dev(options = {}) {
28116
28310
  }
28117
28311
  const humanPreview = mode === 'client-preview' && !structured;
28118
28312
  const managed = structured || humanPreview;
28313
+ if (managed && !isSafeEvidenceDirectory(process.cwd(), options.evidenceDir || path$1.join('.ttmg', 'sessions'))) {
28314
+ const failed = { schemaVersion: '1', command: 'dev', status: 'failed',
28315
+ sessionId: null, platformWrite: false, errorCode: 'DEV_EVIDENCE_DIRECTORY_UNSAFE',
28316
+ error: t('dev.evidence.unsafeDirectory') };
28317
+ if (structured)
28318
+ console.log(JSON.stringify(failed));
28319
+ else
28320
+ console.error(failed.error);
28321
+ process.exitCode = 1;
28322
+ return failed;
28323
+ }
28119
28324
  const humanOutput = humanPreview ? new HumanPreviewOutput() : null;
28120
28325
  const scanPage = mode === 'client-preview' ? new ClientPreviewScanPage() : null;
28121
28326
  const timeoutMs = normalizeTimeout(options.timeout);
@@ -29993,6 +30198,8 @@ async function startBackgroundPreview(options) {
29993
30198
  const clientKey = options.clientKey?.trim();
29994
30199
  if (!clientKey)
29995
30200
  throw workflowError('DEV_ITERATION_INVALID_OPTIONS', 'Pass --client-key when starting a background preview.');
30201
+ if (!isSafeEvidenceDirectory(process.cwd(), path$1.dirname(lock)))
30202
+ throw workflowError('DEV_EVIDENCE_DIRECTORY_UNSAFE', 'The project .ttmg directory must not resolve to a game asset directory.');
29996
30203
  fs$1.mkdirSync(path$1.dirname(lock), { recursive: true });
29997
30204
  try {
29998
30205
  fs$1.mkdirSync(lock, { mode: 0o700 });