cxs is a local-first CLI for searching Codex session logs. It is designed for progressive retrieval: find the right session first, then read
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(env): 默认数据目录改 XDG state,自动从 ~/.cache/cxs 迁移

之前用 ~/.cache/cxs/index.sqlite 不准确 — 索引是「可重建但重建有
成本」的状态(2867 session 全 sync 75s),不是 throwaway cache。XDG
state 是这种数据的标准位置:
- $XDG_STATE_HOME/cxs (尊重)
- ~/.local/state/cxs (fallback)
CXS_DATA_DIR 仍优先级最高。

迁移逻辑 (migrateLegacyCacheDir):
- legacy 不存在 → no-op
- dest 已有数据 → no-op (不 clobber,旧 cache 留原地)
- legacy === dest → no-op
- 其余 → renameSync(rename atomic);失败 swallow,重 sync 即可恢复

trigger 点 = cli.ts 入口,而非 env.ts 模块顶层 — 避免 vitest import
env.ts 时把用户 home 数据搬走 (test isolation)。

实测当前机器:240 MB 索引从 ~/.cache/cxs/ rename 到
~/.local/state/cxs/,lastSyncAt 与 sessionCount 保留,零丢失。

测试: env.test.ts 4 个 case 覆盖 mv / clean install / dest 已有 /
self === self 四种迁移分支。

Windows 注:这个改动跟前一个 platform reduction commit 一起,正式
把 cxs 限定到 macOS+Linux,Windows 用户走 WSL。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Entire-Checkpoint: 8f6309354104

cat 95c667f9 3d86cb9d

+139 -10
+3 -1
README.md
··· 76 76 77 77 ### 数据目录 78 78 79 - 索引默认写到 `~/.cache/cxs/index.sqlite`(XDG cache 约定),可用 `CXS_DATA_DIR` 环境变量覆盖: 79 + 索引默认写到 `~/.local/state/cxs/index.sqlite`(XDG state 约定;`$XDG_STATE_HOME` 也尊重)。`CXS_DATA_DIR` 环境变量优先级最高: 80 80 81 81 ```bash 82 82 export CXS_DATA_DIR="$HOME/.config/cxs" 83 83 ``` 84 + 85 + **自动迁移**:之前装过 cxs 0.2.0 及以下、索引在 `~/.cache/cxs/` 的用户,首次跑新版 `cxs sync` 会自动 `rename` 整个目录到 `~/.local/state/cxs/`,**不需要重 sync**(240 MB 索引不会重建)。如果新位置已有数据,迁移跳过,旧 cache 留在原地等用户手动处理。 84 86 85 87 ### 要求 86 88
+11 -1
cli.ts
··· 3 3 import { existsSync } from "node:fs"; 4 4 import { Command } from "commander"; 5 5 import packageJson from "./package.json" with { type: "json" }; 6 - import { DEFAULT_CODEX_STATE_DB_PATH, DEFAULT_DB_PATH, resolveCodexDir } from "./env"; 6 + import { 7 + DEFAULT_CODEX_STATE_DB_PATH, 8 + DEFAULT_DB_PATH, 9 + migrateLegacyCacheDirIfNeeded, 10 + resolveCodexDir, 11 + } from "./env"; 12 + 13 + // One-shot migration from legacy ~/.cache/cxs/ to ~/.local/state/cxs/. Runs 14 + // before any subcommand so `cxs stats` etc. see the migrated db, not just 15 + // `cxs sync`. Idempotent + silent on failure (worst case is a re-sync). 16 + migrateLegacyCacheDirIfNeeded(); 7 17 import { 8 18 printCurrentSessions, 9 19 printFindResults,
+65
env.test.ts
··· 1 + import { afterEach, describe, expect, test } from "vitest"; 2 + import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; 3 + import { tmpdir } from "node:os"; 4 + import { join } from "node:path"; 5 + import { migrateLegacyCacheDir } from "./env"; 6 + 7 + const tempDirs: string[] = []; 8 + 9 + afterEach(() => { 10 + for (const dir of tempDirs.splice(0)) { 11 + rmSync(dir, { recursive: true, force: true }); 12 + } 13 + }); 14 + 15 + describe("migrateLegacyCacheDir", () => { 16 + test("moves the legacy directory to dest when dest is absent", () => { 17 + const base = mkdtempSync(join(tmpdir(), "cxs-mig-")); 18 + tempDirs.push(base); 19 + const legacy = join(base, "legacy"); 20 + const dest = join(base, "state", "cxs"); 21 + mkdirSync(legacy); 22 + writeFileSync(join(legacy, "index.sqlite"), "stub"); 23 + 24 + expect(migrateLegacyCacheDir(legacy, dest)).toBe(true); 25 + expect(existsSync(legacy)).toBe(false); 26 + expect(readFileSync(join(dest, "index.sqlite"), "utf8")).toBe("stub"); 27 + }); 28 + 29 + test("does nothing when legacy is missing (clean install)", () => { 30 + const base = mkdtempSync(join(tmpdir(), "cxs-mig-")); 31 + tempDirs.push(base); 32 + const legacy = join(base, "legacy"); 33 + const dest = join(base, "state", "cxs"); 34 + 35 + expect(migrateLegacyCacheDir(legacy, dest)).toBe(false); 36 + expect(existsSync(dest)).toBe(false); 37 + }); 38 + 39 + test("refuses to clobber when dest already has data", () => { 40 + const base = mkdtempSync(join(tmpdir(), "cxs-mig-")); 41 + tempDirs.push(base); 42 + const legacy = join(base, "legacy"); 43 + const dest = join(base, "state", "cxs"); 44 + mkdirSync(legacy); 45 + writeFileSync(join(legacy, "index.sqlite"), "old"); 46 + mkdirSync(dest, { recursive: true }); 47 + writeFileSync(join(dest, "index.sqlite"), "new"); 48 + 49 + expect(migrateLegacyCacheDir(legacy, dest)).toBe(false); 50 + // both still in place; user can decide which to keep 51 + expect(readFileSync(join(legacy, "index.sqlite"), "utf8")).toBe("old"); 52 + expect(readFileSync(join(dest, "index.sqlite"), "utf8")).toBe("new"); 53 + }); 54 + 55 + test("no-op when legacy === dest (CXS_DATA_DIR points back to legacy)", () => { 56 + const base = mkdtempSync(join(tmpdir(), "cxs-mig-")); 57 + tempDirs.push(base); 58 + const same = join(base, "shared"); 59 + mkdirSync(same); 60 + writeFileSync(join(same, "index.sqlite"), "stub"); 61 + 62 + expect(migrateLegacyCacheDir(same, same)).toBe(false); 63 + expect(readFileSync(join(same, "index.sqlite"), "utf8")).toBe("stub"); 64 + }); 65 + });
+60 -8
env.ts
··· 1 - import { existsSync, mkdirSync } from "node:fs"; 1 + import { existsSync, mkdirSync, renameSync } from "node:fs"; 2 2 import { homedir } from "node:os"; 3 - import { resolve } from "node:path"; 3 + import { join, resolve } from "node:path"; 4 4 5 - // Why: previously DATA_DIR was resolve(import.meta.dir, "data"), which works 6 - // for dev checkouts but breaks for both `npm i -g cxs` (writes into 7 - // node_modules) and `bun build --compile` (writes into the read-only 8 - // /$bunfs virtual fs). Default to ~/.cache/cxs (XDG cache convention) and 9 - // let CXS_DATA_DIR override. 5 + // Default data dir resolution: 6 + // 1. $CXS_DATA_DIR (explicit override) — wins 7 + // 2. $XDG_STATE_HOME/cxs — XDG state convention 8 + // 3. ~/.local/state/cxs — XDG fallback 9 + // 10 + // Why state and not cache: cxs's index is rebuildable but rebuilding takes 11 + // minutes on real corpora, so it's "warm state", not throwaway cache. 12 + // XDG_STATE_HOME is exactly the bucket for "data that should persist 13 + // between application runs but is not important enough to be put in 14 + // XDG_DATA_HOME". 15 + // 16 + // macOS gets the same Unix-style path on purpose — dev CLIs blend better 17 + // with the rest of the user's tooling there than under 18 + // ~/Library/Application Support/. 19 + // 20 + // Windows is unsupported (see package.json `os` field). If the code 21 + // somehow runs there it'll still produce a path under homedir(). 22 + const LEGACY_CACHE_DIR = join(homedir(), ".cache", "cxs"); 23 + 24 + function defaultDataDir(): string { 25 + const xdgState = process.env.XDG_STATE_HOME; 26 + if (xdgState) return resolve(xdgState, "cxs"); 27 + return join(homedir(), ".local", "state", "cxs"); 28 + } 29 + 10 30 const DATA_DIR = process.env.CXS_DATA_DIR 11 31 ? resolve(process.env.CXS_DATA_DIR) 12 - : resolve(homedir(), ".cache", "cxs"); 32 + : defaultDataDir(); 13 33 14 34 export const DEFAULT_DB_PATH = resolve(DATA_DIR, "index.sqlite"); 15 35 export const DEFAULT_CODEX_DIR = resolve(homedir(), ".codex", "sessions"); ··· 18 38 export const INDEX_VERSION = "cxs-v5-session-field-weights"; 19 39 20 40 export function ensureDataDir(): void { 41 + migrateLegacyCacheDir(LEGACY_CACHE_DIR, DATA_DIR); 21 42 if (!existsSync(DATA_DIR)) { 22 43 mkdirSync(DATA_DIR, { recursive: true }); 23 44 } ··· 26 47 export function resolveCodexDir(override?: string): string { 27 48 return override ? resolve(override) : DEFAULT_CODEX_DIR; 28 49 } 50 + 51 + // Convenience wrapper called once from cli.ts entry — keeps the migration 52 + // out of env.ts's top-level so vitest importing env.ts doesn't touch user 53 + // home as a side effect. Safe to call multiple times (idempotent). 54 + export function migrateLegacyCacheDirIfNeeded(): boolean { 55 + return migrateLegacyCacheDir(LEGACY_CACHE_DIR, DATA_DIR); 56 + } 57 + 58 + // One-shot migration from the old ~/.cache/cxs/ default to whatever DATA_DIR 59 + // resolves to now. No-op when: 60 + // - dest already has data (we don't clobber) 61 + // - legacy doesn't exist (clean install) 62 + // - user opted into a custom dir via CXS_DATA_DIR pointing at the legacy 63 + // cache (they're consciously using cache; respect that) 64 + // 65 + // Failure (e.g. cross-device rename, perm error) is swallowed — the worst 66 + // case is the user re-running `cxs sync`, which is cheap and idempotent. 67 + // Exported for unit tests. 68 + export function migrateLegacyCacheDir(legacyDir: string, destDir: string): boolean { 69 + if (legacyDir === destDir) return false; 70 + if (!existsSync(legacyDir)) return false; 71 + if (existsSync(destDir)) return false; 72 + 73 + try { 74 + mkdirSync(resolve(destDir, ".."), { recursive: true }); 75 + renameSync(legacyDir, destDir); 76 + return true; 77 + } catch { 78 + return false; 79 + } 80 + }