From 611ec8e5439128cd0a06f943ab2de27dfe6ba4da Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Fri, 25 Sep 2026 20:37:10 +0800 Subject: [PATCH] fix(linux): preserve existing data while adding XDG and ARM64 coverage Adapt the Linux compatibility proposal from ASHURA1800/minimax-code f3f0538825360eb1eca8a88d059ea6e12c11abf9. Keep existing data directories in place, validate XDG paths, initialize the Linux CLI consistently, and register regression and release coverage. Refs #362 Co-authored-by: ASHURA1800 <228700200+ASHURA1800@users.noreply.github.com> --- .github/workflows/ci.yml | 6 +- .github/workflows/compatibility.yml | 6 +- CONTRIBUTING.md | 4 +- README.md | 4 +- README_ZH.md | 4 +- docs/installation.md | 9 +- packages/config/src/data-dir.ts | 36 ++- packages/config/test/data-dir.test.ts | 125 ++++++++++ .../src/infra/ensure-path-integration.ts | 2 +- .../test/unit/ensure-path-integration.test.ts | 235 ++++++++++++++++++ packages/tui/src/runtime/data-dir.ts | 14 +- .../tui/test/unit/runtime-data-dir.test.ts | 59 ++++- .../unit/tui-terminal-presentation.test.ts | 21 ++ release/public-source.json | 2 + scripts/lib/cli-release.mjs | 3 +- test/smoke.test.mjs | 32 +++ test/source-sync.test.mjs | 5 +- test/vitest-suites.json | 2 + 18 files changed, 539 insertions(+), 30 deletions(-) create mode 100644 packages/config/test/data-dir.test.ts create mode 100644 packages/local-runtime/test/unit/ensure-path-integration.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ac79b8f7..d94c60a22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,12 +56,16 @@ jobs: fail-fast: false matrix: # Windows runs a focused contract; full capability coverage remains on Linux/macOS. - os: [ubuntu-latest, macos-latest, windows-latest] + # ubuntu-24.04-arm validates ARM64 Linux. + os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest] node: ["24"] include: - os: ubuntu-latest node: "24" profile: full + - os: ubuntu-24.04-arm + node: "24" + profile: full - os: windows-latest node: "24" profile: windows diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index 1b716b138..87c9557b9 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -17,12 +17,16 @@ jobs: fail-fast: false matrix: # Windows validation is temporarily paused until its checks are reliable. - os: [ubuntu-latest, macos-latest] + # ubuntu-24.04-arm validates ARM64 Linux. + os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest] node: ["22.19.0", "24.2.0", "25", "26"] include: - os: ubuntu-latest node: "22.19.0" profile: full + - os: ubuntu-24.04-arm + node: "22.19.0" + profile: full steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e2cec3a0..80e1b32e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,7 +39,7 @@ pnpm install --frozen-lockfile pnpm verify ``` -`pnpm verify` runs the complete gate list in the same order as GitHub CI. Normal PR and main-branch checks use Node.js 24 on Linux and macOS. The Linux job runs the full profile; the macOS job uses `pnpm verify --profile platform`, which omits only the duplicate TypeScript compiler check. Windows runs the focused `pnpm verify --profile windows` contract on PRs; the profile is Windows-only and fails closed elsewhere. It checks source inventory, release tooling, build boundaries, artifacts, and Windows-specific tests without running the full capability suite. Gates that depend on platform behaviour are selected by platform rather than skipped silently; run `pnpm verify --list`, `pnpm verify --profile platform --list`, or `pnpm verify --profile windows --list` to inspect each plan. Individual gates remain available as their own scripts, such as `pnpm typecheck` or `pnpm test:byok`, while you iterate. +`pnpm verify` runs the complete gate list in the same order as GitHub CI. Normal PR and main-branch checks use Node.js 24 on Linux x64, Linux ARM64 and macOS. Both Linux jobs run the full profile; the macOS job uses `pnpm verify --profile platform`, which omits only the duplicate TypeScript compiler check. Windows runs the focused `pnpm verify --profile windows` contract on PRs; the profile is Windows-only and fails closed elsewhere. It checks source inventory, release tooling, build boundaries, artifacts, and Windows-specific tests without running the full capability suite. Gates that depend on platform behaviour are selected by platform rather than skipped silently; run `pnpm verify --list`, `pnpm verify --profile platform --list`, or `pnpm verify --profile windows --list` to inspect each plan. Individual gates remain available as their own scripts, such as `pnpm typecheck` or `pnpm test:byok`, while you iterate. `pnpm lint` (or `pnpm lint:tui`) checks TUI source and tests with the source repository's Airbnb, TypeScript, import and Prettier rules. Errors fail verification; formatting, unused variables and explicit `any` remain warnings, matching the source policy. The scripts use `--quiet` to keep existing warning debt out of CI logs; omit it when invoking ESLint directly to inspect warnings. The audited Pi engine retains its narrow style exceptions; its public facade uses the normal rules. Vendored Pi test copies are excluded. Full, platform, archive and Windows verification include this gate; docs and package-installation profiles do not. @@ -47,7 +47,7 @@ CI writes per-gate timing and exit metadata to the Job Summary and a seven-day ` Existing README files, `CONTRIBUTING.md`, `.github/PULL_REQUEST_TEMPLATE.md`, Markdown under `docs/`, and media directly under `docs/assets/` use the `docs` profile when they are the only changed paths. That profile checks the source inventory and generated paths, exports the committed source, and tests release tooling. History and source-snapshot secret scans still run; platform builds and distribution scans are skipped. Mixed changes, unknown paths, missing comparisons, and any `release/` inventory change get full CI. Documentation-only changes skip the platform matrix entirely. The `verification` aggregate check always runs and rejects failed, cancelled, or unexpectedly skipped jobs. Use it together with `source-history-artifact` as required checks when configuring branch protection; this repository's automation does not change administrative settings. -`Node compatibility` runs weekly and on demand against macOS and Linux with Node 22.19.0, 24.2.0, 25 and 26. Windows remains excluded from this matrix; the PR Windows contract is a focused Node 24 check, not a substitute for the full compatibility matrix. It does not run automatically on PRs. Dispatch it on the selected branch for changes to supported Node versions, native dependencies or compatibility-sensitive verification tooling, and before a source release. This covers the minimum versions of the two supported ranges and the additional supported majors. Deferring those versions from ordinary PR checks can delay regression discovery; a known failure in a supported version still needs resolution before release. Dependabot proposes weekly Actions and npm updates, grouping Actions and development-tool minor/patch updates. External Actions use reviewed full commit SHAs, while local actions and reusable workflows come from the same checked-out revision. +`Node compatibility` runs weekly and on demand against macOS and Linux (x64 and ARM64) with Node 22.19.0, 24.2.0, 25 and 26. Windows remains excluded from this matrix; the PR Windows contract is a focused Node 24 check, not a substitute for the full compatibility matrix. It does not run automatically on PRs. Dispatch it on the selected branch for changes to supported Node versions, native dependencies or compatibility-sensitive verification tooling, and before a source release. This covers the minimum versions of the two supported ranges and the additional supported majors. Deferring those versions from ordinary PR checks can delay regression discovery; a known failure in a supported version still needs resolution before release. Dependabot proposes weekly Actions and npm updates, grouping Actions and development-tool minor/patch updates. External Actions use reviewed full commit SHAs, while local actions and reusable workflows come from the same checked-out revision. Source candidates are requested independently through the `Source candidate` workflow; ordinary PRs and main pushes do not produce them. Its Linux/macOS archive validation is described in [Releasing](docs/releasing.md). `CLI release` builds npm-installable tar.gz packages from version tags and validates the same archive before attaching it to a GitHub Release. Its `package` verification profile tests installation of an existing archive; it does not replace full source verification. npm registry and official installer publication remain separate release processes. diff --git a/README.md b/README.md index 195b9acde..de876161e 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ mcode login --region global Complete sign-in in your browser, then open `mcode` and use `/status` to check your account and `/provider` to choose a model. Run `mcode logout` to sign out. -Token Plan requires an account with available credits. Builds from this repository and the published npm CLI `@minimax-ai/code@0.4.12` default to `~/.minimax` for user data (or `~/.minimax-` when a profile is selected). `MINIMAX_DATA_DIR` or `MAVIS_DATA_DIR` can override the data directory. The installer's `~/.minimax-code` installation directory is separate from this choice. See [Accounts and data](docs/installation.md#accounts-and-data) before locating or removing configuration and sessions. +Token Plan requires an account with available credits. The published npm CLI `@minimax-ai/code@0.4.12` uses `~/.minimax` for user data. Source builds keep existing Linux installations there; new Linux installations use `$XDG_DATA_HOME/minimax` (default `~/.local/share/minimax`). macOS and Windows source builds continue to use `~/.minimax`. A selected profile appends `-` to the default directory name. `MINIMAX_DATA_DIR` or `MAVIS_DATA_DIR` can override the data directory. The installer's `~/.minimax-code` installation directory is separate from this choice. See [Accounts and data](docs/installation.md#accounts-and-data) before locating or removing configuration and sessions.
Use your own API key (BYOK) @@ -197,7 +197,7 @@ rm -rf -- "$HOME/.minimax" Remove-Item -LiteralPath "$env:USERPROFILE\.minimax" -Recurse -Force ``` -A profile uses `~/.minimax-`; `MINIMAX_DATA_DIR` or `MAVIS_DATA_DIR` can select a different location. Remove only the specific directories you intend to discard, without wildcard deletion. Remove any MCode-specific environment variable assignments you added to shell profiles or user environment settings if you no longer need them. +A profile appends `-` to its default directory name. New Linux source installations use `$XDG_DATA_HOME/minimax` (default `~/.local/share/minimax`); `MINIMAX_DATA_DIR` or `MAVIS_DATA_DIR` can select a different location. Remove only the specific directories you intend to discard, without wildcard deletion. Remove any MCode-specific environment variable assignments you added to shell profiles or user environment settings if you no longer need them. ## What you can do diff --git a/README_ZH.md b/README_ZH.md index ab35a0808..53fe152bd 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -80,7 +80,7 @@ mcode login --region global 在浏览器中完成登录,再启动 `mcode`,通过 `/status` 检查账号、通过 `/provider` 选择模型。退出登录使用 `mcode logout`。 -Token Plan 需要账号与可用额度。从本仓库构建的版本与已发布的 npm CLI `@minimax-ai/code@0.4.12` 均默认将用户数据保存在 `~/.minimax`(选择 profile 时为 `~/.minimax-`)。`MINIMAX_DATA_DIR` 或 `MAVIS_DATA_DIR` 可以覆盖数据目录。安装脚本使用的 `~/.minimax-code` 安装目录与数据目录的选择是两回事。查找或删除配置和会话前,请参阅[账号与数据](docs/installation.md#accounts-and-data)。 +Token Plan 需要账号与可用额度。已发布的 npm CLI `@minimax-ai/code@0.4.12` 将用户数据保存在 `~/.minimax`。从本仓库构建的版本会保留 Linux 现有安装的数据目录;Linux 全新安装使用 `$XDG_DATA_HOME/minimax`(默认为 `~/.local/share/minimax`),macOS 和 Windows 仍使用 `~/.minimax`。选择 profile 时,在默认目录名后追加 `-`。`MINIMAX_DATA_DIR` 或 `MAVIS_DATA_DIR` 可以覆盖数据目录。安装脚本使用的 `~/.minimax-code` 安装目录与数据目录的选择是两回事。查找或删除配置和会话前,请参阅[账号与数据](docs/installation.md#accounts-and-data)。
使用自己的 API Key(BYOK) @@ -197,7 +197,7 @@ rm -rf -- "$HOME/.minimax" Remove-Item -LiteralPath "$env:USERPROFILE\.minimax" -Recurse -Force ``` -profile 使用 `~/.minimax-`;`MINIMAX_DATA_DIR` 或 `MAVIS_DATA_DIR` 可以指定其他位置。只删除确定不再需要的具体目录,不要使用通配符批量删除。如果不再需要自行添加的 MCode 环境变量,也请从 shell 配置或用户环境变量设置中移除对应赋值。 +profile 会在默认目录名后追加 `-`。Linux 全新源码安装使用 `$XDG_DATA_HOME/minimax`(默认为 `~/.local/share/minimax`);`MINIMAX_DATA_DIR` 或 `MAVIS_DATA_DIR` 可以指定其他位置。只删除确定不再需要的具体目录,不要使用通配符批量删除。如果不再需要自行添加的 MCode 环境变量,也请从 shell 配置或用户环境变量设置中移除对应赋值。 ## 可以做什么 diff --git a/docs/installation.md b/docs/installation.md index 0b8c52676..e366141f1 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -78,9 +78,12 @@ Source builds and the published npm CLI use the same default user data directory | CLI artifact | Default user data directory | | --- | --- | | Published npm `@minimax-ai/code@0.4.12` | `~/.minimax` | -| Build from this repository | `~/.minimax` | +| Build from this repository on Linux (new installation) | `$XDG_DATA_HOME/minimax`, or `~/.local/share/minimax` | +| Existing Linux installation, or macOS / Windows source build | `~/.minimax` | -The npm 0.4.12 default was checked against the [public registry artifact](https://registry.npmjs.org/@minimax-ai/code/0.4.12) on 2026-09-19, with its SHA-512 integrity verified. This applies to that published version, including local npm dependencies; do not infer the default of another release from its version label alone. A selected profile uses `~/.minimax-`. The source default is defined in [`data-dir.ts`](../packages/tui/src/runtime/data-dir.ts). Login state, provider configuration such as `config.yaml`, caches, and sessions belong to the selected data directory. +The npm 0.4.12 default was checked against the [public registry artifact](https://registry.npmjs.org/@minimax-ai/code/0.4.12) on 2026-09-19, with its SHA-512 integrity verified. This applies to that published version, including local npm dependencies; do not infer the default of another release from its version label alone. A selected profile appends `-` to the selected default directory name. The source default is defined in [`data-dir.ts`](../packages/tui/src/runtime/data-dir.ts). Login state, provider configuration such as `config.yaml`, caches, and sessions belong to the selected data directory. + +Following the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir/latest/), Linux source builds accept only an absolute `XDG_DATA_HOME`; unset, empty, or relative values fall back to `~/.local/share`. Existing `~/.minimax` directories and symlinks remain authoritative, even when XDG also contains data. Older `~/.mavis` installations retain the existing migration to `~/.minimax`; the CLI performs that migration before exporting its runtime directory. Existing installations are not automatically relocated to XDG, so active databases and old shell PATH entries keep their location. New XDG installations retain a `.mavis` compatibility link. Both accept a non-empty `MINIMAX_DATA_DIR`, falling back to a non-empty `MAVIS_DATA_DIR`, before the default. The [macOS / Linux / WSL installer](https://filecdn.minimax.chat/public/install.sh) installs the npm package under `~/.minimax-code` by default (`MCODE_INSTALL_DIR` changes the installation location). It does not set either data-directory override. Installation files and user data are separate concerns, even when their directories have the same name. @@ -132,7 +135,7 @@ Alternatively, merge this entry into `/tui/keybindings.json`, preservi } ``` -For the default profile this is `~/.minimax/tui/keybindings.json`; [profiles and data-directory overrides](#accounts-and-data) change the path. Create the `tui` directory if needed. After editing the file, run `/reload` while idle with no pending interaction or queued message, or restart MCode. This replaces `Alt+M`; use `["alt+m", "ctrl+x"]` as the value to retain both bindings. `/hotkeys` shows the effective binding. +For the default profile this is `/tui/keybindings.json` (for example, `~/.minimax/tui/keybindings.json` or `~/.local/share/minimax/tui/keybindings.json`); [profiles and data-directory overrides](#accounts-and-data) change the path. Create the `tui` directory if needed. After editing the file, run `/reload` while idle with no pending interaction or queued message, or restart MCode. This replaces `Alt+M`; use `["alt+m", "ctrl+x"]` as the value to retain both bindings. `/hotkeys` shows the effective binding. ### Verify the result diff --git a/packages/config/src/data-dir.ts b/packages/config/src/data-dir.ts index 6945a3548..43ee620d9 100644 --- a/packages/config/src/data-dir.ts +++ b/packages/config/src/data-dir.ts @@ -37,8 +37,28 @@ function basenameForProfile(base: string, profile?: string | null): string { return profile ? `${base}-${profile}` : base; } +/** Default for new Linux installations; existing installations keep their location. */ export function getPrimaryDataDirPath(homeDir?: string, profile?: string | null): string { - return path.join(resolveHomeDir(homeDir), basenameForProfile(NEW_DATA_DIR_BASENAME, profile)); + const resolvedHomeDir = resolveHomeDir(homeDir); + const previousPrimary = path.join( + resolvedHomeDir, + basenameForProfile(NEW_DATA_DIR_BASENAME, profile), + ); + if (process.platform !== 'linux') return previousPrimary; + + const xdgDataHome = process.env.XDG_DATA_HOME; + const dataHome = xdgDataHome && path.isAbsolute(xdgDataHome) + ? xdgDataHome + : path.join(resolvedHomeDir, '.local', 'share'); + const primary = path.join(dataHome, basenameForProfile('minimax', profile)); + // Do not relocate a live database, hide existing data, or mistake an inaccessible + // directory / dangling symlink for a fresh install. Older .mavis installations + // retain the existing migration to .minimax. A compat link from an earlier XDG + // startup must not cause the next startup to switch back to .minimax. + if (pathState(previousPrimary).kind !== 'missing') return previousPrimary; + const legacy = getLegacyDataDirPath(resolvedHomeDir, profile); + if (pathState(legacy).kind !== 'missing' && !isLinkTo(legacy, primary)) return previousPrimary; + return primary; } export function getLegacyDataDirPath(homeDir?: string, profile?: string | null): string { @@ -298,6 +318,13 @@ function resolveDataDirPair( return newDir; } + if (newState.kind === 'link' || newState.kind === 'other') { + logger.warn( + `Primary data dir path ${newDir} exists but is not a directory; using it without migration`, + ); + return newDir; + } + if (newState.kind === 'directory') { const newContent = contentState(newDir); const legacyContent = @@ -369,13 +396,6 @@ function resolveDataDirPair( return legacyDir; } - if (newState.kind === 'link' || newState.kind === 'other') { - logger.warn( - `Primary data dir path ${newDir} exists but is not a directory; using it without migration`, - ); - return newDir; - } - ensurePrimaryDir(newDir, logger); ensureCompatLink(legacyDir, newDir, logger, nowMs); return newDir; diff --git a/packages/config/test/data-dir.test.ts b/packages/config/test/data-dir.test.ts new file mode 100644 index 000000000..a26615576 --- /dev/null +++ b/packages/config/test/data-dir.test.ts @@ -0,0 +1,125 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getLegacyDataDirPath, getPrimaryDataDirPath, resolveDataDir } from "../src/data-dir.js"; + +const originalPlatform = process.platform; +let root: string; +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { value: platform, configurable: true }); +} +function marker(dir: string, text = "synthetic-data"): void { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "config.yaml"), text); +} +function readMarker(dir: string): string { + return fs.readFileSync(path.join(dir, "config.yaml"), "utf8"); +} +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "minimax-data-dir-")); + setPlatform("linux"); + for (const key of ["XDG_DATA_HOME", "MINIMAX_DATA_DIR", "MAVIS_DATA_DIR"]) vi.stubEnv(key, ""); +}); +afterEach(() => { + setPlatform(originalPlatform); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + fs.rmSync(root, { recursive: true, force: true }); +}); + +describe("Linux XDG directory selection", () => { + it.each(["", " ", "relative/data", "~/data"])("falls back for invalid XDG_DATA_HOME %j", (value) => { + vi.stubEnv("XDG_DATA_HOME", value); + expect(getPrimaryDataDirPath(root)).toBe(path.join(root, ".local", "share", "minimax")); + }); + it("uses an absolute XDG path and preserves the profile suffix", () => { + vi.stubEnv("XDG_DATA_HOME", path.join(root, "custom data")); + expect(getPrimaryDataDirPath(root, "work")).toBe(path.join(root, "custom data", "minimax-work")); + expect(getLegacyDataDirPath(root, "work")).toBe(path.join(root, ".mavis-work")); + }); + it("initializes a fresh XDG directory and remains stable on repeated resolution", () => { + const expected = path.join(root, ".local", "share", "minimax"); + expect(resolveDataDir({ homeDir: root })).toBe(expected); + marker(expected); + expect(resolveDataDir({ homeDir: root })).toBe(expected); + expect(readMarker(expected)).toBe("synthetic-data"); + expect(fs.realpathSync(path.join(root, ".mavis"))).toBe(fs.realpathSync(expected)); + }); + it.each([null, "work"])("preserves existing .minimax data in place for profile %s", (profile) => { + const previous = path.join(root, profile ? `.minimax-${profile}` : ".minimax"); + marker(previous); + expect(getPrimaryDataDirPath(root, profile)).toBe(previous); + expect(resolveDataDir({ homeDir: root, profile })).toBe(previous); + expect(readMarker(previous)).toBe("synthetic-data"); + expect(fs.lstatSync(previous).isDirectory()).toBe(true); + expect(fs.existsSync(path.join(root, ".local"))).toBe(false); + }); + it("preserves .minimax when .mavis already points to it", () => { + const previous = path.join(root, ".minimax"); + marker(previous); + fs.symlinkSync(previous, path.join(root, ".mavis"), "dir"); + expect(resolveDataDir({ homeDir: root })).toBe(previous); + expect(readMarker(previous)).toBe("synthetic-data"); + expect(readMarker(path.join(root, ".mavis"))).toBe("synthetic-data"); + }); + it("does not relocate an existing .minimax symlink target", () => { + const external = path.join(root, "external"); + marker(external); + fs.symlinkSync(external, path.join(root, ".minimax"), "dir"); + fs.symlinkSync(external, path.join(root, ".mavis"), "dir"); + expect(resolveDataDir({ homeDir: root })).toBe(path.join(root, ".minimax")); + expect(readMarker(external)).toBe("synthetic-data"); + expect(readMarker(path.join(root, ".minimax"))).toBe("synthetic-data"); + }); + it("retains the existing .mavis to .minimax migration for older installations", () => { + marker(path.join(root, ".mavis")); + const expected = path.join(root, ".minimax"); + expect(resolveDataDir({ homeDir: root })).toBe(expected); + expect(readMarker(expected)).toBe("synthetic-data"); + expect(fs.realpathSync(path.join(root, ".mavis"))).toBe(fs.realpathSync(expected)); + }); + it("prefers the existing installation when XDG also contains independent data", () => { + const previous = path.join(root, ".minimax"); + const xdg = path.join(root, ".local", "share", "minimax"); + marker(previous, "previous"); + marker(xdg, "xdg"); + expect(resolveDataDir({ homeDir: root })).toBe(previous); + expect(readMarker(previous)).toBe("previous"); + expect(readMarker(xdg)).toBe("xdg"); + }); + it("does not silently switch away from an inaccessible previous directory", () => { + const previous = path.join(root, ".minimax"); + const lstatSync = fs.lstatSync; + vi.spyOn(fs, "lstatSync").mockImplementation((...args) => { + if (args[0] === previous) throw Object.assign(new Error("synthetic access denied"), { code: "EACCES" }); + return lstatSync(...args); + }); + expect(resolveDataDir({ homeDir: root })).toBe(previous); + expect(fs.existsSync(path.join(root, ".local"))).toBe(false); + }); + it("preserves a dangling previous symlink instead of selecting a new data store", () => { + const previous = path.join(root, ".minimax"); + fs.symlinkSync(path.join(root, "unmounted"), previous, "dir"); + expect(resolveDataDir({ homeDir: root })).toBe(previous); + expect(fs.readlinkSync(previous)).toBe(path.join(root, "unmounted")); + expect(fs.existsSync(path.join(root, ".local"))).toBe(false); + }); + it.each(["MINIMAX_DATA_DIR", "MAVIS_DATA_DIR"])("keeps explicit %s authoritative", async (key) => { + const explicit = path.join(root, "explicit"); + vi.stubEnv(key, explicit); + vi.stubEnv("XDG_DATA_HOME", path.join(root, "xdg")); + const { getDataDir } = await import("../src/config.js"); + expect(getDataDir()).toBe(explicit); + }); +}); + +describe("other platforms", () => { + it.each(["darwin", "win32"] as const)("preserves primary and legacy paths on %s", (platform) => { + setPlatform(platform); + vi.stubEnv("XDG_DATA_HOME", path.join(root, "xdg")); + expect(getPrimaryDataDirPath(root)).toBe(path.join(root, ".minimax")); + expect(getPrimaryDataDirPath(root, "work")).toBe(path.join(root, ".minimax-work")); + expect(getLegacyDataDirPath(root)).toBe(path.join(root, ".mavis")); + }); +}); diff --git a/packages/local-runtime/src/infra/ensure-path-integration.ts b/packages/local-runtime/src/infra/ensure-path-integration.ts index 958d6b7f4..524b45cf8 100644 --- a/packages/local-runtime/src/infra/ensure-path-integration.ts +++ b/packages/local-runtime/src/infra/ensure-path-integration.ts @@ -36,7 +36,7 @@ function ensurePosixShellPath(binDir: string): void { const rcFiles = process.platform === 'darwin' ? [join(home, '.zshrc'), join(home, '.bashrc')] - : [join(home, '.bashrc')]; + : [join(home, '.bashrc'), join(home, '.zshrc')]; const exportLine = `export PATH="${binDir}:$PATH"`; for (const rc of rcFiles) { diff --git a/packages/local-runtime/test/unit/ensure-path-integration.test.ts b/packages/local-runtime/test/unit/ensure-path-integration.test.ts new file mode 100644 index 000000000..bc7604c04 --- /dev/null +++ b/packages/local-runtime/test/unit/ensure-path-integration.test.ts @@ -0,0 +1,235 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const originalPlatform = process.platform; +afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); +}); + +const fsMocks = vi.hoisted(() => { + const existsSync = vi.fn(); + const readFileSync = vi.fn(); + const appendFileSync = vi.fn(); + return { existsSync, readFileSync, appendFileSync }; +}); + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { + ...actual, + existsSync: fsMocks.existsSync, + readFileSync: fsMocks.readFileSync, + appendFileSync: fsMocks.appendFileSync, + }; +}); + +const osMocks = vi.hoisted(() => { + const homedir = vi.fn(); + return { homedir }; +}); + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os'); + return { + ...actual, + homedir: osMocks.homedir, + }; +}); + +const childProcessMocks = vi.hoisted(() => { + const execFileSync = vi.fn(); + return { execFileSync }; +}); + +vi.mock('node:child_process', async () => { + const actual = await vi.importActual( + 'node:child_process', + ); + return { + ...actual, + execFileSync: childProcessMocks.execFileSync, + }; +}); + +beforeEach(() => { + fsMocks.existsSync.mockReset(); + fsMocks.readFileSync.mockReset(); + fsMocks.appendFileSync.mockReset(); + osMocks.homedir.mockReset(); + childProcessMocks.execFileSync.mockReset(); +}); + +async function importModule() { + // Dynamic import so mocks above are in place before the module evaluates. + const mod = await import('../../src/infra/ensure-path-integration.js'); + return mod.ensurePathIntegration; +} + +describe('ensurePathIntegration — Linux', () => { + beforeEach(() => { + vi.stubEnv('HOME', '/home/testuser'); + Object.defineProperty(process, 'platform', { value: 'linux' }); + osMocks.homedir.mockReturnValue('/home/testuser'); + fsMocks.existsSync.mockReturnValue(false); + fsMocks.readFileSync.mockReturnValue(''); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('writes PATH export to both .bashrc and .zshrc on Linux', async () => { + const ensurePathIntegration = await importModule(); + ensurePathIntegration('/opt/minimax/data'); + + const writtenFiles = fsMocks.appendFileSync.mock.calls.map( + (call) => call[0] as string, + ); + + expect(writtenFiles).toContain('/home/testuser/.bashrc'); + expect(writtenFiles).toContain('/home/testuser/.zshrc'); + }); + + it('uses the correct export line with the data bin directory', async () => { + const ensurePathIntegration = await importModule(); + ensurePathIntegration('/opt/minimax/data'); + + const appendCalls = fsMocks.appendFileSync.mock.calls; + for (const call of appendCalls) { + const content = call[1] as string; + expect(content).toContain('export PATH="/opt/minimax/data/bin:$PATH"'); + expect(content).toContain('# Added by MiniMax Code'); + } + }); + + it('is idempotent — does not append if PATH_MARKER already present in .bashrc', async () => { + fsMocks.existsSync.mockImplementation( + (p: string) => p === '/home/testuser/.bashrc', + ); + fsMocks.readFileSync.mockImplementation((p: string) => { + if (p === '/home/testuser/.bashrc') { + return '# existing content\n# Added by MiniMax Code\nexport PATH="/old:$PATH"\n'; + } + return ''; + }); + + const ensurePathIntegration = await importModule(); + ensurePathIntegration('/opt/minimax/data'); + + const writtenFiles = fsMocks.appendFileSync.mock.calls.map( + (call) => call[0] as string, + ); + + // .bashrc should be skipped because it already has the marker. + expect(writtenFiles).not.toContain('/home/testuser/.bashrc'); + // .zshrc should still be written (it does not have the marker). + expect(writtenFiles).toContain('/home/testuser/.zshrc'); + }); + + it('is idempotent — does not append if PATH_MARKER already present in .zshrc', async () => { + fsMocks.existsSync.mockImplementation( + (p: string) => p === '/home/testuser/.zshrc', + ); + fsMocks.readFileSync.mockImplementation((p: string) => { + if (p === '/home/testuser/.zshrc') { + return '# Added by MiniMax Code\nexport PATH="/old:$PATH"\n'; + } + return ''; + }); + + const ensurePathIntegration = await importModule(); + ensurePathIntegration('/opt/minimax/data'); + + const writtenFiles = fsMocks.appendFileSync.mock.calls.map( + (call) => call[0] as string, + ); + + // .zshrc should be skipped. + expect(writtenFiles).not.toContain('/home/testuser/.zshrc'); + // .bashrc should still be written. + expect(writtenFiles).toContain('/home/testuser/.bashrc'); + }); + + it('is fully idempotent — skips both files when both already contain the marker', async () => { + fsMocks.existsSync.mockReturnValue(true); + fsMocks.readFileSync.mockReturnValue('# Added by MiniMax Code\nexport PATH="/old:$PATH"\n'); + + const ensurePathIntegration = await importModule(); + ensurePathIntegration('/opt/minimax/data'); + + expect(fsMocks.appendFileSync).not.toHaveBeenCalled(); + }); + + it('does not throw when HOME is inaccessible', async () => { + osMocks.homedir.mockReturnValue('/home/testuser'); + fsMocks.existsSync.mockReturnValue(false); + fsMocks.appendFileSync.mockImplementation(() => { + throw new Error('EACCES'); + }); + + const ensurePathIntegration = await importModule(); + expect(() => ensurePathIntegration('/opt/minimax/data')).not.toThrow(); + }); +}); + +describe('ensurePathIntegration — macOS', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + osMocks.homedir.mockReturnValue('/Users/testuser'); + fsMocks.existsSync.mockReturnValue(false); + fsMocks.readFileSync.mockReturnValue(''); + }); + + it('writes PATH export to both .zshrc and .bashrc on macOS', async () => { + const ensurePathIntegration = await importModule(); + ensurePathIntegration('/opt/minimax/data'); + + const writtenFiles = fsMocks.appendFileSync.mock.calls.map( + (call) => call[0] as string, + ); + + expect(writtenFiles).toContain('/Users/testuser/.zshrc'); + expect(writtenFiles).toContain('/Users/testuser/.bashrc'); + expect(writtenFiles).toHaveLength(2); + }); + + it('macOS behaviour is unchanged — .zshrc comes before .bashrc', async () => { + const ensurePathIntegration = await importModule(); + ensurePathIntegration('/opt/minimax/data'); + + const writtenFiles = fsMocks.appendFileSync.mock.calls.map( + (call) => call[0] as string, + ); + + expect(writtenFiles[0]).toBe('/Users/testuser/.zshrc'); + expect(writtenFiles[1]).toBe('/Users/testuser/.bashrc'); + }); +}); + +describe('ensurePathIntegration — Windows', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + osMocks.homedir.mockReturnValue('C:\\Users\\testuser'); + }); + + it('uses registry, not shell rc files', async () => { + childProcessMocks.execFileSync.mockImplementation( + (cmd: string, args: readonly string[]) => { + if (cmd === 'reg' && args?.[0] === 'query') { + return 'HKEY_CURRENT_USER\\Environment\r\n Path REG_SZ C:\\old\r\n'; + } + return ''; + }, + ); + + const ensurePathIntegration = await importModule(); + ensurePathIntegration('C:\\minimax\\data'); + + expect(fsMocks.appendFileSync).not.toHaveBeenCalled(); + // The first call should be the registry query. + expect(childProcessMocks.execFileSync).toHaveBeenCalledWith( + 'reg', + ['query', 'HKCU\\Environment'], + expect.any(Object), + ); + }); +}); diff --git a/packages/tui/src/runtime/data-dir.ts b/packages/tui/src/runtime/data-dir.ts index 2b07d4eb5..006e6655a 100644 --- a/packages/tui/src/runtime/data-dir.ts +++ b/packages/tui/src/runtime/data-dir.ts @@ -1,4 +1,4 @@ -import { getPrimaryDataDirPath, getProfile } from '@mavis/config'; +import { getPrimaryDataDirPath, getProfile, resolveDataDir } from '@mavis/config'; import { resolveMcodeDataEnvironment, type McodeDataEnvironment } from '../auth/environment.js'; import { configureTuiRuntimeEnvironment } from '../cli/environment.js'; @@ -16,12 +16,20 @@ export interface PrepareTuiDataDirOptions { configureRuntimeEnvironment?: typeof configureTuiRuntimeEnvironment; } +// Linux must finish legacy directory resolution before the entry point exports +// MINIMAX_DATA_DIR; otherwise every later runtime treats that path as an override. +const resolveCliDefaultDataDir: typeof resolveDataDir = (options) => ( + process.platform === 'linux' + ? resolveDataDir(options) + : getPrimaryDataDirPath(options?.homeDir, options?.profile) +); + export function resolveDefaultTuiDataDir( _buildEnv: McodeDataEnvironment, - getPrimaryDataDir: typeof getPrimaryDataDirPath = getPrimaryDataDirPath, + resolveDefaultDataDir: typeof resolveDataDir = resolveCliDefaultDataDir, getCurrentProfile: typeof getProfile = getProfile, ): string { - return getPrimaryDataDir(undefined, getCurrentProfile()); + return resolveDefaultDataDir({ profile: getCurrentProfile() }); } function getDefaultTuiDataDir(): string { diff --git a/packages/tui/test/unit/runtime-data-dir.test.ts b/packages/tui/test/unit/runtime-data-dir.test.ts index c2dda75f4..1117f0359 100644 --- a/packages/tui/test/unit/runtime-data-dir.test.ts +++ b/packages/tui/test/unit/runtime-data-dir.test.ts @@ -1,28 +1,79 @@ -import { homedir } from 'node:os'; +import fs from 'node:fs'; +import os from 'node:os'; import { join } from 'node:path'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getTuiDataDirPath, prepareTuiDataDir, resolveDefaultTuiDataDir, } from '../../src/runtime/data-dir.js'; +const originalPlatform = process.platform; +let home: string; +beforeEach(() => { + home = fs.mkdtempSync(join(os.tmpdir(), 'tui-data-dir-')); + vi.spyOn(os, 'homedir').mockReturnValue(home); + vi.stubEnv('XDG_DATA_HOME', ''); +}); +afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + fs.rmSync(home, { recursive: true, force: true }); +}); +function defaultPath(profile?: string): string { + return process.platform === 'linux' + ? join(home, '.local', 'share', profile ? `minimax-${profile}` : 'minimax') + : join(home, profile ? `.minimax-${profile}` : '.minimax'); +} + describe('TUI data directory', () => { it.each(['dev', 'test', 'staging', 'prod'] as const)( 'uses the shared user directory for %s builds', (buildEnv) => { expect(resolveDefaultTuiDataDir(buildEnv, undefined, () => null)).toBe( - join(homedir(), '.minimax'), + defaultPath(), ); }, ); it('keeps the shared profile suffix', () => { expect(resolveDefaultTuiDataDir('prod', undefined, () => 'smoke')).toBe( - join(homedir(), '.minimax-smoke'), + defaultPath('smoke'), ); }); + it('passes existing Linux data through startup without switching to XDG', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + const existing = join(home, '.minimax'); + fs.mkdirSync(existing); + fs.writeFileSync(join(existing, 'config.yaml'), 'synthetic: true'); + const configureRuntimeEnvironment = vi.fn(); + await expect(prepareTuiDataDir({ + environment: {}, + getDefaultDataDir: () => resolveDefaultTuiDataDir('prod', undefined, () => null), + configureRuntimeEnvironment, + })).resolves.toBe(existing); + expect(configureRuntimeEnvironment).toHaveBeenCalledWith({ dataDir: existing }); + expect(fs.readFileSync(join(existing, 'config.yaml'), 'utf8')).toBe('synthetic: true'); + }); + + it('runs legacy migration before exporting the data directory override', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + const legacy = join(home, '.mavis'); + fs.mkdirSync(legacy); + fs.writeFileSync(join(legacy, 'config.yaml'), 'synthetic: true'); + const configureRuntimeEnvironment = vi.fn(({ dataDir }) => { + expect(fs.readFileSync(join(dataDir, 'config.yaml'), 'utf8')).toBe('synthetic: true'); + }); + await expect(prepareTuiDataDir({ + environment: {}, + getDefaultDataDir: () => resolveDefaultTuiDataDir('prod', undefined, () => null), + configureRuntimeEnvironment, + })).resolves.toBe(join(home, '.minimax')); + expect(configureRuntimeEnvironment).toHaveBeenCalledTimes(1); + }); + it.each([ [{}, '/default'], [{ MINIMAX_DATA_DIR: ' ', MAVIS_DATA_DIR: ' ' }, '/default'], diff --git a/packages/tui/test/unit/tui-terminal-presentation.test.ts b/packages/tui/test/unit/tui-terminal-presentation.test.ts index 1d04d2fbb..beb6dc668 100644 --- a/packages/tui/test/unit/tui-terminal-presentation.test.ts +++ b/packages/tui/test/unit/tui-terminal-presentation.test.ts @@ -186,6 +186,27 @@ describe('terminal notification policy and transport', () => { } }); + it('honors an explicit method override on an unidentified Linux terminal instead of falling back to BEL', () => { + for (const [method, prefix] of [ + ['osc9', '\u001b]9;'] as const, + ['osc777', '\u001b]777;'] as const, + ]) { + const executeFile = vi.fn(); + const terminal = { write: vi.fn() }; + const notifications = new TuiTerminalNotifications(terminal, { + capabilities: capabilities(), + settings: { method }, + executeFile, + }); + notifications.setActive(true); + expect(notifications.notifyOnce('turn-complete', 'done')).toBe(true); + expect(executeFile).not.toHaveBeenCalled(); + expect(terminal.write).toHaveBeenCalledTimes(1); + const written = terminal.write.mock.calls[0]?.[0] as string; + expect(written.startsWith(prefix)).toBe(true); + } + }); + it('quotes native notification text and ignores native failures from a suspended generation', () => { const env = { WT_SESSION: 'test' }; const terminal = { write: vi.fn() }; diff --git a/release/public-source.json b/release/public-source.json index 392407803..cffbd53e0 100644 --- a/release/public-source.json +++ b/release/public-source.json @@ -476,6 +476,7 @@ "packages/config/src/tui-status-line-write.ts", "packages/config/test/config-file-permissions.test.ts", "packages/config/test/config-update-preservation.test.ts", + "packages/config/test/data-dir.test.ts", "packages/config/test/managed-preset-sync.test.ts", "packages/config/test/private-config-file.test.ts", "packages/local-runtime-v2/assets/agents/_default/prompt-base-all.md", @@ -2653,6 +2654,7 @@ "packages/local-runtime/test/unit/content-safety-api-fail-policy.test.ts", "packages/local-runtime/test/unit/content-safety-api-v2.test.ts", "packages/local-runtime/test/unit/content-safety-turn-v2.test.ts", + "packages/local-runtime/test/unit/ensure-path-integration.test.ts", "packages/local-runtime/test/unit/error-reporting-privacy.test.ts", "packages/local-runtime/test/unit/local-background-task-task-output-polling-reminder.test.ts", "packages/local-runtime/test/unit/local-memory-edit-literal.test.ts", diff --git a/scripts/lib/cli-release.mjs b/scripts/lib/cli-release.mjs index 3b1eaae0e..1e310df40 100644 --- a/scripts/lib/cli-release.mjs +++ b/scripts/lib/cli-release.mjs @@ -27,5 +27,6 @@ export const cliExternalModules = [ '@larksuiteoapi/node-sdk', ]; -export const cliReleaseTargets = ['ubuntu-latest', 'macos-latest'].flatMap(os => +// ubuntu-24.04-arm validates ARM64 Linux before release publication. +export const cliReleaseTargets = ['ubuntu-latest', 'ubuntu-24.04-arm', 'macos-latest'].flatMap(os => ['22.19.0', '24.2.0', '25', '26'].map(node => ({ os, node }))); diff --git a/test/smoke.test.mjs b/test/smoke.test.mjs index 72082a742..9db656050 100644 --- a/test/smoke.test.mjs +++ b/test/smoke.test.mjs @@ -83,6 +83,7 @@ test("CLI defaults to the shared user config without migrating the old source di Object.assign(options.env, { HOME: home, USERPROFILE: home, + XDG_DATA_HOME: path.join(home, "unused-xdg"), MCODE_DISABLE_TELEMETRY: "1", }); const result = spawnSync(process.execPath, [cli, "telemetry", "status"], { @@ -96,6 +97,37 @@ test("CLI defaults to the shared user config without migrating the old source di assert.equal(status.configured, true); assert.equal(readFileSync(oldConfig, "utf8"), oldContents); }); +for (const xdgMode of ['absolute', 'relative', 'unset']) { + test(`CLI reads Linux XDG configuration with ${xdgMode} XDG_DATA_HOME`, { + skip: process.platform !== 'linux', + }, (t) => { + const options = fixture(t); + const home = options.cwd; + const xdgHome = path.join(home, 'custom data'); + const selected = xdgMode === 'absolute' ? xdgHome : path.join(home, '.local', 'share'); + const config = path.join(selected, 'minimax', 'config.yaml'); + mkdirSync(path.dirname(config), { recursive: true }); + writeFileSync(config, 'telemetry:\n enabled: true\n', { mode: 0o600 }); + for (const name of Object.keys(options.env)) { + if (name.startsWith('__MAVIS_RUNTIME')) delete options.env[name]; + } + delete options.env.MINIMAX_DATA_DIR; + delete options.env.MAVIS_DATA_DIR; + delete options.env.XDG_DATA_HOME; + if (xdgMode !== 'unset') options.env.XDG_DATA_HOME = xdgMode === 'absolute' ? xdgHome : 'relative-data'; + Object.assign(options.env, { HOME: home, USERPROFILE: home, MCODE_DISABLE_TELEMETRY: '1' }); + for (let startup = 0; startup < 2; startup += 1) { + const result = spawnSync(process.execPath, [cli, 'telemetry', 'status'], { + ...options, encoding: 'utf8', timeout: runtimeTimeoutMs, + }); + assertSuccessfulChild(result); + const status = JSON.parse(result.stdout); + assert.equal(status.configFile, config); + assert.equal(status.configured, true); + } + assert.equal(existsSync(path.join(home, '.minimax')), false); + }); +} test("provider configuration loads from an isolated data directory", (t) => { const result = spawnSync(process.execPath, [cli, "provider", "list"], { ...fixture(t), diff --git a/test/source-sync.test.mjs b/test/source-sync.test.mjs index 178069648..5acdfbfe0 100644 --- a/test/source-sync.test.mjs +++ b/test/source-sync.test.mjs @@ -749,10 +749,11 @@ test('ordinary CI runs a focused Windows contract while compatibility remains ma assert.ok(Object.hasOwn(ci.on, 'pull_request')); assert.deepEqual(ci.on.push.branches, ['main']); assert.deepEqual(Object.keys(ci.jobs).sort(), ['changes', 'docs', 'verification', 'verify']); - assert.deepEqual(ci.jobs.verify.strategy.matrix.os, ['ubuntu-latest', 'macos-latest', 'windows-latest']); + assert.deepEqual(ci.jobs.verify.strategy.matrix.os, ['ubuntu-latest', 'ubuntu-24.04-arm', 'macos-latest', 'windows-latest']); assert.deepEqual(ci.jobs.verify.strategy.matrix.node, ['24']); assert.deepEqual(ci.jobs.verify.strategy.matrix.include, [ { os: 'ubuntu-latest', node: '24', profile: 'full' }, + { os: 'ubuntu-24.04-arm', node: '24', profile: 'full' }, { os: 'windows-latest', node: '24', profile: 'windows' }, ]); assert.equal(ci.jobs.verify.needs, 'changes'); @@ -764,7 +765,7 @@ test('ordinary CI runs a focused Windows contract while compatibility remains ma const compatibility = readWorkflow('compatibility'); assert.deepEqual(Object.keys(compatibility.on).sort(), ['schedule', 'workflow_dispatch']); assert.deepEqual(compatibility.jobs.compatibility.strategy.matrix.node, ['22.19.0', '24.2.0', '25', '26']); - assert.deepEqual(compatibility.jobs.compatibility.strategy.matrix.os, ['ubuntu-latest', 'macos-latest']); + assert.deepEqual(compatibility.jobs.compatibility.strategy.matrix.os, ['ubuntu-latest', 'ubuntu-24.04-arm', 'macos-latest']); const audit = readWorkflow('security'); assert.ok(Object.hasOwn(audit.on, 'pull_request')); assert.ok(audit.jobs['source-history-artifact'].steps.some(step => step.run?.includes('gitleaks dir dist'))); diff --git a/test/vitest-suites.json b/test/vitest-suites.json index db55c40e3..eaf1bc65e 100644 --- a/test/vitest-suites.json +++ b/test/vitest-suites.json @@ -25,6 +25,7 @@ "packages/tui/test/unit/tui-plugin-manager.test.ts", "packages/tui/test/unit/daily-checkin-application.test.ts", "packages/tui/test/unit/daily-checkin-http-gateway.test.ts", + "packages/local-runtime/test/unit/ensure-path-integration.test.ts", "packages/local-runtime/test/unit/error-reporting-privacy.test.ts", "packages/local-runtime/test/unit/metrics-telemetry-privacy.test.ts", "packages/tui/test/unit/runtime-feedback-service.test.ts", @@ -73,6 +74,7 @@ "packages/mcode-tools-host/test/unit/resource-manifest.test.ts", "packages/tui/test/unit/headless-config.test.ts", "packages/tui/test/unit/runtime-data-dir.test.ts", + "packages/config/test/data-dir.test.ts", "packages/tui/test/unit/headless-contract.test.ts", "packages/tui/test/unit/headless-invocation.test.ts", "packages/tui/test/unit/wsl-attachment-paths.test.ts",