Add full Cursor chat archive for Documents/repos workspaces.
Includes global state.vscdb, per-repo transcripts, workspace storage, composer exports, and restore scripts for a new machine. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Executable
+329
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Export Cursor chat history for ~/Documents/repos workspaces into this archive."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
ARCHIVE_ROOT = Path(__file__).resolve().parents[1]
|
||||
OLD_HOME = Path.home()
|
||||
OLD_REPOS_ROOT = OLD_HOME / "Documents" / "repos"
|
||||
CURSOR_PROJECTS = OLD_HOME / ".cursor" / "projects"
|
||||
CURSOR_USER = OLD_HOME / ".config" / "Cursor" / "User"
|
||||
GLOBAL_DB = CURSOR_USER / "globalStorage" / "state.vscdb"
|
||||
STORAGE_JSON = CURSOR_USER / "globalStorage" / "storage.json"
|
||||
IDE_STATE = OLD_HOME / ".cursor" / "ide_state.json"
|
||||
WS_ROOT = CURSOR_USER / "workspaceStorage"
|
||||
|
||||
|
||||
def folder_uri(path: Path) -> str:
|
||||
return f"file://{path.resolve()}"
|
||||
|
||||
|
||||
def workspace_id(path: Path) -> str:
|
||||
return hashlib.md5(folder_uri(path).encode()).hexdigest()
|
||||
|
||||
|
||||
def path_to_slug(path: Path) -> str:
|
||||
return str(path.resolve()).strip("/").replace("/", "-")
|
||||
|
||||
|
||||
def repo_name_from_path(path: str) -> str | None:
|
||||
marker = "/Documents/repos/"
|
||||
if marker not in path:
|
||||
return None
|
||||
return path.split(marker, 1)[1].split("/")[0]
|
||||
|
||||
|
||||
def sqlite_backup(src: Path, dst: Path) -> None:
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
if dst.exists():
|
||||
dst.unlink()
|
||||
src_conn = sqlite3.connect(f"file:{src}?mode=ro", uri=True)
|
||||
dst_conn = sqlite3.connect(dst)
|
||||
src_conn.backup(dst_conn)
|
||||
src_conn.close()
|
||||
dst_conn.close()
|
||||
|
||||
|
||||
def parse_folder_uri(uri: str) -> str:
|
||||
if uri.startswith("file://"):
|
||||
parsed = urlparse(uri)
|
||||
return unquote(parsed.path)
|
||||
return uri
|
||||
|
||||
|
||||
def collect_composer_index(conn: sqlite3.Connection) -> dict[str, list[str]]:
|
||||
"""Map repo_name -> list of composerIds."""
|
||||
by_repo: dict[str, list[str]] = {}
|
||||
row = conn.execute(
|
||||
"SELECT value FROM ItemTable WHERE key='composer.composerHeaders'"
|
||||
).fetchone()
|
||||
if not row:
|
||||
return by_repo
|
||||
data = json.loads(row[0])
|
||||
for composer in data.get("allComposers", []):
|
||||
cid = composer.get("composerId")
|
||||
if not cid:
|
||||
continue
|
||||
wi = composer.get("workspaceIdentifier") or {}
|
||||
uri = wi.get("uri") or {}
|
||||
fs_path = uri.get("fsPath") or uri.get("path") or ""
|
||||
name = repo_name_from_path(fs_path)
|
||||
if name:
|
||||
by_repo.setdefault(name, []).append(cid)
|
||||
return by_repo
|
||||
|
||||
|
||||
def export_composer_blobs(conn: sqlite3.Connection, composer_ids: list[str], dest: Path) -> int:
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
count = 0
|
||||
for cid in composer_ids:
|
||||
key = f"composerData:{cid}"
|
||||
row = conn.execute(
|
||||
"SELECT value FROM cursorDiskKV WHERE key=?", (key,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
continue
|
||||
out = dest / f"{cid}.json"
|
||||
try:
|
||||
parsed = json.loads(row[0])
|
||||
out.write_text(
|
||||
json.dumps(parsed, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
out.write_text(row[0], encoding="utf-8")
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def copy_tree(src: Path, dst: Path) -> bool:
|
||||
if not src.exists():
|
||||
return False
|
||||
if dst.exists():
|
||||
shutil.rmtree(dst)
|
||||
shutil.copytree(src, dst, dirs_exist_ok=True)
|
||||
return True
|
||||
|
||||
|
||||
def discover_repos_from_workspace_storage() -> dict[str, dict]:
|
||||
repos: dict[str, dict] = {}
|
||||
for ws_dir in sorted(WS_ROOT.iterdir()):
|
||||
wj = ws_dir / "workspace.json"
|
||||
if not wj.is_file():
|
||||
continue
|
||||
data = json.loads(wj.read_text(encoding="utf-8"))
|
||||
folder = data.get("folder", "")
|
||||
if "/Documents/repos/" not in folder:
|
||||
continue
|
||||
abs_path = parse_folder_uri(folder)
|
||||
name = repo_name_from_path(abs_path)
|
||||
if not name:
|
||||
continue
|
||||
entry = repos.setdefault(
|
||||
name,
|
||||
{
|
||||
"repo_name": name,
|
||||
"old_absolute_path": abs_path,
|
||||
"old_folder_uri": folder,
|
||||
"old_workspace_ids": [],
|
||||
"old_project_slug": path_to_slug(Path(abs_path)),
|
||||
"workspace_storage_sizes": {},
|
||||
},
|
||||
)
|
||||
ws_id = ws_dir.name
|
||||
if ws_id not in entry["old_workspace_ids"]:
|
||||
entry["old_workspace_ids"].append(ws_id)
|
||||
size = sum(f.stat().st_size for f in ws_dir.rglob("*") if f.is_file())
|
||||
entry["workspace_storage_sizes"][ws_id] = size
|
||||
return repos
|
||||
|
||||
|
||||
def discover_project_slugs() -> dict[str, str]:
|
||||
"""slug -> repo_name (if mappable)."""
|
||||
mapping: dict[str, str] = {}
|
||||
prefix = "home-ruslanpi-Documents-repos-"
|
||||
for proj in CURSOR_PROJECTS.iterdir():
|
||||
if not proj.is_dir():
|
||||
continue
|
||||
name = proj.name
|
||||
if name == "home-ruslanpi-Documents-repos":
|
||||
mapping[name] = "__repos_root__"
|
||||
elif name.startswith(prefix):
|
||||
mapping[name] = name[len(prefix) :]
|
||||
elif "Documents-repos-lmru-devsecops-ansible" in name:
|
||||
mapping[name] = "lmru--devsecops--ansible"
|
||||
return mapping
|
||||
|
||||
|
||||
def pick_primary_workspace_id(entry: dict) -> str:
|
||||
sizes = entry.get("workspace_storage_sizes") or {}
|
||||
if not sizes:
|
||||
return entry["old_workspace_ids"][0]
|
||||
return max(sizes, key=sizes.get)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("Archive root:", ARCHIVE_ROOT)
|
||||
print("Exporting from:", OLD_REPOS_ROOT)
|
||||
|
||||
repos = discover_repos_from_workspace_storage()
|
||||
slug_map = discover_project_slugs()
|
||||
|
||||
for slug, repo_name in slug_map.items():
|
||||
if repo_name == "__repos_root__":
|
||||
continue
|
||||
if repo_name not in repos:
|
||||
repos[repo_name] = {
|
||||
"repo_name": repo_name,
|
||||
"old_absolute_path": str(OLD_REPOS_ROOT / repo_name),
|
||||
"old_folder_uri": folder_uri(OLD_REPOS_ROOT / repo_name),
|
||||
"old_workspace_ids": [],
|
||||
"old_project_slug": slug,
|
||||
"workspace_storage_sizes": {},
|
||||
}
|
||||
|
||||
# Global backup
|
||||
global_dir = ARCHIVE_ROOT / "global"
|
||||
global_dir.mkdir(parents=True, exist_ok=True)
|
||||
if GLOBAL_DB.exists():
|
||||
print("Backing up global state.vscdb ...")
|
||||
sqlite_backup(GLOBAL_DB, global_dir / "state.vscdb")
|
||||
if STORAGE_JSON.exists():
|
||||
shutil.copy2(STORAGE_JSON, global_dir / "storage.json")
|
||||
if IDE_STATE.exists():
|
||||
shutil.copy2(IDE_STATE, global_dir / "ide_state.json")
|
||||
|
||||
composer_by_repo: dict[str, list[str]] = {}
|
||||
if GLOBAL_DB.exists():
|
||||
conn = sqlite3.connect(f"file:{GLOBAL_DB}?mode=ro", uri=True)
|
||||
composer_by_repo = collect_composer_index(conn)
|
||||
|
||||
stats = {
|
||||
"repos_count": 0,
|
||||
"agent_transcript_files": 0,
|
||||
"workspace_storage_dirs": 0,
|
||||
"composer_json_exports": 0,
|
||||
}
|
||||
|
||||
for repo_name, entry in sorted(repos.items()):
|
||||
stats["repos_count"] += 1
|
||||
repo_dir = ARCHIVE_ROOT / "repos" / repo_name
|
||||
repo_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
old_path = Path(entry["old_absolute_path"])
|
||||
entry["expected_workspace_id"] = workspace_id(old_path)
|
||||
entry["old_project_slug"] = path_to_slug(old_path)
|
||||
entry["composer_ids"] = sorted(set(composer_by_repo.get(repo_name, [])))
|
||||
entry["primary_workspace_id"] = (
|
||||
pick_primary_workspace_id(entry)
|
||||
if entry.get("old_workspace_ids")
|
||||
else entry["expected_workspace_id"]
|
||||
)
|
||||
|
||||
# agent-transcripts
|
||||
slug = entry["old_project_slug"]
|
||||
proj_dir = CURSOR_PROJECTS / slug
|
||||
at_src = proj_dir / "agent-transcripts"
|
||||
if copy_tree(at_src, repo_dir / "agent-transcripts"):
|
||||
stats["agent_transcript_files"] += len(
|
||||
list((repo_dir / "agent-transcripts").rglob("*.jsonl"))
|
||||
)
|
||||
entry["has_agent_transcripts"] = True
|
||||
else:
|
||||
entry["has_agent_transcripts"] = False
|
||||
|
||||
# extra slugs (renamed repos)
|
||||
extra_slugs = [
|
||||
s for s, r in slug_map.items() if r == repo_name and s != slug
|
||||
]
|
||||
entry["extra_project_slugs"] = extra_slugs
|
||||
for extra in extra_slugs:
|
||||
extra_at = CURSOR_PROJECTS / extra / "agent-transcripts"
|
||||
extra_dst = repo_dir / "agent-transcripts-extra" / extra
|
||||
if copy_tree(extra_at, extra_dst):
|
||||
stats["agent_transcript_files"] += len(
|
||||
list(extra_dst.rglob("*.jsonl"))
|
||||
)
|
||||
|
||||
# workspace-storage (all ids, primary highlighted)
|
||||
ws_archive = repo_dir / "workspace-storage"
|
||||
ws_archive.mkdir(exist_ok=True)
|
||||
entry["has_workspace_storage"] = False
|
||||
for ws_id in entry.get("old_workspace_ids") or []:
|
||||
src = WS_ROOT / ws_id
|
||||
if copy_tree(src, ws_archive / ws_id):
|
||||
stats["workspace_storage_dirs"] += 1
|
||||
entry["has_workspace_storage"] = True
|
||||
|
||||
# composer exports
|
||||
if entry["composer_ids"] and GLOBAL_DB.exists():
|
||||
conn = sqlite3.connect(f"file:{GLOBAL_DB}?mode=ro", uri=True)
|
||||
n = export_composer_blobs(
|
||||
conn, entry["composer_ids"], repo_dir / "composers"
|
||||
)
|
||||
stats["composer_json_exports"] += n
|
||||
conn.close()
|
||||
|
||||
(repo_dir / "meta.json").write_text(
|
||||
json.dumps(entry, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# orphan slugs (transcripts without repo folder on disk)
|
||||
orphans = []
|
||||
for slug, repo_name in slug_map.items():
|
||||
if repo_name == "__repos_root__":
|
||||
orphans.append({"slug": slug, "kind": "repos_root"})
|
||||
proj = CURSOR_PROJECTS / slug
|
||||
if proj.exists():
|
||||
dst = ARCHIVE_ROOT / "orphans" / slug
|
||||
copy_tree(proj, dst)
|
||||
continue
|
||||
if repo_name not in repos:
|
||||
orphans.append({"slug": slug, "repo_name": repo_name})
|
||||
dst = ARCHIVE_ROOT / "orphans" / slug
|
||||
copy_tree(CURSOR_PROJECTS / slug, dst)
|
||||
|
||||
source_profile = {
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"old_home": str(OLD_HOME),
|
||||
"old_repos_root": str(OLD_REPOS_ROOT),
|
||||
"old_username": OLD_HOME.name,
|
||||
"workspace_id_uri_format": "file://{absolute_path}",
|
||||
"cursor_projects_dir": str(CURSOR_PROJECTS),
|
||||
"cursor_user_dir": str(CURSOR_USER),
|
||||
}
|
||||
|
||||
manifest = {
|
||||
"version": 1,
|
||||
"source": source_profile,
|
||||
"stats": stats,
|
||||
"repos": repos,
|
||||
"orphan_project_slugs": orphans,
|
||||
}
|
||||
|
||||
(ARCHIVE_ROOT / "source-profile.json").write_text(
|
||||
json.dumps(source_profile, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(ARCHIVE_ROOT / "manifest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
print(json.dumps(stats, indent=2))
|
||||
print("Done. Files written to", ARCHIVE_ROOT)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+356
@@ -0,0 +1,356 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Restore Cursor chat history from cursor-chat-archive on a new machine/path."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
ARCHIVE_ROOT = Path(__file__).resolve().parents[1]
|
||||
CONFIG_PATH = ARCHIVE_ROOT / "restore-config.json"
|
||||
MANIFEST_PATH = ARCHIVE_ROOT / "manifest.json"
|
||||
SOURCE_PROFILE_PATH = ARCHIVE_ROOT / "source-profile.json"
|
||||
|
||||
|
||||
def folder_uri(path: Path) -> str:
|
||||
return f"file://{path.resolve()}"
|
||||
|
||||
|
||||
def workspace_id(path: Path) -> str:
|
||||
return hashlib.md5(folder_uri(path).encode()).hexdigest()
|
||||
|
||||
|
||||
def path_to_slug(path: Path) -> str:
|
||||
return str(path.resolve()).strip("/").replace("/", "-")
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
if not CONFIG_PATH.is_file():
|
||||
print(f"Create {CONFIG_PATH} from restore-config.example.json", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def cursor_is_running() -> bool:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["pgrep", "-f", "cursor.mjs"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return bool(out.stdout.strip())
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def make_workspace_identifier(ws_id: str, abs_path: Path) -> dict:
|
||||
p = str(abs_path.resolve())
|
||||
return {
|
||||
"id": ws_id,
|
||||
"uri": {
|
||||
"$mid": 1,
|
||||
"fsPath": p,
|
||||
"external": f"file://{p}",
|
||||
"path": p,
|
||||
"scheme": "file",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def replace_bytes(blob: bytes, replacements: list[tuple[bytes, bytes]]) -> bytes:
|
||||
out = blob
|
||||
for old, new in replacements:
|
||||
if old in out:
|
||||
out = out.replace(old, new)
|
||||
return out
|
||||
|
||||
|
||||
def replace_in_json_obj(obj, old_path: str, new_path: str, old_ids: set[str], new_id: str) -> bool:
|
||||
changed = False
|
||||
if isinstance(obj, dict):
|
||||
if obj.get("id") in old_ids:
|
||||
obj["id"] = new_id
|
||||
changed = True
|
||||
uri = obj.get("uri")
|
||||
if isinstance(uri, dict):
|
||||
for key in ("fsPath", "path"):
|
||||
if uri.get(key) == old_path:
|
||||
uri[key] = new_path
|
||||
changed = True
|
||||
if uri.get("external") == f"file://{old_path}":
|
||||
uri["external"] = f"file://{new_path}"
|
||||
changed = True
|
||||
for v in obj.values():
|
||||
if replace_in_json_obj(v, old_path, new_path, old_ids, new_id):
|
||||
changed = True
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
if replace_in_json_obj(item, old_path, new_path, old_ids, new_id):
|
||||
changed = True
|
||||
elif isinstance(obj, str):
|
||||
if obj == old_path:
|
||||
return False
|
||||
return changed
|
||||
|
||||
|
||||
def remap_global_db(
|
||||
db_path: Path,
|
||||
old_home: str,
|
||||
new_home: str,
|
||||
repo_mappings: list[dict],
|
||||
) -> dict:
|
||||
conn = sqlite3.connect(db_path)
|
||||
stats = {"item_updates": 0, "kv_updates": 0, "header_updates": 0}
|
||||
|
||||
home_repl = [
|
||||
(old_home.encode(), new_home.encode()),
|
||||
(old_home.replace("/", "\\").encode(), new_home.replace("/", "\\").encode()),
|
||||
]
|
||||
|
||||
for old_path, new_path, old_ids, new_id, composer_ids in repo_mappings:
|
||||
old_b = old_path.encode()
|
||||
new_b = new_path.encode()
|
||||
repl = home_repl + [(old_b, new_b)]
|
||||
for old_ws in old_ids:
|
||||
repl.append((old_ws.encode(), new_id.encode()))
|
||||
|
||||
# composer headers
|
||||
row = conn.execute(
|
||||
"SELECT value FROM ItemTable WHERE key='composer.composerHeaders'"
|
||||
).fetchone()
|
||||
if row:
|
||||
data = json.loads(row[0])
|
||||
for composer in data.get("allComposers", []):
|
||||
cid = composer.get("composerId", "")
|
||||
wi = composer.get("workspaceIdentifier") or {}
|
||||
uri = wi.get("uri") or {}
|
||||
path = uri.get("fsPath") or uri.get("path") or ""
|
||||
ws = wi.get("id", "")
|
||||
if (
|
||||
cid in composer_ids
|
||||
or path == old_path
|
||||
or ws in old_ids
|
||||
):
|
||||
composer["workspaceIdentifier"] = json.loads(
|
||||
json.dumps(make_workspace_identifier(new_id, Path(new_path)))
|
||||
)
|
||||
stats["header_updates"] += 1
|
||||
conn.execute(
|
||||
"UPDATE ItemTable SET value=? WHERE key='composer.composerHeaders'",
|
||||
(json.dumps(data, ensure_ascii=False),),
|
||||
)
|
||||
|
||||
for cid in composer_ids:
|
||||
key = f"composerData:{cid}"
|
||||
row = conn.execute(
|
||||
"SELECT value FROM cursorDiskKV WHERE key=?", (key,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(row[0])
|
||||
if replace_in_json_obj(data, old_path, new_path, set(old_ids), new_id):
|
||||
conn.execute(
|
||||
"UPDATE cursorDiskKV SET value=? WHERE key=?",
|
||||
(json.dumps(data, ensure_ascii=False), key),
|
||||
)
|
||||
stats["kv_updates"] += 1
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# broad replace in ItemTable and cursorDiskKV strings
|
||||
for table, col in (("ItemTable", "value"), ("cursorDiskKV", "value")):
|
||||
rows = conn.execute(f"SELECT rowid, {col} FROM {table}").fetchall()
|
||||
for rowid, value in rows:
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
new_val = value
|
||||
for old_path, new_path, old_ids, new_id, _ in repo_mappings:
|
||||
new_val = new_val.replace(old_path, new_path)
|
||||
for oid in old_ids:
|
||||
new_val = new_val.replace(oid, new_id)
|
||||
new_val = new_val.replace(old_home, new_home)
|
||||
if new_val != value:
|
||||
conn.execute(
|
||||
f"UPDATE {table} SET {col}=? WHERE rowid=?",
|
||||
(new_val, rowid),
|
||||
)
|
||||
stats["item_updates"] += 1
|
||||
elif isinstance(value, bytes):
|
||||
new_val = value
|
||||
for old, new in home_repl:
|
||||
new_val = replace_bytes(new_val, [(old, new)])
|
||||
for old_path, new_path, old_ids, new_id, _ in repo_mappings:
|
||||
new_val = replace_bytes(
|
||||
new_val, [(old_path.encode(), new_path.encode())]
|
||||
)
|
||||
for oid in old_ids:
|
||||
new_val = replace_bytes(
|
||||
new_val, [(oid.encode(), new_id.encode())]
|
||||
)
|
||||
if new_val != value:
|
||||
conn.execute(
|
||||
f"UPDATE {table} SET {col}=? WHERE rowid=?",
|
||||
(new_val, rowid),
|
||||
)
|
||||
stats["item_updates"] += 1
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return stats
|
||||
|
||||
|
||||
def copy_tree(src: Path, dst: Path) -> None:
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
if dst.exists():
|
||||
shutil.rmtree(dst)
|
||||
if src.exists():
|
||||
shutil.copytree(src, dst, dirs_exist_ok=True)
|
||||
|
||||
|
||||
def fix_workspace_json(ws_dir: Path, new_uri: str) -> None:
|
||||
wj = ws_dir / "workspace.json"
|
||||
wj.write_text(
|
||||
json.dumps({"folder": new_uri}, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if cursor_is_running():
|
||||
sys.exit("Cursor is running. Quit Cursor completely, then run again.")
|
||||
|
||||
cfg = load_config()
|
||||
new_home = Path(cfg["new_home"]).resolve()
|
||||
new_repos_root = Path(cfg["new_repos_root"]).resolve()
|
||||
new_projects = new_home / ".cursor" / "projects"
|
||||
new_ws_root = new_home / ".config" / "Cursor" / "User" / "workspaceStorage"
|
||||
new_global = new_home / ".config" / "Cursor" / "User" / "globalStorage"
|
||||
|
||||
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
||||
source = json.loads(SOURCE_PROFILE_PATH.read_text(encoding="utf-8"))
|
||||
old_home = source["old_home"]
|
||||
repos = manifest["repos"]
|
||||
|
||||
ts = __import__("datetime").datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
backup_root = new_home / ".cursor" / f"restore-chat-backup-{ts}"
|
||||
backup_root.mkdir(parents=True, exist_ok=True)
|
||||
for p in (new_global / "state.vscdb", new_global / "storage.json"):
|
||||
if p.exists():
|
||||
shutil.copy2(p, backup_root / p.name)
|
||||
|
||||
# global
|
||||
archived_db = ARCHIVE_ROOT / "global" / "state.vscdb"
|
||||
if archived_db.exists():
|
||||
new_global.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(archived_db, new_global / "state.vscdb")
|
||||
archived_storage = ARCHIVE_ROOT / "global" / "storage.json"
|
||||
if archived_storage.exists():
|
||||
text = archived_storage.read_text(encoding="utf-8")
|
||||
text = text.replace(old_home, str(new_home))
|
||||
(new_global / "storage.json").write_text(text, encoding="utf-8")
|
||||
|
||||
repo_mappings = []
|
||||
restore_report = []
|
||||
|
||||
for repo_name, meta in repos.items():
|
||||
old_path = meta["old_absolute_path"]
|
||||
new_path = new_repos_root / repo_name
|
||||
new_uri = folder_uri(new_path)
|
||||
new_id = workspace_id(new_path)
|
||||
new_slug = path_to_slug(new_path)
|
||||
old_ids = meta.get("old_workspace_ids") or []
|
||||
if meta.get("primary_workspace_id") and meta["primary_workspace_id"] not in old_ids:
|
||||
old_ids.append(meta["primary_workspace_id"])
|
||||
composer_ids = set(meta.get("composer_ids") or [])
|
||||
repo_mappings.append(
|
||||
(old_path, str(new_path), old_ids, new_id, composer_ids)
|
||||
)
|
||||
|
||||
repo_archive = ARCHIVE_ROOT / "repos" / repo_name
|
||||
|
||||
# projects / agent-transcripts
|
||||
at_src = repo_archive / "agent-transcripts"
|
||||
if at_src.exists():
|
||||
copy_tree(at_src, new_projects / new_slug / "agent-transcripts")
|
||||
for extra in repo_archive.glob("agent-transcripts-extra/*"):
|
||||
if extra.is_dir():
|
||||
copy_tree(extra, new_projects / extra.name / "agent-transcripts")
|
||||
|
||||
# workspace storage — use primary id dir from archive
|
||||
primary = meta.get("primary_workspace_id")
|
||||
ws_src = None
|
||||
if primary:
|
||||
candidate = repo_archive / "workspace-storage" / primary
|
||||
if candidate.exists():
|
||||
ws_src = candidate
|
||||
if ws_src is None:
|
||||
ws_dirs = list((repo_archive / "workspace-storage").glob("*"))
|
||||
ws_src = ws_dirs[0] if ws_dirs else None
|
||||
if ws_src and ws_src.exists():
|
||||
dst_ws = new_ws_root / new_id
|
||||
copy_tree(ws_src, dst_ws)
|
||||
fix_workspace_json(dst_ws, new_uri)
|
||||
|
||||
restore_report.append(
|
||||
{
|
||||
"repo": repo_name,
|
||||
"new_path": str(new_path),
|
||||
"new_workspace_id": new_id,
|
||||
"new_project_slug": new_slug,
|
||||
}
|
||||
)
|
||||
|
||||
# orphans
|
||||
orphans_dir = ARCHIVE_ROOT / "orphans"
|
||||
if orphans_dir.exists():
|
||||
for child in orphans_dir.iterdir():
|
||||
if child.is_dir():
|
||||
copy_tree(child, new_projects / child.name)
|
||||
|
||||
if (new_global / "state.vscdb").exists():
|
||||
db_stats = remap_global_db(
|
||||
new_global / "state.vscdb",
|
||||
old_home,
|
||||
str(new_home),
|
||||
repo_mappings,
|
||||
)
|
||||
else:
|
||||
db_stats = {}
|
||||
|
||||
# ide_state
|
||||
ide_arch = ARCHIVE_ROOT / "global" / "ide_state.json"
|
||||
if ide_arch.exists():
|
||||
text = ide_arch.read_text(encoding="utf-8").replace(old_home, str(new_home))
|
||||
ide_dst = new_home / ".cursor" / "ide_state.json"
|
||||
ide_dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
ide_dst.write_text(text, encoding="utf-8")
|
||||
|
||||
report_path = new_home / ".cursor" / "cursor-chat-restore-report.json"
|
||||
report_path.write_text(
|
||||
json.dumps(
|
||||
{"db_stats": db_stats, "repos": restore_report},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
print("Restore complete.")
|
||||
print("Backup of previous DB:", backup_root)
|
||||
print("Report:", report_path)
|
||||
print("DB stats:", json.dumps(db_stats, indent=2))
|
||||
print("\nRestart Cursor, then open repos under:", new_repos_root)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user