Skip to content

Instantly share code, notes, and snippets.

@kidonng
Created May 22, 2026 17:25
Show Gist options
  • Select an option

  • Save kidonng/1cd60c58061b5b9540cb3c098095130a to your computer and use it in GitHub Desktop.

Select an option

Save kidonng/1cd60c58061b5b9540cb3c098095130a to your computer and use it in GitHub Desktop.
Deno script to move Codex project
#!/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