Created
May 22, 2026 17:25
-
-
Save kidonng/1cd60c58061b5b9540cb3c098095130a to your computer and use it in GitHub Desktop.
Deno script to move Codex project
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env -S deno run --allow-read --allow-write --allow-run --allow-env | |
| import { join } from "jsr:@std/path"; | |
| const [oldCwdArg, newCwdArg] = Deno.args; | |
| if (!oldCwdArg || !newCwdArg) { | |
| console.error(` | |
| Usage: | |
| deno run --allow-read --allow-write --allow-run --allow-env move-codex-project.ts <old-cwd> <new-cwd> | |
| Example: | |
| deno run --allow-read --allow-write --allow-run --allow-env move-codex-project.ts \\ | |
| "/Users/kid/Old/project" \\ | |
| "/Users/kid/Projects/project" | |
| `); | |
| Deno.exit(1); | |
| } | |
| const home = Deno.env.get("HOME"); | |
| if (!home) { | |
| console.error("Cannot find HOME environment variable."); | |
| Deno.exit(1); | |
| } | |
| const oldCwd = expandHome(oldCwdArg); | |
| const newCwd = expandHome(newCwdArg); | |
| const configPath = join(home, ".codex", "config.toml"); | |
| const globalStatePath = join(home, ".codex", ".codex-global-state.json"); | |
| const dbPath = join(home, ".codex", "state_5.sqlite"); | |
| await ensureCommand("sqlite3"); | |
| await ensureFileExists(configPath, `Codex config not found: ${configPath}`); | |
| await ensureFileExists(globalStatePath, `Codex global state not found: ${globalStatePath}`); | |
| await ensureFileExists(dbPath, `Codex SQLite database not found: ${dbPath}`); | |
| await ensureDirectoryExists(newCwd, `New path does not exist or is not a directory: ${newCwd}`); | |
| const rows = await queryJson<ThreadRow>( | |
| dbPath, | |
| ` | |
| SELECT id, title, cwd, rollout_path | |
| FROM threads | |
| WHERE cwd = @oldCwd | |
| ORDER BY updated_at_ms DESC; | |
| `, | |
| { | |
| "@oldCwd": oldCwd, | |
| }, | |
| ); | |
| if (rows.length === 0) { | |
| console.log(`No Codex threads found for cwd:\n ${oldCwd}`); | |
| console.log(` | |
| Tip: check the exact stored cwd with: | |
| sqlite3 ${shellQuote(dbPath)} " | |
| SELECT quote(cwd), length(cwd), title | |
| FROM threads | |
| WHERE cwd LIKE '%${escapeForSqlLike(lastPathPart(oldCwd))}%' | |
| ORDER BY updated_at_ms DESC; | |
| " | |
| Common causes: | |
| - database cwd has a trailing slash | |
| - path uses a symlinked location | |
| - path casing differs | |
| - old path was expanded differently | |
| `); | |
| Deno.exit(0); | |
| } | |
| console.log(`Found ${rows.length} thread(s) to move:\n`); | |
| for (const row of rows) { | |
| console.log(`- ${row.title || "(untitled)"}`); | |
| console.log(` id: ${row.id}`); | |
| console.log(` cwd: ${row.cwd}`); | |
| console.log(` rollout: ${row.rollout_path || "(missing)"}`); | |
| console.log(""); | |
| } | |
| console.log(`Old cwd: | |
| ${oldCwd} | |
| New cwd: | |
| ${newCwd} | |
| This will: | |
| 1. Back up ${dbPath} | |
| 2. Update ${configPath} | |
| 3. Update ${globalStatePath} | |
| 4. Update threads.cwd in SQLite | |
| 5. Rewrite the first session_meta entry in each rollout JSONL | |
| Important: | |
| Please fully quit Codex before continuing. | |
| `); | |
| if (await hasProcessNamed("codex")) { | |
| console.log(red("Warning: found a running process named `codex`. Quit Codex fully before continuing.")); | |
| } | |
| const confirmed = await confirm("Continue?"); | |
| if (!confirmed) { | |
| console.log("Aborted."); | |
| Deno.exit(0); | |
| } | |
| await updateConfigToml(configPath, oldCwd, newCwd); | |
| console.log(`Config updated:\n ${configPath}`); | |
| await updateGlobalState(globalStatePath, oldCwd, newCwd); | |
| console.log(`Global state updated:\n ${globalStatePath}`); | |
| let updatedRollouts = 0; | |
| let skippedRollouts = 0; | |
| for (const row of rows) { | |
| if (!row.rollout_path) { | |
| console.warn(`Skipping rollout for ${row.id}: missing rollout_path`); | |
| skippedRollouts++; | |
| continue; | |
| } | |
| try { | |
| await rewriteSessionMeta(row.rollout_path, newCwd); | |
| updatedRollouts++; | |
| } catch (error) { | |
| console.warn(`Skipping rollout for ${row.id}: ${row.rollout_path}`); | |
| console.warn(` ${error instanceof Error ? error.message : String(error)}`); | |
| skippedRollouts++; | |
| } | |
| } | |
| await execSql( | |
| dbPath, | |
| ` | |
| UPDATE threads | |
| SET cwd = @newCwd | |
| WHERE cwd = @oldCwd; | |
| `, | |
| { | |
| "@newCwd": newCwd, | |
| "@oldCwd": oldCwd, | |
| }, | |
| ); | |
| console.log(` | |
| Done. | |
| Updated SQLite threads: | |
| ${rows.length} | |
| Updated rollout files: | |
| ${updatedRollouts} | |
| Skipped rollout files: | |
| ${skippedRollouts} | |
| Restart Codex after this. | |
| `); | |
| type ThreadRow = { | |
| id: string; | |
| title: string | null; | |
| cwd: string; | |
| rollout_path: string | null; | |
| }; | |
| async function rewriteSessionMeta(rolloutPath: string, newCwd: string) { | |
| const text = await Deno.readTextFile(rolloutPath); | |
| if (!text) { | |
| throw new Error("rollout file is empty"); | |
| } | |
| const firstLineEnd = text.indexOf("\n"); | |
| const firstLine = firstLineEnd === -1 | |
| ? text | |
| : text[firstLineEnd - 1] === "\r" | |
| ? text.slice(0, firstLineEnd - 1) | |
| : text.slice(0, firstLineEnd); | |
| const remainder = firstLineEnd === -1 ? "" : text.slice(firstLineEnd + 1); | |
| const first = JSON.parse(firstLine); | |
| if (first.type !== "session_meta") { | |
| throw new Error("first JSONL entry is not session_meta"); | |
| } | |
| const meta = structuredClone(first); | |
| meta.payload ??= {}; | |
| meta.payload.cwd = newCwd; | |
| // 移动 cwd 后,旧 git metadata 可能已经不准确。 | |
| delete meta.payload.git; | |
| const line = JSON.stringify(meta); | |
| const newline = firstLineEnd === -1 | |
| ? "\n" | |
| : text[firstLineEnd - 1] === "\r" | |
| ? "\r\n" | |
| : "\n"; | |
| const updated = `${line}${newline}${remainder}`; | |
| await Deno.writeTextFile(rolloutPath, updated); | |
| } | |
| async function updateConfigToml(path: string, oldCwd: string, newCwd: string) { | |
| const text = await Deno.readTextFile(path); | |
| const oldSection = `[projects."${oldCwd}"]`; | |
| const newSection = `[projects."${newCwd}"]`; | |
| if (!text.includes(oldSection) && !text.includes(newSection)) { | |
| throw new Error(`project section not found in config: ${oldSection}`); | |
| } | |
| const updated = text.replaceAll(oldSection, newSection); | |
| await Deno.writeTextFile(path, updated); | |
| } | |
| async function updateGlobalState(path: string, oldCwd: string, newCwd: string) { | |
| const text = await Deno.readTextFile(path); | |
| const state = JSON.parse(text); | |
| const updated = replaceExactStrings(state, oldCwd, newCwd); | |
| await Deno.writeTextFile(path, `${JSON.stringify(updated, null, 2)}\n`); | |
| } | |
| function replaceExactStrings<T>(value: T, oldCwd: string, newCwd: string): T { | |
| if (typeof value === "string") { | |
| return (value === oldCwd ? newCwd : value) as T; | |
| } | |
| if (Array.isArray(value)) { | |
| return value.map((item) => replaceExactStrings(item, oldCwd, newCwd)) as T; | |
| } | |
| if (value && typeof value === "object") { | |
| const entries = Object.entries(value as Record<string, unknown>).map( | |
| ([key, entry]) => [key, replaceExactStrings(entry, oldCwd, newCwd)] as const, | |
| ); | |
| return Object.fromEntries(entries) as T; | |
| } | |
| return value; | |
| } | |
| async function queryJson<T>( | |
| dbPath: string, | |
| sql: string, | |
| params: Record<string, string>, | |
| ): Promise<T[]> { | |
| const output = await sqliteWithParams(dbPath, sql, params, { | |
| json: true, | |
| }); | |
| const trimmed = output.trim(); | |
| return trimmed ? JSON.parse(trimmed) : []; | |
| } | |
| async function execSql( | |
| dbPath: string, | |
| sql: string, | |
| params: Record<string, string>, | |
| ) { | |
| await sqliteWithParams(dbPath, sql, params, { | |
| json: false, | |
| }); | |
| } | |
| async function sqliteWithParams( | |
| dbPath: string, | |
| sql: string, | |
| params: Record<string, string>, | |
| options: { | |
| json: boolean; | |
| }, | |
| ) { | |
| const script: string[] = [ | |
| ".bail on", | |
| ".parameter init", | |
| ]; | |
| for (const [name, value] of Object.entries(params)) { | |
| if (!/^[@:$][A-Za-z_][A-Za-z0-9_]*$/.test(name)) { | |
| throw new Error(`Invalid SQLite parameter name: ${name}`); | |
| } | |
| script.push(`.parameter set ${name} ${sqliteCliTextLiteral(value)}`); | |
| } | |
| if (options.json) { | |
| script.push(".mode json"); | |
| } | |
| script.push(sql.trim()); | |
| script.push(".quit"); | |
| const command = new Deno.Command("sqlite3", { | |
| args: [dbPath], | |
| stdin: "piped", | |
| stdout: "piped", | |
| stderr: "piped", | |
| }); | |
| const child = command.spawn(); | |
| const writer = child.stdin.getWriter(); | |
| await writer.write(new TextEncoder().encode(script.join("\n"))); | |
| await writer.close(); | |
| const { code, stdout, stderr } = await child.output(); | |
| const out = new TextDecoder().decode(stdout); | |
| const err = new TextDecoder().decode(stderr); | |
| if (code !== 0) { | |
| throw new Error(err || `sqlite3 exited with code ${code}`); | |
| } | |
| return out; | |
| } | |
| function sqliteCliTextLiteral(value: string) { | |
| // .parameter set 会把 value 当 SQL 表达式解析。 | |
| // 为了确保是 text,这里生成单引号 SQL string literal。 | |
| return `'${value.replaceAll("'", "''")}'`; | |
| } | |
| async function ensureCommand(name: string) { | |
| const command = new Deno.Command("which", { | |
| args: [name], | |
| stdout: "null", | |
| stderr: "null", | |
| }); | |
| const { code } = await command.output(); | |
| if (code !== 0) { | |
| console.error(`Required command not found: ${name}`); | |
| Deno.exit(1); | |
| } | |
| } | |
| async function ensureFileExists(path: string, message: string) { | |
| try { | |
| const stat = await Deno.stat(path); | |
| if (!stat.isFile) { | |
| console.error(message); | |
| Deno.exit(1); | |
| } | |
| } catch { | |
| console.error(message); | |
| Deno.exit(1); | |
| } | |
| } | |
| async function ensureDirectoryExists(path: string, message: string) { | |
| try { | |
| const stat = await Deno.stat(path); | |
| if (!stat.isDirectory) { | |
| console.error(message); | |
| Deno.exit(1); | |
| } | |
| } catch { | |
| console.error(message); | |
| Deno.exit(1); | |
| } | |
| } | |
| function expandHome(path: string) { | |
| if (path === "~") { | |
| const home = Deno.env.get("HOME"); | |
| if (!home) throw new Error("HOME is not set"); | |
| return home; | |
| } | |
| if (path.startsWith("~/")) { | |
| const home = Deno.env.get("HOME"); | |
| if (!home) throw new Error("HOME is not set"); | |
| return `${home}/${path.slice(2)}`; | |
| } | |
| // 故意不去掉末尾 slash。 | |
| // Codex 数据库里 cwd 如果保存了末尾 slash,脚本应该按用户传入的精确值匹配。 | |
| return path; | |
| } | |
| function shellQuote(value: string) { | |
| return `'${value.replaceAll("'", "'\\''")}'`; | |
| } | |
| function lastPathPart(path: string) { | |
| const parts = path.split("/").filter(Boolean); | |
| return parts.at(-1) ?? path; | |
| } | |
| function escapeForSqlLike(value: string) { | |
| return value.replaceAll("'", "''"); | |
| } | |
| async function hasProcessNamed(name: string) { | |
| const command = new Deno.Command("pgrep", { | |
| args: ["-x", name], | |
| stdout: "null", | |
| stderr: "null", | |
| }); | |
| const { code } = await command.output(); | |
| return code === 0; | |
| } | |
| function red(text: string) { | |
| return `\x1b[31m${text}\x1b[0m`; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment