From 1ba7c866de6189311e85c7c333ada26f74b6da0c Mon Sep 17 00:00:00 2001 From: Fafo Date: Sun, 31 May 2026 18:56:31 +0200 Subject: [PATCH 1/7] Expand Cursor setup support --- README.md | 8 +- src/adapters/cursor/index.ts | 556 +++++++++++++++++++---- src/adapters/cursor/paths.ts | 60 ++- src/commands/backup.ts | 2 +- src/commands/restore.ts | 21 +- test/integration/capture-restore.test.ts | 280 ++++++++++-- 6 files changed, 769 insertions(+), 158 deletions(-) diff --git a/README.md b/README.md index 55ffbe5..ea89d64 100644 --- a/README.md +++ b/README.md @@ -49,18 +49,18 @@ arbella --help ## What it does ```text - push ~/.claude · ~/.codex ──▶ strip · template ──▶ private repo - pull private repo ──▶ install · place ──▶ ~/.claude · ~/.codex + push ~/.claude · ~/.codex · Cursor ──▶ strip · template ──▶ private repo + pull private repo ──▶ install · place ──▶ tools + Cursor User data ``` -`push` reads the parts of your setup that matter, strips anything secret, swaps machine-specific paths for placeholders, and pushes the result to your repo. `pull` does the reverse: installs the CLIs you don't have, drops files back with this machine's paths, reinstalls plugins and skills, and wires your shared instructions into both Claude and Codex. +`push` reads the parts of your setup that matter, strips anything secret, swaps machine-specific paths for placeholders, and pushes the result to your repo. `pull` does the reverse: installs the CLIs you don't have, drops files back with this machine's paths, reinstalls plugins and skills, and wires your shared instructions into the restored tools. Two things it deliberately won't do: - **Never commits secrets.** API keys, OAuth tokens, `auth.json`, `.credentials.json` — all excluded, no exceptions. You sign back in after a pull, or carry them yourself with [`arbella secrets`](#arbella-secrets). - **Doesn't copy what it can reinstall.** Plugins and registry skills are saved as a list and pulled fresh, so the repo stays small and never goes stale. -Supported today: **Claude Code** and **Codex**, plus a **Cursor** adapter that quietly does nothing when Cursor isn't installed. +Supported today: **Claude Code**, **Codex**, and **Cursor**. Cursor support covers global MCP config, user settings, keybindings, snippets, local skills, skills.sh symlinks, and extension IDs; runtime state and credentials stay out. ## Commands diff --git a/src/adapters/cursor/index.ts b/src/adapters/cursor/index.ts index ac841f5..9701d7a 100644 --- a/src/adapters/cursor/index.ts +++ b/src/adapters/cursor/index.ts @@ -2,26 +2,25 @@ * The Cursor adapter — implements the Adapter contract for Cursor. * * Cursor is a desktop app; its CLI may be entirely absent (notably on Linux, - * where there is no headless install). This adapter is therefore deliberately - * MINIMAL but real, and FULLY GRACEFUL when ~/.cursor does not exist: + * where there is no headless install). This adapter is therefore real for the + * portable parts of Cursor and fully graceful when Cursor does not exist: * - * - detect(): true only if ~/.cursor exists (returns false cleanly, - * never throws, when the dir is missing). + * - detect(): true if ~/.cursor or Cursor User data exists; false + * cleanly when both are missing. * - isCliInstalled():probes `cursor` on PATH (often false on this kind of box). * - installCli(os): brew --cask cursor (darwin) / winget (win32); on Linux * there is no standard install -> warn + skip (no throw). * - capture(ctx): if Cursor is absent, returns an EMPTY CaptureResult with a * single warning (so backup of the other tools is never - * blocked). Otherwise freezes ~/.cursor/mcp.json, sanitized - * (secret values redacted) + templated (machine paths -> - * {{HOME}}/...). MCP server entries whose env/headers held - * secret values are recorded as SecretRefs (metadata only; - * the redacted file is what gets stored). - * - restore(ctx,d): writes the frozen files back onto ~/.cursor (rehydrating - * {{TOKENS}} -> machine paths). When the backup repo carried - * shared instructions (R9), also materializes a Cursor user - * rule from /shared/instructions.md so the single - * CLAUDE.md == AGENTS.md content reaches Cursor too. + * blocked). Otherwise freezes global MCP config, Cursor User + * settings/keybindings/snippets, local skills, skills.sh + * symlinks, and extension IDs. Secret values are redacted and + * machine paths are templated before anything enters the repo. + * - restore(ctx,d): writes those files back onto ~/.cursor or Cursor's User + * data dir, rehydrating {{TOKENS}} to this machine's paths. + * It also recreates skill symlinks, best-effort installs + * extensions via `cursor --install-extension`, and deploys the + * shared instructions rule when present. * * All fs work goes through the injected CoreServices on the context objects, so * the adapter is unit-testable against a fixture dir. No direct node:fs, no clock. @@ -30,24 +29,31 @@ import path from "node:path"; +import { execa } from "execa"; + import type { Adapter, CaptureContext, RestoreContext, RestoreData } from "../adapter.interface.js"; import type { CaptureResult, CapturedFile, CapturedSymlink, OS, + PluginEntry, + RestoreAction, SecretRef, + SkillEntry, ToolManifest, } from "../../types.js"; import { denylistFor, matchesDeny } from "../../core/sanitizer/denylist.js"; -import { cliBinaryName, installCommandFor } from "../../platform/os.js"; +import { cliBinaryName, detectOS, installCommandFor } from "../../platform/os.js"; import { runInstall, which } from "../../platform/install.js"; import { FROZEN_PATHS, REPO_PREFIX, SHARED_INSTRUCTIONS_REPO_PATH, + USER_REPO_PREFIX, + cursorUserPaths, paths, sharedRulePath, } from "./paths.js"; @@ -61,18 +67,34 @@ function repoPathFor(rel: string): string { return `${REPO_PREFIX}/${rel}`; } +/** Build the repo path for a Cursor User-data-relative POSIX path. */ +function userRepoPathFor(rel: string): string { + return `${USER_REPO_PREFIX}/${rel}`; +} + /** Convert an absolute child path under `home` into a POSIX rel path. */ function toRel(home: string, abs: string): string { return path.relative(home, abs).split(path.sep).join("/"); } -/** Strip the leading "cursor/files/" prefix from a repoPath. Returns the POSIX - * tail relative to the tool home, or undefined if the path is not ours. */ -function relFromRepoPath(repoPath: string): string | undefined { +type CursorTarget = { root: "home" | "user"; rel: string }; + +/** Strip cursor/files or cursor/user from a repo path and keep which root it uses. */ +function targetFromRepoPath(repoPath: string): CursorTarget | undefined { const norm = repoPath.replace(/\\/g, "/"); - const prefix = `${REPO_PREFIX}/`; - if (!norm.startsWith(prefix)) return undefined; - return norm.slice(prefix.length); + const homePrefix = `${REPO_PREFIX}/`; + if (norm.startsWith(homePrefix)) return { root: "home", rel: norm.slice(homePrefix.length) }; + + const userPrefix = `${USER_REPO_PREFIX}/`; + if (norm.startsWith(userPrefix)) return { root: "user", rel: norm.slice(userPrefix.length) }; + + return undefined; +} + +/** Resolve a CursorTarget onto the target machine. */ +function targetAbsFor(ctx: RestoreContext, target: CursorTarget): string { + const root = target.root === "home" ? ctx.toolHome : cursorUserPaths(ctx.toolHome, ctx.os).userDir; + return path.join(root, ...target.rel.split("/").filter(Boolean)); } /** Heuristic: treat a file as binary if its bytes contain a NUL. */ @@ -142,6 +164,263 @@ function collectMcpSecretRefs( return refs; } +/** Read mode bits best-effort so executable helper files keep their mode. */ +async function readMode(abs: string): Promise { + try { + const { promises: fsp } = await import("node:fs"); + const st = await fsp.stat(abs); + return st.mode & 0o777; + } catch { + return undefined; + } +} + +/** Read + sanitize + template a single file and push a CapturedFile. */ +async function captureFile(args: { + ctx: CaptureContext; + abs: string; + rel: string; + repoPath: string; + files: CapturedFile[]; + secrets: SecretRef[]; + warnings: string[]; + scanMcpSecrets?: boolean; +}): Promise { + const { ctx, abs, rel, repoPath, files, secrets, warnings, scanMcpSecrets = false } = args; + const mode = await readMode(abs); + + let bytes: Buffer; + try { + bytes = await ctx.fs.readBytes(abs); + } catch (err) { + warnings.push(`cursor: could not read ${rel}: ${(err as Error).message}`); + return; + } + + if (looksBinary(bytes)) { + const file: CapturedFile = { + repoPath, + content: bytes.toString("base64"), + binary: true, + }; + if (mode !== undefined) file.mode = mode; + files.push(file); + return; + } + + const raw = bytes.toString("utf8"); + if (scanMcpSecrets) { + try { + secrets.push(...collectMcpSecretRefs(ctx, JSON.parse(raw), rel)); + } catch (err) { + warnings.push(`cursor: could not parse ${rel} for secret scan: ${(err as Error).message}`); + } + } + + const content = ctx.includeSecrets ? raw : ctx.sanitizer.sanitizeFile(raw, "cursor", rel).content; + const templated = ctx.templater.toTemplate(content, ctx.vars); + const file: CapturedFile = { repoPath, content: templated }; + if (mode !== undefined) file.mode = mode; + files.push(file); +} + +/** Recursively freeze a Cursor directory tree. */ +async function walkFrozen( + ctx: CaptureContext, + root: string, + abs: string, + deny: string[], + repoPathBuilder: (rel: string) => string, + files: CapturedFile[], + symlinks: CapturedSymlink[], + secrets: SecretRef[], + warnings: string[], +): Promise { + const kind = await ctx.fs.statKind(abs); + if (kind === "missing") return; + + const rel = toRel(root, abs); + if (rel && matchesDeny(rel, deny)) { + ctx.log.debug(`cursor: skip (denylist) ${rel}`); + return; + } + + if (kind === "symlink") { + const target = await ctx.fs.readLink(abs); + symlinks.push({ repoPath: repoPathBuilder(rel), target }); + ctx.log.debug(`cursor: symlink ${rel} -> ${target}`); + return; + } + + if (kind === "dir") { + const entries = await ctx.fs.list(abs); + entries.sort(); + for (const name of entries) { + await walkFrozen(ctx, root, path.join(abs, name), deny, repoPathBuilder, files, symlinks, secrets, warnings); + } + return; + } + + await captureFile({ + ctx, + abs, + rel, + repoPath: repoPathBuilder(rel), + files, + secrets, + warnings, + scanMcpSecrets: rel === "mcp.json", + }); +} + +/** Classify Cursor skills as reinstallable symlinks or frozen local skill dirs. */ +async function captureSkills( + ctx: CaptureContext, + home: string, + skillsDir: string, + deny: string[], + files: CapturedFile[], + symlinks: CapturedSymlink[], + skills: SkillEntry[], + secrets: SecretRef[], + warnings: string[], +): Promise { + if ((await ctx.fs.statKind(skillsDir)) !== "dir") return; + + const entries = await ctx.fs.list(skillsDir); + entries.sort(); + + for (const name of entries) { + const abs = path.join(skillsDir, name); + const rel = toRel(home, abs); + if (matchesDeny(rel, deny)) continue; + + const kind = await ctx.fs.statKind(abs); + if (kind === "symlink") { + const target = await ctx.fs.readLink(abs); + symlinks.push({ repoPath: repoPathFor(rel), target }); + skills.push({ + name, + source: "skills.sh", + installCommand: `npx skills add ${name}`, + symlinked: true, + }); + continue; + } + + if (kind === "dir") { + skills.push({ name, source: "frozen", symlinked: false }); + await walkFrozen(ctx, home, abs, deny, repoPathFor, files, symlinks, secrets, warnings); + continue; + } + + if (kind === "file") { + skills.push({ name, source: "frozen", symlinked: false }); + await walkFrozen(ctx, home, abs, deny, repoPathFor, files, symlinks, secrets, warnings); + } + } +} + +function parseExtensionId(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + if (!/^[A-Za-z0-9][A-Za-z0-9_-]*\.[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(trimmed)) return undefined; + return trimmed; +} + +function parseCursorExtensionEntry(entry: unknown): PluginEntry | undefined { + if (entry === null || typeof entry !== "object") return undefined; + const obj = entry as Record; + const identifier = obj.identifier; + const identifierObj = identifier !== null && typeof identifier === "object" + ? identifier as Record + : {}; + const id = parseExtensionId(identifierObj.id) ?? parseExtensionId(obj.id); + if (id === undefined) return undefined; + const version = typeof obj.version === "string" ? obj.version : undefined; + return { + id, + name: id, + version, + enabled: true, + scope: "user", + }; +} + +function parseCursorExtensionsJson(raw: string): PluginEntry[] { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + + const out: PluginEntry[] = []; + const seen = new Set(); + for (const entry of parsed) { + const plugin = parseCursorExtensionEntry(entry); + if (plugin === undefined || seen.has(plugin.id)) continue; + seen.add(plugin.id); + out.push(plugin); + } + return out; +} + +function parseCursorExtensionLine(line: string): PluginEntry | undefined { + const trimmed = line.trim(); + if (trimmed.length === 0) return undefined; + const at = trimmed.lastIndexOf("@"); + const id = at > 0 ? trimmed.slice(0, at) : trimmed; + const version = at > 0 ? trimmed.slice(at + 1) : undefined; + const parsedId = parseExtensionId(id); + if (parsedId === undefined) return undefined; + return { + id: parsedId, + name: parsedId, + version: version && version.length > 0 ? version : undefined, + enabled: true, + scope: "user", + }; +} + +/** Capture extension IDs without copying extension payloads. */ +async function captureExtensions(ctx: CaptureContext, cursorHome: string, warnings: string[]): Promise { + const extensionState = path.join(cursorHome, "extensions", "extensions.json"); + if ((await ctx.fs.statKind(extensionState)) === "file") { + try { + const fromState = parseCursorExtensionsJson(await ctx.fs.read(extensionState)); + if (fromState.length > 0) return fromState; + } catch (err) { + warnings.push(`cursor: could not read extensions metadata: ${(err as Error).message}`); + } + } + + if (!(await which(cliBinaryName("cursor")))) return []; + + try { + const result = await execa(cliBinaryName("cursor"), ["--list-extensions", "--show-versions"], { + stdin: "ignore", + stderr: "ignore", + stdout: "pipe", + reject: false, + }); + const plugins = result.stdout + .split(/\r?\n/) + .map(parseCursorExtensionLine) + .filter((plugin): plugin is PluginEntry => plugin !== undefined); + const seen = new Set(); + return plugins.filter((plugin) => { + if (seen.has(plugin.id)) return false; + seen.add(plugin.id); + return true; + }); + } catch (err) { + warnings.push(`cursor: could not list extensions via CLI: ${(err as Error).message}`); + return []; + } +} + /* -------------------------------------------------------------------------- */ /* Capture */ /* -------------------------------------------------------------------------- */ @@ -168,82 +447,39 @@ export async function capture( const manifest = emptyCursorManifest(); const p = paths(ctx.toolHome); + const userPaths = cursorUserPaths(p.home, ctx.os); const deny = denylistFor("cursor"); // Graceful absence: Cursor may not be installed at all. Never block the rest // of the backup — return an empty result with a single explanatory warning. - if ((await ctx.fs.statKind(p.home)) !== "dir") { - warnings.push("cursor: ~/.cursor not found; skipping (Cursor not installed?)."); + const hasToolHome = (await ctx.fs.statKind(p.home)) === "dir"; + const hasUserDir = (await ctx.fs.statKind(userPaths.userDir)) === "dir"; + if (!hasToolHome && !hasUserDir) { + warnings.push("cursor: no ~/.cursor or Cursor User data found; skipping (Cursor not installed?)."); return { tool: "cursor", files, symlinks, manifest, secrets, warnings }; } - for (const rel of FROZEN_PATHS) { - const abs = path.join(p.home, rel); - const relPosix = toRel(p.home, abs); - - if (matchesDeny(relPosix, deny)) { - ctx.log.debug(`cursor: skip (denylist) ${relPosix}`); - continue; - } - - const kind = await ctx.fs.statKind(abs); - if (kind === "missing") { - ctx.log.debug(`cursor: not present ${relPosix}`); - continue; - } - if (kind !== "file") { - // The minimal Cursor adapter only freezes top-level files (mcp.json). - ctx.log.debug(`cursor: skip non-file ${relPosix}`); - continue; - } - - let bytes: Buffer; - try { - bytes = await ctx.fs.readBytes(abs); - } catch (err) { - warnings.push(`cursor: could not read ${relPosix}: ${(err as Error).message}`); - continue; - } - - if (looksBinary(bytes)) { - // Unexpected for mcp.json, but stay robust: store as base64. - files.push({ repoPath: repoPathFor(relPosix), content: bytes.toString("base64"), binary: true }); - continue; + if (hasToolHome) { + for (const rel of FROZEN_PATHS) { + const abs = path.join(p.home, rel); + if (rel === "skills") { + await captureSkills(ctx, p.home, abs, deny, files, symlinks, manifest.skills, secrets, warnings); + } else { + await walkFrozen(ctx, p.home, abs, deny, repoPathFor, files, symlinks, secrets, warnings); + } } + manifest.plugins = await captureExtensions(ctx, p.home, warnings); + } - const raw = bytes.toString("utf8"); - - // For mcp.json: discover secret refs from the parsed shape BEFORE redaction, - // so we can tell the user which MCP server env/header to re-supply. - if (relPosix === "mcp.json") { - try { - secrets.push(...collectMcpSecretRefs(ctx, JSON.parse(raw), relPosix)); - } catch (err) { - warnings.push(`cursor: could not parse mcp.json for secret scan: ${(err as Error).message}`); - } + if (hasUserDir) { + const userItems = [userPaths.settingsJson, userPaths.keybindingsJson, userPaths.snippetsDir] as const; + for (const abs of userItems) { + await walkFrozen(ctx, userPaths.userDir, abs, [], userRepoPathFor, files, symlinks, secrets, warnings); } + } - // Sanitize secret VALUES (unless includeSecrets opts INTO carrying them), - // then template machine paths to placeholders. - // - // sanitizeFile routes mcp.json through the STRUCTURAL key-name-aware sanitizer - // so an opaque MCP env/header secret stored under a secret KEY NAME (e.g. - // ACME_API_KEY with a custom-shaped value) is redacted before it is stored, - // not just values matching a known token regex. - // - // When ctx.includeSecrets is ON (opt-in, default OFF), the documented switch - // carries the real MCP secret values into the PRIVATE repo (we still recorded - // the SecretRefs above so the user is told which ones are now present). - const content = ctx.includeSecrets - ? raw - : ctx.sanitizer.sanitizeFile(raw, "cursor", relPosix).content; - const templated = ctx.templater.toTemplate(content, ctx.vars); - files.push({ repoPath: repoPathFor(relPosix), content: templated }); - ctx.log.debug(`cursor: froze ${relPosix}`); - } - - if (files.length === 0) { - warnings.push("cursor: present but nothing portable to capture (no mcp.json)."); + if (files.length === 0 && symlinks.length === 0 && manifest.plugins.length === 0) { + warnings.push("cursor: present but nothing portable to capture."); } return { tool: "cursor", files, symlinks, manifest, secrets, warnings }; @@ -260,15 +496,15 @@ export async function capture( * local file (the repo only "wins" when sourceOfTruth === "repo"). */ async function writeRestoredFile(ctx: RestoreContext, file: CapturedFile): Promise { - const rel = relFromRepoPath(file.repoPath); - if (rel === undefined) { + const target = targetFromRepoPath(file.repoPath); + if (target === undefined) { ctx.log.debug(`cursor: ignoring foreign repoPath ${file.repoPath}`); return; } - const dest = path.join(ctx.toolHome, ...rel.split("/")); + const dest = targetAbsFor(ctx, target); if (ctx.sourceOfTruth === "local" && (await ctx.fs.exists(dest))) { - ctx.log.debug(`cursor: keep local ${rel} (sourceOfTruth=local)`); + ctx.log.debug(`cursor: keep local ${target.rel} (sourceOfTruth=local)`); return; } @@ -281,6 +517,23 @@ async function writeRestoredFile(ctx: RestoreContext, file: CapturedFile): Promi await ctx.fs.write(dest, expanded, file.mode); } +/** Recreate a captured symlink under ~/.cursor or Cursor User data. */ +async function writeRestoredSymlink(ctx: RestoreContext, link: CapturedSymlink): Promise { + const target = targetFromRepoPath(link.repoPath); + if (target === undefined) { + ctx.log.debug(`cursor: ignoring foreign symlink ${link.repoPath}`); + return; + } + const dest = targetAbsFor(ctx, target); + + if (ctx.sourceOfTruth === "local" && (await ctx.fs.statKind(dest)) !== "missing") { + ctx.log.debug(`cursor: keep local symlink ${target.rel} (sourceOfTruth=local)`); + return; + } + + await ctx.fs.symlink(link.target, dest); +} + /** * Deploy the shared-instructions (R9) content as a Cursor user rule. Reads * /shared/instructions.md (if present) and writes it under the Cursor @@ -301,6 +554,104 @@ async function deploySharedRule(ctx: RestoreContext): Promise { ctx.log.debug(`cursor: wrote shared-instructions rule -> ${dest}`); } +/** Best-effort Cursor extension reinstall via the `cursor` CLI. */ +async function reinstallExtensions(ctx: RestoreContext, plugins: PluginEntry[]): Promise { + const userPlugins = plugins.filter((plugin) => plugin.scope === "user"); + if (userPlugins.length === 0) return; + + const cursorBin = cliBinaryName("cursor"); + if (!(await which(cursorBin))) { + ctx.log.warn( + "cursor CLI not found; extension IDs were restored in manifest.json, but extensions were not installed.", + ); + return; + } + + for (const plugin of userPlugins) { + try { + const result = await execa(cursorBin, ["--install-extension", plugin.id], { + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + reject: false, + }); + if (result.exitCode === 0) { + ctx.log.step(`cursor: installed extension ${plugin.id}`); + } else { + const stderr = result.stderr.trim(); + ctx.log.warn( + `cursor: failed to install extension ${plugin.id}${stderr ? ` (${stderr})` : ""}`, + ); + } + } catch (err) { + ctx.log.warn(`cursor: failed to install extension ${plugin.id}: ${(err as Error).message}`); + } + } +} + +/** + * Build the list of restore actions WITHOUT executing them (for --dry-run and + * safety-backup sizing). + */ +export async function planActions( + ctx: RestoreContext, + data: RestoreData, +): Promise { + const actions: RestoreAction[] = []; + + for (const file of data.files) { + const target = targetFromRepoPath(file.repoPath); + if (target === undefined) continue; + const targetPath = targetAbsFor(ctx, target); + const overwrites = await ctx.fs.exists(targetPath); + if (ctx.sourceOfTruth === "local" && overwrites) continue; + actions.push({ + type: "write-file", + tool: "cursor", + targetPath, + description: `Write ${target.root === "user" ? "User/" : ""}${target.rel}`, + overwrites, + }); + } + + for (const link of data.symlinks) { + const target = targetFromRepoPath(link.repoPath); + if (target === undefined) continue; + const targetPath = targetAbsFor(ctx, target); + const overwrites = (await ctx.fs.statKind(targetPath)) !== "missing"; + if (ctx.sourceOfTruth === "local" && overwrites) continue; + actions.push({ + type: "write-symlink", + tool: "cursor", + targetPath, + description: `Link ${target.root === "user" ? "User/" : ""}${target.rel} -> ${link.target}`, + overwrites, + }); + } + + for (const plugin of data.manifest.plugins.filter((p) => p.scope === "user")) { + actions.push({ + type: "install-plugin", + tool: "cursor", + description: `cursor --install-extension ${plugin.id}`, + }); + } + + const sharedAbs = path.join(ctx.repoRoot, ...SHARED_INSTRUCTIONS_REPO_PATH.split("/")); + if (await ctx.fs.exists(sharedAbs)) { + const targetPath = sharedRulePath(ctx.toolHome); + actions.push({ + type: "write-file", + tool: "cursor", + targetPath, + description: "Write shared Cursor rule", + overwrites: await ctx.fs.exists(targetPath), + }); + } + + return actions; +} + /** * Restore Cursor: place frozen files back, then (if the repo carried shared * instructions) materialize the Cursor user rule. Honors ctx.dryRun (plan only). @@ -308,14 +659,8 @@ async function deploySharedRule(ctx: RestoreContext): Promise { */ export async function restore(ctx: RestoreContext, data: RestoreData): Promise { if (ctx.dryRun) { - for (const file of data.files) { - const rel = relFromRepoPath(file.repoPath); - if (rel) ctx.log.step(`cursor: would write ${path.join(ctx.toolHome, ...rel.split("/"))}`); - } - const sharedAbs = path.join(ctx.repoRoot, ...SHARED_INSTRUCTIONS_REPO_PATH.split("/")); - if (await ctx.fs.exists(sharedAbs)) { - ctx.log.step(`cursor: would write shared-instructions rule -> ${sharedRulePath(ctx.toolHome)}`); - } + const actions = await planActions(ctx, data); + for (const action of actions) ctx.log.step(action.description); return; } @@ -327,21 +672,32 @@ export async function restore(ctx: RestoreContext, data: RestoreData): Promise { - return fsExistsDir(paths().home); + const p = paths(); + return (await fsExistsDir(p.home)) || (await fsExistsDir(cursorUserPaths(p.home, detectOS()).userDir)); } /** Local lstat-free existence-as-dir probe via the default fs service. */ diff --git a/src/adapters/cursor/paths.ts b/src/adapters/cursor/paths.ts index 4550058..9ef4e64 100644 --- a/src/adapters/cursor/paths.ts +++ b/src/adapters/cursor/paths.ts @@ -10,16 +10,16 @@ * correct on win32 as well. The `repoPath` prefix, by contrast, is a POSIX-only * string used inside the backup repo and is intentionally a literal. * - * Cursor reality: Cursor is a desktop app and may be - * entirely absent (no CLI on Linux). The only globally-portable artifact is - * `~/.cursor/mcp.json` (`{ "mcpServers": { ... } }`). Project-level - * `.cursor/rules` are repo-specific and out of scope for the global backup; on - * restore, the shared-instructions (R9) content is materialized as a Cursor user - * rule under the rules dir. + * Cursor reality: Cursor is a desktop app and may be entirely absent (no CLI on + * Linux). Portable global state is split between `~/.cursor` (MCP, skills, + * rules) and the VS Code-style application User directory (settings, + * keybindings, snippets). Runtime state, projects, workspace DBs, and extension + * payloads remain out of scope. */ import path from "node:path"; +import type { OS } from "../../types.js"; import { toolHomeDir } from "../../platform/os.js"; /** Absolute path to ~/.cursor on this machine. */ @@ -33,6 +33,12 @@ export function home(): string { */ export const REPO_PREFIX = "cursor/files"; +/** + * Prefix (POSIX) for Cursor application User data. A file at + * `/X` is stored at `cursor/user/X` in the backup repo. + */ +export const USER_REPO_PREFIX = "cursor/user"; + /** Fully-resolved set of Cursor paths, all absolute. */ export interface CursorPaths { /** ~/.cursor */ @@ -45,6 +51,18 @@ export interface CursorPaths { rulesDir: string; } +/** Fully-resolved Cursor application User data paths, all absolute. */ +export interface CursorUserPaths { + /** VS Code-style Cursor User data directory. */ + userDir: string; + /** settings.json */ + settingsJson: string; + /** keybindings.json */ + keybindingsJson: string; + /** snippets directory */ + snippetsDir: string; +} + /** * Build the absolute Cursor path set. * @param overrideHome optional home dir (tests point this at a fixture); when @@ -60,17 +78,37 @@ export function paths(overrideHome?: string): CursorPaths { }; } +/** + * Build Cursor's platform-specific app User data paths from the tool home. The + * tests pass a fixture tool home; live code passes the real ~/.cursor. + */ +export function cursorUserPaths(toolHome: string, os: OS): CursorUserPaths { + const userHome = path.dirname(toolHome); + const userDir = + os === "darwin" + ? path.join(userHome, "Library", "Application Support", "Cursor", "User") + : os === "win32" + ? path.join(userHome, "AppData", "Roaming", "Cursor", "User") + : path.join(userHome, ".config", "Cursor", "User"); + + return { + userDir, + settingsJson: path.join(userDir, "settings.json"), + keybindingsJson: path.join(userDir, "keybindings.json"), + snippetsDir: path.join(userDir, "snippets"), + }; +} + /** * Files/dirs to FREEZE (copy into the repo), relative to the tool home, in - * capture order. Cursor's global, portable state is just `mcp.json`. The skills - * dir is intentionally NOT frozen here: like Claude/Codex it is a skills.sh - * mechanism (relative symlinks into ~/.agents/skills) handled centrally, and the - * minimal Cursor adapter keeps to the MCP config + R9 rule on restore. + * capture order. Cursor's `skills` dir is intentionally included: symlinked + * skills are recorded as reinstallable skills.sh entries, while local skill dirs + * are frozen just like Claude/Codex local skills. * * NOTE: anything matching the denylist is skipped during capture regardless of * its presence here. */ -export const FROZEN_PATHS: readonly string[] = ["mcp.json"] as const; +export const FROZEN_PATHS: readonly string[] = ["mcp.json", "skills"] as const; /** * Repo path (POSIX) of the shared-instructions file the backup command writes at diff --git a/src/commands/backup.ts b/src/commands/backup.ts index 23ae692..5931cca 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -90,7 +90,7 @@ import { capture as captureCursor } from "../adapters/cursor/index.js"; import type { Adapter } from "../adapters/adapter.interface.js"; /** arbella version stamped into arbella.json (kept in sync with package.json). */ -const ARBELLA_VERSION = "0.1.0"; +const ARBELLA_VERSION = "0.1.1"; /* -------------------------------------------------------------------------- */ /* Options + CLI registration */ diff --git a/src/commands/restore.ts b/src/commands/restore.ts index b778af7..212995a 100644 --- a/src/commands/restore.ts +++ b/src/commands/restore.ts @@ -97,7 +97,7 @@ import { import { claudeAdapter } from "../adapters/claude/index.js"; import { codexAdapter } from "../adapters/codex/index.js"; -import { cursorAdapter } from "../adapters/cursor/index.js"; +import { cursorAdapter, planActions as cursorPlanActions } from "../adapters/cursor/index.js"; import { planActions as claudePlanActions } from "../adapters/claude/restore.js"; import { planActions as codexPlanActions } from "../adapters/codex/restore.js"; @@ -124,9 +124,7 @@ export interface RestoreOptions { /** * Per-tool adapter + its (optional) standalone planActions exporter. The Adapter * interface itself only exposes restore(); planActions lives as a sibling module - * export for claude/codex. Cursor has no separate - * planner — its dry-run plan is derived by running restore() with dryRun=true, - * which logs the intended writes (see cursor/index.ts). We model that uniformly. + * export for the adapters that need tool-specific path mapping. */ interface ToolWiring { readonly id: ToolId; @@ -141,7 +139,7 @@ interface ToolWiring { const WIRING: readonly ToolWiring[] = [ { id: "claude", adapter: claudeAdapter, planActions: claudePlanActions }, { id: "codex", adapter: codexAdapter, planActions: codexPlanActions }, - { id: "cursor", adapter: cursorAdapter }, + { id: "cursor", adapter: cursorAdapter, planActions: cursorPlanActions }, ]; /** Look up the wiring for a tool id (every ToolId has an entry). */ @@ -563,8 +561,8 @@ function stripFilesPrefix(tool: ToolId, repoPath: string): string { } /** - * Synthesize a plan fragment for a tool that has no dedicated planActions export - * (cursor). Mirrors the planner shape used by the claude/codex restore modules: + * Synthesize a plan fragment for a tool that has no dedicated planActions export. + * Mirrors the planner shape used by adapter restore modules: * one write-file/write-symlink action per frozen artifact, honoring sourceOfTruth * (when local is authoritative, an existing destination is kept, not overwritten, * so we omit the action just as the real restore would skip it). @@ -609,8 +607,8 @@ async function fallbackActions( /** * Build the full RestorePlan: gather each tool's RestoreData, compute its planned - * actions (via the tool's planActions when available; otherwise a synthesized - * file/symlink action list — see fallbackActions), and probe which tool CLIs are + * actions (via the tool's planActions when available; otherwise a synthesized + * file/symlink action list), and probe which tool CLIs are * missing on this machine (R6). No mutation happens here. * * Actions are always computed against a dryRun context so planning is side-effect @@ -639,9 +637,8 @@ async function buildPlan(args: { os: args.os, }); - // Prefer the tool's own planner (claude/codex). For tools without one - // (cursor), synthesize file/symlink write actions from the loaded RestoreData - // so the dry-run plan is still complete and uniform. + // Prefer the tool's own planner. For tools without one, synthesize file/symlink + // write actions from RestoreData so the dry-run plan is still complete. const toolActions = wiring.planActions ? await wiring.planActions(planCtx, data) : await fallbackActions(planCtx, id, data); diff --git a/test/integration/capture-restore.test.ts b/test/integration/capture-restore.test.ts index 44b0582..2c783b4 100644 --- a/test/integration/capture-restore.test.ts +++ b/test/integration/capture-restore.test.ts @@ -39,6 +39,11 @@ import { capture as captureClaude } from "../../src/adapters/claude/capture.js"; import { restore as restoreClaude } from "../../src/adapters/claude/restore.js"; import { capture as captureCodex } from "../../src/adapters/codex/capture.js"; import { restore as restoreCodex } from "../../src/adapters/codex/restore.js"; +import { + capture as captureCursor, + restore as restoreCursor, + planActions as planCursorActions, +} from "../../src/adapters/cursor/index.js"; import { fs as realFs } from "../../src/utils/fs.js"; import { createSanitizer } from "../../src/core/sanitizer/index.js"; @@ -68,6 +73,7 @@ const CLAUDE_CRED_SECRET = "sk-ant-cred-DO-NOT-LEAK-AAAAAAAAAAAAAAAAAAAAAA"; const CODEX_AUTH_SECRET = "OPENAI_AUTH_TOKEN_DO_NOT_LEAK_BBBBBBBBBBBBBBB"; const SETTINGS_API_KEY = "sk-ant-api03-INLINE-KEY-CCCCCCCCCCCCCCCCCCCCCCCC"; const CONFIG_MCP_SECRET = "glpat-CONFIG-MCP-SECRET-DDDDDDDDDDDDD"; +const CURSOR_MCP_SECRET = "sk-ant-api03-CURSOR-MCP-SECRET-HHHHHHHHHHHHH"; // OPAQUE value under a secret KEY NAME — matches NO known token pattern. Proves // the STRUCTURAL key-name-aware capture path (sanitizeFile) redacts it; the old // value-only sanitizeText path would have leaked it verbatim into the repo. @@ -139,6 +145,7 @@ let repoRoot: string; // the backup repo working tree let claudeCapture: CaptureResult; let codexCapture: CaptureResult; +let cursorCapture: CaptureResult; let sharedFile: CapturedFile; /** Absolute, OS-correct path under a tool home from a POSIX-ish rel path. */ @@ -158,8 +165,9 @@ beforeAll(async () => { dstHome = path.join(tmpRoot, "dst-home"); repoRoot = path.join(tmpRoot, "repo"); - const claudeSrc = path.join(srcHome, ".claude"); - const codexSrc = path.join(srcHome, ".codex"); + const claudeSrc = path.join(srcHome, ".claude"); + const codexSrc = path.join(srcHome, ".codex"); + const cursorSrc = path.join(srcHome, ".cursor"); // ---- ~/.claude ---- // settings.json: an inline API key (redact) + two machine paths to template: @@ -220,13 +228,65 @@ beforeAll(async () => { await writeFile(codexSrc, "config.toml", configToml); // Byte-identical to CLAUDE.md. await writeFile(codexSrc, "AGENTS.md", SHARED_MD); - await writeFile(codexSrc, "prompts/review.md", "Review prompt body.\n"); - // FAKE SECRET FILE — excluded wholesale. - await writeFile(codexSrc, "auth.json", `{"token":"${CODEX_AUTH_SECRET}"}`, 0o600); + await writeFile(codexSrc, "prompts/review.md", "Review prompt body.\n"); + // FAKE SECRET FILE — excluded wholesale. + await writeFile(codexSrc, "auth.json", `{"token":"${CODEX_AUTH_SECRET}"}`, 0o600); + + // ---- ~/.cursor + Cursor app user data ---- + await writeFile( + cursorSrc, + "mcp.json", + JSON.stringify( + { + mcpServers: { + figma: { + command: "node", + args: [`${srcHome}/.cursor/mcp/figma.js`], + env: { + ANTHROPIC_API_KEY: CURSOR_MCP_SECRET, + }, + }, + }, + }, + null, + 2, + ), + ); + await writeFile( + cursorSrc, + "skills/local/SKILL.md", + `# Local Cursor skill\n\nReads ${srcHome}/.cursor/skills/local/data.json\n`, + ); + await writeFile(cursorSrc, "extensions/extensions.json", JSON.stringify([ + { + identifier: { id: "anysphere.cursorpyright" }, + version: "1.2.3", + }, + ])); + const cursorSkillLink = under(cursorSrc, "skills/humanizer"); + await fsp.mkdir(path.dirname(cursorSkillLink), { recursive: true }); + await fsp.symlink("../../.agents/skills/humanizer", cursorSkillLink); + const cursorUserDir = path.join(srcHome, ".config", "Cursor", "User"); + await writeFile( + cursorUserDir, + "settings.json", + JSON.stringify({ "terminal.integrated.cwd": `${srcHome}/programming` }, null, 2), + ); + await writeFile( + cursorUserDir, + "keybindings.json", + JSON.stringify([{ key: "cmd+k", command: "cursorai.action.generate" }], null, 2), + ); + await writeFile( + cursorUserDir, + "snippets/typescript.json", + JSON.stringify({ log: { prefix: "cl", body: [`console.log('${srcHome}')`] } }, null, 2), + ); // ---- Capture orchestration (mirrors the backup command's R9 wiring) ---- - const claudeVars = makeVariables(srcHome, "fab", "linux", claudeSrc); - const codexVars = makeVariables(srcHome, "fab", "linux", codexSrc); + const claudeVars = makeVariables(srcHome, "fab", "linux", claudeSrc); + const codexVars = makeVariables(srcHome, "fab", "linux", codexSrc); + const cursorVars = makeVariables(srcHome, "fab", "linux", cursorSrc); const claudeMd = SHARED_MD; const agentsMd = SHARED_MD; @@ -236,10 +296,13 @@ beforeAll(async () => { claudeCapture = await captureClaude(makeCaptureCtx(claudeSrc, claudeVars), { skipInstructions: share, }); - codexCapture = await captureCodex(makeCaptureCtx(codexSrc, codexVars), { - skipInstructions: share, - }); - sharedFile = buildSharedInstructionsFile(SHARED_MD); + codexCapture = await captureCodex(makeCaptureCtx(codexSrc, codexVars), { + skipInstructions: share, + }); + cursorCapture = await captureCursor(makeCaptureCtx(cursorSrc, cursorVars), { + skipInstructions: share, + }); + sharedFile = buildSharedInstructionsFile(SHARED_MD); }); afterAll(async () => { @@ -250,10 +313,10 @@ afterAll(async () => { /* Helpers over the captured output */ /* -------------------------------------------------------------------------- */ -/** Concatenate every captured file's content + repoPath across both tools + shared. */ +/** Concatenate every captured file's content + repoPath across all tools + shared. */ function allCapturedText(): string { const parts: string[] = []; - for (const f of [...claudeCapture.files, ...codexCapture.files, sharedFile]) { + for (const f of [...claudeCapture.files, ...codexCapture.files, ...cursorCapture.files, sharedFile]) { parts.push(f.repoPath, f.content); } return parts.join("\n"); @@ -306,8 +369,9 @@ describe("capture: secrets never leave the machine", () => { itPosixHost("redacts inline secret VALUES in settings.json and config.toml", () => { const blob = allCapturedText(); - expect(blob).not.toContain(SETTINGS_API_KEY); - expect(blob).not.toContain(CONFIG_MCP_SECRET); + expect(blob).not.toContain(SETTINGS_API_KEY); + expect(blob).not.toContain(CONFIG_MCP_SECRET); + expect(blob).not.toContain(CURSOR_MCP_SECRET); const settings = findFile(claudeCapture.files, "claude/files/settings.json"); expect(settings).toBeDefined(); @@ -345,7 +409,7 @@ describe("capture: machine paths become placeholders", () => { expect(settings.content).toContain("{{HOME}}/.agents/skills"); }); - itPosixHost("folds tool-home and plain-home paths in config.toml (value side)", () => { + itPosixHost("folds tool-home and plain-home paths in config.toml (value side)", () => { const config = findFile(codexCapture.files, "codex/files/config.toml")!; expect(config.content).not.toContain(srcHome); expect(config.content).toContain("{{HOME}}/.agents/notify.sh"); @@ -357,8 +421,57 @@ describe("capture: machine paths become placeholders", () => { expect(agent.content).not.toContain(srcHome); expect(agent.content).toContain("{{TOOL_HOME}}/agents/reviewer.log"); expect(agent.content).toContain("{{HOME}}/.agents/shared.md"); - }); -}); + }); + + itPosixHost("folds Cursor MCP, user settings, snippets, and skill paths", () => { + const mcp = findFile(cursorCapture.files, "cursor/files/mcp.json")!; + expect(mcp.content).not.toContain(srcHome); + expect(mcp.content).toContain("{{TOOL_HOME}}/mcp/figma.js"); + expect(mcp.content).toContain("{{REDACTED}}"); + + const settings = findFile(cursorCapture.files, "cursor/user/settings.json")!; + expect(settings.content).not.toContain(srcHome); + expect(settings.content).toContain("{{HOME}}/programming"); + + const snippet = findFile(cursorCapture.files, "cursor/user/snippets/typescript.json")!; + expect(snippet.content).not.toContain(srcHome); + expect(snippet.content).toContain("{{HOME}}"); + + const skill = findFile(cursorCapture.files, "cursor/files/skills/local/SKILL.md")!; + expect(skill.content).not.toContain(srcHome); + expect(skill.content).toContain("{{TOOL_HOME}}/skills/local/data.json"); + }); + }); + + describe("capture: Cursor portable state", () => { + it("captures global MCP, user data, local skills, skills.sh symlinks, and extension metadata", () => { + expect(findFile(cursorCapture.files, "cursor/files/mcp.json")).toBeDefined(); + expect(findFile(cursorCapture.files, "cursor/user/settings.json")).toBeDefined(); + expect(findFile(cursorCapture.files, "cursor/user/keybindings.json")).toBeDefined(); + expect(findFile(cursorCapture.files, "cursor/user/snippets/typescript.json")).toBeDefined(); + expect(findFile(cursorCapture.files, "cursor/files/skills/local/SKILL.md")).toBeDefined(); + + expect(cursorCapture.symlinks).toContainEqual({ + repoPath: "cursor/files/skills/humanizer", + target: "../../.agents/skills/humanizer", + }); + expect(cursorCapture.manifest.skills).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "local", source: "frozen", symlinked: false }), + expect.objectContaining({ name: "humanizer", source: "skills.sh", symlinked: true }), + ]), + ); + expect(cursorCapture.manifest.plugins).toContainEqual( + expect.objectContaining({ + id: "anysphere.cursorpyright", + name: "anysphere.cursorpyright", + version: "1.2.3", + scope: "user", + }), + ); + expect(findFile(cursorCapture.files, "cursor/files/extensions/extensions.json")).toBeUndefined(); + }); + }); describe("capture: shared instructions are deduped (R9)", () => { it("does NOT emit per-tool CLAUDE.md / AGENTS.md when sharing", () => { @@ -381,7 +494,7 @@ describe("restore: files reappear correctly in a fresh $HOME", () => { beforeAll(async () => { // Materialize the captured repo tree on disk under repoRoot. - for (const f of [...claudeCapture.files, ...codexCapture.files]) { + for (const f of [...claudeCapture.files, ...codexCapture.files, ...cursorCapture.files]) { const abs = path.join(repoRoot, ...f.repoPath.split("/")); await fsp.mkdir(path.dirname(abs), { recursive: true }); if (f.binary) { @@ -396,11 +509,13 @@ describe("restore: files reappear correctly in a fresh $HOME", () => { await fsp.writeFile(sharedAbs, sharedFile.content); // Restore target: a DIFFERENT home, so {{HOME}} must expand to dstHome. - const claudeDst = path.join(dstHome, ".claude"); - const codexDst = path.join(dstHome, ".codex"); - restoredVars = makeVariables(dstHome, "newuser", "linux"); - const claudeVars = makeVariables(dstHome, "newuser", "linux", claudeDst); - const codexVars = makeVariables(dstHome, "newuser", "linux", codexDst); + const claudeDst = path.join(dstHome, ".claude"); + const codexDst = path.join(dstHome, ".codex"); + const cursorDst = path.join(dstHome, ".cursor"); + restoredVars = makeVariables(dstHome, "newuser", "linux"); + const claudeVars = makeVariables(dstHome, "newuser", "linux", claudeDst); + const codexVars = makeVariables(dstHome, "newuser", "linux", codexDst); + const cursorVars = makeVariables(dstHome, "newuser", "linux", cursorDst); // Restore Claude frozen files + the shared instructions (-> CLAUDE.md). const claudeData: RestoreData = { @@ -425,10 +540,20 @@ describe("restore: files reappear correctly in a fresh $HOME", () => { ], symlinks: codexCapture.symlinks, }; - await restoreCodex( - makeRestoreCtx(codexDst, path.join(repoRoot, "codex"), repoRoot, codexVars), - codexData, - ); + await restoreCodex( + makeRestoreCtx(codexDst, path.join(repoRoot, "codex"), repoRoot, codexVars), + codexData, + ); + + const cursorData: RestoreData = { + manifest: cursorCapture.manifest, + files: cursorCapture.files, + symlinks: cursorCapture.symlinks, + }; + await restoreCursor( + makeRestoreCtx(cursorDst, path.join(repoRoot, "cursor"), repoRoot, cursorVars), + cursorData, + ); }); itPosixHost("re-creates settings.json with placeholders expanded to the TARGET home", async () => { @@ -464,7 +589,7 @@ describe("restore: files reappear correctly in a fresh $HOME", () => { expect(content).not.toContain(CONFIG_MCP_SECRET); }); - it("deploys the single shared instructions file to BOTH tools", async () => { + it("deploys the single shared instructions file to BOTH tools", async () => { const claudeMd = await fsp.readFile( under(path.join(dstHome, ".claude"), "CLAUDE.md"), "utf8", @@ -475,7 +600,43 @@ describe("restore: files reappear correctly in a fresh $HOME", () => { ); expect(claudeMd).toBe(SHARED_MD); expect(agentsMd).toBe(SHARED_MD); - }); + }); + + itPosixHost("restores Cursor tool-home and app User files to the correct locations", async () => { + const mcp = await fsp.readFile(under(path.join(dstHome, ".cursor"), "mcp.json"), "utf8"); + expect(mcp).toContain(`${dstHome}/.cursor/mcp/figma.js`); + expect(mcp).toContain("{{REDACTED}}"); + expect(mcp).not.toContain(CURSOR_MCP_SECRET); + + const settings = await fsp.readFile( + path.join(dstHome, ".config", "Cursor", "User", "settings.json"), + "utf8", + ); + expect(settings).toContain(`${dstHome}/programming`); + expect(settings).not.toContain("{{HOME}}"); + + const snippet = await fsp.readFile( + path.join(dstHome, ".config", "Cursor", "User", "snippets", "typescript.json"), + "utf8", + ); + expect(snippet).toContain(dstHome); + expect(snippet).not.toContain("{{HOME}}"); + + const skill = await fsp.readFile( + under(path.join(dstHome, ".cursor"), "skills/local/SKILL.md"), + "utf8", + ); + expect(skill).toContain(`${dstHome}/.cursor/skills/local/data.json`); + const linkTarget = await fsp.readlink(under(path.join(dstHome, ".cursor"), "skills/humanizer")); + expect(linkTarget).toBe("../../.agents/skills/humanizer"); + + const sharedRule = await fsp.readFile( + under(path.join(dstHome, ".cursor"), "rules/arbella-shared-instructions.mdc"), + "utf8", + ); + expect(sharedRule).toContain("alwaysApply: true"); + expect(sharedRule).toContain(SHARED_MD); + }); it("does NOT recreate any secret file in the fresh home", async () => { const credExists = await realFs.exists( @@ -489,6 +650,65 @@ describe("restore: files reappear correctly in a fresh $HOME", () => { }); }); +describe("restore: Cursor plan actions", () => { + it("plans Cursor User files outside ~/.cursor and extension installs", async () => { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), "arbella-cursor-plan-")); + try { + const home = path.join(root, "home"); + const cursorHome = path.join(home, ".cursor"); + const repo = path.join(root, "repo"); + const ctx = makeRestoreCtx( + cursorHome, + path.join(repo, "cursor"), + repo, + makeVariables(home, "newuser", "linux", cursorHome), + ); + const actions = await planCursorActions(ctx, { + manifest: { + tool: "cursor", + plugins: [ + { + id: "anysphere.cursorpyright", + name: "anysphere.cursorpyright", + version: "1.2.3", + enabled: true, + scope: "user", + }, + ], + marketplaces: [], + skills: [], + npmGlobals: [], + enabledPlugins: {}, + }, + files: [ + { + repoPath: "cursor/user/settings.json", + content: "{}", + }, + ], + symlinks: [], + }); + + expect(actions).toContainEqual( + expect.objectContaining({ + type: "write-file", + tool: "cursor", + targetPath: path.join(home, ".config", "Cursor", "User", "settings.json"), + }), + ); + expect(actions).toContainEqual( + expect.objectContaining({ + type: "install-plugin", + tool: "cursor", + description: "cursor --install-extension anysphere.cursorpyright", + }), + ); + } finally { + await fsp.rm(root, { recursive: true, force: true }); + } + }); +}); + /* -------------------------------------------------------------------------- */ /* includeSecrets flag is actually CONSUMED by capture (not dead config) */ /* -------------------------------------------------------------------------- */ From f394d811fdf21421ea461b8efeee3fc2ede4ea0c Mon Sep 17 00:00:00 2001 From: Fafo Date: Sun, 31 May 2026 19:06:29 +0200 Subject: [PATCH 2/7] Harden npm publishing workflow --- .github/workflows/publish.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8ac7b69..a5dd43b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -24,6 +24,9 @@ jobs: node-version: 24 registry-url: https://registry.npmjs.org + - name: Use recent npm + run: npm install -g npm@latest + - name: Install dependencies run: npm ci @@ -40,4 +43,4 @@ jobs: run: npm run prepublishOnly - name: Publish to npm - run: npm publish + run: npm publish --provenance From a30e840e06dce48e3ddcdc40750b50d45633a630 Mon Sep 17 00:00:00 2001 From: Fafo Date: Sun, 31 May 2026 19:24:41 +0200 Subject: [PATCH 3/7] Fix Cursor restore paths --- README.md | 2 +- src/adapters/cursor/index.ts | 59 ++--------- src/adapters/cursor/paths.ts | 22 ---- src/commands/backup.ts | 41 +++++--- src/commands/restore.ts | 119 ++++++++++++++-------- src/core/manifest/index.ts | 5 +- src/core/sanitizer/index.ts | 123 +++++++++++++++++++++-- test/integration/capture-restore.test.ts | 36 +++---- test/unit/sanitizer.test.ts | 29 +++++- 9 files changed, 277 insertions(+), 159 deletions(-) diff --git a/README.md b/README.md index ea89d64..0c6242a 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ arbella --help pull private repo ──▶ install · place ──▶ tools + Cursor User data ``` -`push` reads the parts of your setup that matter, strips anything secret, swaps machine-specific paths for placeholders, and pushes the result to your repo. `pull` does the reverse: installs the CLIs you don't have, drops files back with this machine's paths, reinstalls plugins and skills, and wires your shared instructions into the restored tools. +`push` reads the parts of your setup that matter, strips anything secret, swaps machine-specific paths for placeholders, and pushes the result to your repo. `pull` does the reverse: installs the CLIs you don't have, drops files back with this machine's paths, reinstalls plugins and skills, and wires shared Claude/Codex instructions back into `CLAUDE.md` and `AGENTS.md`. Two things it deliberately won't do: diff --git a/src/adapters/cursor/index.ts b/src/adapters/cursor/index.ts index 9701d7a..1713370 100644 --- a/src/adapters/cursor/index.ts +++ b/src/adapters/cursor/index.ts @@ -19,8 +19,7 @@ * - restore(ctx,d): writes those files back onto ~/.cursor or Cursor's User * data dir, rehydrating {{TOKENS}} to this machine's paths. * It also recreates skill symlinks, best-effort installs - * extensions via `cursor --install-extension`, and deploys the - * shared instructions rule when present. + * extensions via `cursor --install-extension`. * * All fs work goes through the injected CoreServices on the context objects, so * the adapter is unit-testable against a fixture dir. No direct node:fs, no clock. @@ -51,11 +50,9 @@ import { runInstall, which } from "../../platform/install.js"; import { FROZEN_PATHS, REPO_PREFIX, - SHARED_INSTRUCTIONS_REPO_PATH, USER_REPO_PREFIX, cursorUserPaths, paths, - sharedRulePath, } from "./paths.js"; /* -------------------------------------------------------------------------- */ @@ -217,7 +214,12 @@ async function captureFile(args: { } } - const content = ctx.includeSecrets ? raw : ctx.sanitizer.sanitizeFile(raw, "cursor", rel).content; + let content = raw; + if (!ctx.includeSecrets) { + const sanitized = ctx.sanitizer.sanitizeFile(raw, "cursor", rel); + content = sanitized.content; + secrets.push(...sanitized.found); + } const templated = ctx.templater.toTemplate(content, ctx.vars); const file: CapturedFile = { repoPath, content: templated }; if (mode !== undefined) file.mode = mode; @@ -474,7 +476,7 @@ export async function capture( if (hasUserDir) { const userItems = [userPaths.settingsJson, userPaths.keybindingsJson, userPaths.snippetsDir] as const; for (const abs of userItems) { - await walkFrozen(ctx, userPaths.userDir, abs, [], userRepoPathFor, files, symlinks, secrets, warnings); + await walkFrozen(ctx, userPaths.userDir, abs, deny, userRepoPathFor, files, symlinks, secrets, warnings); } } @@ -534,26 +536,6 @@ async function writeRestoredSymlink(ctx: RestoreContext, link: CapturedSymlink): await ctx.fs.symlink(link.target, dest); } -/** - * Deploy the shared-instructions (R9) content as a Cursor user rule. Reads - * /shared/instructions.md (if present) and writes it under the Cursor - * rules dir. Best-effort: absence of the shared file is not an error. - */ -async function deploySharedRule(ctx: RestoreContext): Promise { - const sharedAbs = path.join(ctx.repoRoot, ...SHARED_INSTRUCTIONS_REPO_PATH.split("/")); - if (!(await ctx.fs.exists(sharedAbs))) { - ctx.log.debug("cursor: no shared/instructions.md to deploy as a Cursor rule."); - return; - } - const body = await ctx.fs.read(sharedAbs); - // Cursor `.mdc` rules support frontmatter; `alwaysApply: true` makes the rule - // global. The instructions content is plain markdown and needs no templating. - const rule = `---\ndescription: Shared agent instructions (managed by arbella)\nalwaysApply: true\n---\n\n${body}`; - const dest = sharedRulePath(ctx.toolHome); - await ctx.fs.write(dest, rule); - ctx.log.debug(`cursor: wrote shared-instructions rule -> ${dest}`); -} - /** Best-effort Cursor extension reinstall via the `cursor` CLI. */ async function reinstallExtensions(ctx: RestoreContext, plugins: PluginEntry[]): Promise { const userPlugins = plugins.filter((plugin) => plugin.scope === "user"); @@ -637,25 +619,13 @@ export async function planActions( }); } - const sharedAbs = path.join(ctx.repoRoot, ...SHARED_INSTRUCTIONS_REPO_PATH.split("/")); - if (await ctx.fs.exists(sharedAbs)) { - const targetPath = sharedRulePath(ctx.toolHome); - actions.push({ - type: "write-file", - tool: "cursor", - targetPath, - description: "Write shared Cursor rule", - overwrites: await ctx.fs.exists(targetPath), - }); - } - return actions; } /** - * Restore Cursor: place frozen files back, then (if the repo carried shared - * instructions) materialize the Cursor user rule. Honors ctx.dryRun (plan only). - * Exported for direct use by the restore command + the Adapter wrapper below. + * Restore Cursor: place frozen files/User data back, recreate symlinks, and + * best-effort install extensions. Honors ctx.dryRun (plan only). Exported for + * direct use by the restore command + the Adapter wrapper below. */ export async function restore(ctx: RestoreContext, data: RestoreData): Promise { if (ctx.dryRun) { @@ -680,13 +650,6 @@ export async function restore(ctx: RestoreContext, data: RestoreData): Promise/files` first makes the backup a true - * mirror: files deleted locally disappear from the repo on the next backup. + * + symlinks + manifest. Removing each owned repo data root first makes the + * backup a true mirror: files deleted locally disappear from the repo on the next + * backup. Cursor owns an extra `cursor/user` root for application User data. * * NOTE: memories (when included) are emitted by the codex adapter under * `codex/files/memories/...`, so they live inside the same subtree and are * covered by this replace. */ async function replaceToolFiles(repoRoot: string, result: CaptureResult): Promise { - const filesDir = repoJoin(repoRoot, toolFilesPrefix(result.tool)); - await fs.rmrf(filesDir); + for (const root of toolRepoDataRoots(result.tool)) { + await fs.rmrf(repoJoin(repoRoot, root)); + } for (const file of result.files) { await writeCapturedFile(repoRoot, file); @@ -432,6 +434,12 @@ async function replaceToolFiles(repoRoot: string, result: CaptureResult): Promis await fs.write(manifestPath, serialize(result.manifest)); } +function toolRepoDataRoots(tool: ToolId): string[] { + const roots = [toolFilesPrefix(tool)]; + if (tool === "cursor") roots.push("cursor/user"); + return roots; +} + /** * Write a single CapturedFile into the repo working tree. Handles the text vs. * base64-binary distinction and preserves an explicit file mode (e.g. 0o755 for @@ -482,9 +490,13 @@ function renderRepoReadme( generatedAtIso: string, ): string { const toolList = meta.tools.map((t) => `- ${toolLabel(t)} (\`${t}\`)`).join("\n"); + const cursorUserLine = meta.tools.includes("cursor") + ? "- `cursor/user/…` — Cursor application User data such as settings,\n" + + " keybindings, and snippets.\n" + : ""; const sharedLine = meta.sharedInstructions ? "Your `CLAUDE.md` and `AGENTS.md` were identical and are stored once in " + - "[`shared/instructions.md`](shared/instructions.md); restore deploys it to both tools.\n" + "[`shared/instructions.md`](shared/instructions.md); restore deploys it to Claude Code and Codex.\n" : ""; return `# arbella backup @@ -506,6 +518,7 @@ Each tool lives under \`/\`: - \`/files/…\` — frozen config files (paths replaced with \`{{HOME}}\`-style placeholders, secret values redacted). +${cursorUserLine} - \`/manifest.json\` — what to reinstall (plugins, marketplaces, skills, npm globals) and which plugins to re-enable. @@ -520,7 +533,7 @@ npm install -g arbella arbella pull \`\`\` -arbella will (R6/R14) take a timestamped safety copy of any existing tool homes, +arbella will (R6/R14) take a timestamped safety copy of any existing restore targets, auto-install missing CLIs, write the frozen files back (re-expanding placeholders to this machine's paths), reinstall plugins/marketplaces/skills, and re-enable plugins. @@ -587,19 +600,21 @@ function renderRepoGitignore(tools: ToolId[]): string { ]; for (const name of secretBasenames) lines.push(name); - // Scope each tool's denylist directory patterns under /files/ so noise - // dirs (sessions/, cache/, …) cannot sneak in if someone copies a raw home in. - lines.push("", "# Per-tool excluded directories (scoped under /files/)"); + // Scope each tool's denylist directory patterns under owned data roots so noise + // dirs (sessions/, cache/, …) cannot sneak in if someone copies raw data in. + lines.push("", "# Per-tool excluded directories (scoped under owned data roots)"); const seen = new Set(); for (const tool of tools) { for (const pattern of denylistFor(tool)) { // Only directory patterns are useful to scope here; loose globs are already // covered by the universal rules above. if (!pattern.endsWith("/")) continue; - const scoped = `${tool}/files/${pattern}`; - if (seen.has(scoped)) continue; - seen.add(scoped); - lines.push(scoped); + for (const root of toolRepoDataRoots(tool)) { + const scoped = `${root}/${pattern}`; + if (seen.has(scoped)) continue; + seen.add(scoped); + lines.push(scoped); + } } } diff --git a/src/commands/restore.ts b/src/commands/restore.ts index 212995a..8b67a41 100644 --- a/src/commands/restore.ts +++ b/src/commands/restore.ts @@ -20,10 +20,9 @@ * and run each adapter's restore() (which places files, recreates symlinks, * reinstalls plugins/marketplaces/skills, re-enables plugins, installs npm * globals — all guarded + best-effort per adapter). - * 7. Deploy the shared instructions (R9) to BOTH tools when meta.sharedInstructions + * 7. Deploy the shared instructions (R9) to Claude + Codex when meta.sharedInstructions * is set: write shared/instructions.md to each sharedInstructionsTargets() - * destination; the cursor adapter additionally writes a Cursor rule from the - * same source inside its own restore(). + * destination. * 8. Print a re-auth reminder: secret files (.credentials.json / auth.json) were * NEVER carried, so the user must re-login. Never prints any secret value. * @@ -98,6 +97,7 @@ import { import { claudeAdapter } from "../adapters/claude/index.js"; import { codexAdapter } from "../adapters/codex/index.js"; import { cursorAdapter, planActions as cursorPlanActions } from "../adapters/cursor/index.js"; +import { cursorUserPaths } from "../adapters/cursor/paths.js"; import { planActions as claudePlanActions } from "../adapters/claude/restore.js"; import { planActions as codexPlanActions } from "../adapters/codex/restore.js"; @@ -208,10 +208,10 @@ function looksBinary(buf: Buffer): boolean { } /** - * Recursively walk `/files` inside the cloned repo and reconstruct the + * Recursively walk frozen roots inside the cloned repo and reconstruct the * CapturedFile[] / CapturedSymlink[] the adapter expects. `repoPath` is rebuilt - * with the canonical "/files/" prefix (POSIX separators) so the - * adapter's stripPrefix() maps it back onto the target tool home. + * with the root's canonical prefix (POSIX separators), e.g. "claude/files/..." + * or "cursor/user/...". * * Symlinks captured under skills/ are preserved verbatim (their target is the * data). Regular files are classified binary vs. text on read. Missing dirs are @@ -221,17 +221,12 @@ async function readToolFrozen( repoToolDir: string, tool: ToolId, ): Promise<{ files: CapturedFile[]; symlinks: CapturedSymlink[] }> { - const filesRoot = path.join(repoToolDir, "files"); const files: CapturedFile[] = []; const symlinks: CapturedSymlink[] = []; + const roots = frozenRootsForTool(repoToolDir, tool); - // Nothing frozen for this tool (e.g. cursor absent at capture) -> empty. - if ((await fs.statKind(filesRoot)) !== "dir") { - return { files, symlinks }; - } - - /** relParts: POSIX path segments under filesRoot accumulated so far. */ - async function walk(absDir: string, relParts: string[]): Promise { + /** relParts: POSIX path segments under the frozen root accumulated so far. */ + async function walk(absDir: string, relParts: string[], repoPrefix: string): Promise { const entries = await fs.list(absDir); // Stable order so dry-run output and writes are deterministic. entries.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); @@ -240,7 +235,7 @@ async function readToolFrozen( const abs = path.join(absDir, name); const nextRel = [...relParts, name]; const relPosix = nextRel.join("/"); - const repoPath = `${tool}/files/${relPosix}`; + const repoPath = `${repoPrefix}/${relPosix}`; const kind = await fs.statKind(abs); if (kind === "symlink") { @@ -258,7 +253,7 @@ async function readToolFrozen( } if (kind === "dir") { - await walk(abs, nextRel); + await walk(abs, nextRel, repoPrefix); continue; } @@ -293,10 +288,26 @@ async function readToolFrozen( } } - await walk(filesRoot, []); + for (const root of roots) { + if ((await fs.statKind(root.absRoot)) !== "dir") continue; + await walk(root.absRoot, [], root.repoPrefix); + } return { files, symlinks }; } +function frozenRootsForTool( + repoToolDir: string, + tool: ToolId, +): Array<{ absRoot: string; repoPrefix: string }> { + const roots = [ + { absRoot: path.join(repoToolDir, "files"), repoPrefix: `${tool}/files` }, + ]; + if (tool === "cursor") { + roots.push({ absRoot: path.join(repoToolDir, "user"), repoPrefix: "cursor/user" }); + } + return roots; +} + /** * Read a tool's manifest.json from //manifest.json and validate * it. A missing manifest is tolerated (returns an empty manifest) so a tool that @@ -327,7 +338,7 @@ async function readToolManifest( } /** Assemble the full RestoreData (manifest + frozen files/symlinks) for a tool. */ -async function loadRestoreData( +export async function loadRestoreData( repoRoot: string, tool: ToolId, ): Promise { @@ -391,7 +402,7 @@ interface SafetyBackup { } /** - * Make a timestamped safety copy of each existing target tool home BEFORE any + * Make a timestamped safety copy of each existing restore target BEFORE any * restore mutation (R14). Destinations live under dataDir()/safety-backups so * they never pollute a tool home. Only existing homes are copied; absent ones * are skipped. `iso` is supplied by the caller (commands own the clock). @@ -401,33 +412,59 @@ interface SafetyBackup { async function safetyBackup( tools: ToolId[], iso: string, + os: OS, ): Promise { const stamp = iso.replace(/[:.]/g, "-"); - const backupsRoot = path.join(dataDir(), "safety-backups"); const made: SafetyBackup[] = []; for (const tool of tools) { - const source = toolHomeDir(tool); - if (!(await fs.exists(source))) { - log.debug(`restore: no existing ${tool} home to back up (${source}).`); - continue; - } - const dest = path.join(backupsRoot, `${tool}-${stamp}`); - try { - await fs.copy(source, dest); - made.push({ tool, source, dest }); - log.step(`Safety backup: ${source} -> ${dest}`); - } catch (err) { - // A failed safety copy is serious: warn loudly but do not abort the whole - // restore over a single tool's snapshot (the others still get protected). - log.warn( - `restore: failed to safety-backup ${tool} (${source}): ${errMsg(err)}`, - ); + for (const entry of safetySourcesForTool(tool, os, stamp)) { + if (!(await fs.exists(entry.source))) { + log.debug(`restore: no existing ${entry.label} to back up (${entry.source}).`); + continue; + } + try { + await fs.copy(entry.source, entry.dest); + made.push({ tool, source: entry.source, dest: entry.dest }); + log.step(`Safety backup: ${entry.source} -> ${entry.dest}`); + } catch (err) { + // A failed safety copy is serious: warn loudly but do not abort the whole + // restore over a single target's snapshot (the others still get protected). + log.warn( + `restore: failed to safety-backup ${entry.label} (${entry.source}): ${errMsg(err)}`, + ); + } } } return made; } +function safetySourcesForTool( + tool: ToolId, + os: OS, + stamp: string, +): Array<{ label: string; source: string; dest: string }> { + const backupsRoot = path.join(dataDir(), "safety-backups"); + const toolHome = toolHomeDir(tool); + const sources = [ + { + label: `${tool} home`, + source: toolHome, + dest: path.join(backupsRoot, `${tool}-${stamp}`), + }, + ]; + + if (tool === "cursor") { + sources.push({ + label: "cursor User data", + source: cursorUserPaths(toolHome, os).userDir, + dest: path.join(backupsRoot, `cursor-user-${stamp}`), + }); + } + + return sources; +} + /* -------------------------------------------------------------------------- */ /* Shared instructions (R9) deployment */ /* -------------------------------------------------------------------------- */ @@ -435,9 +472,9 @@ async function safetyBackup( /** * When the repo stored a single shared instructions file (CLAUDE.md == AGENTS.md * at capture, R9), deploy it to BOTH tool targets: ~/.claude/CLAUDE.md and - * ~/.codex/AGENTS.md (from sharedInstructionsTargets()). The cursor adapter - * deploys its own Cursor rule from the same source inside its restore(), so it - * is intentionally not handled here. + * ~/.codex/AGENTS.md (from sharedInstructionsTargets()). Cursor User Rules are + * stored in Cursor settings, not as global `.mdc` files, so restore does not + * synthesize a Cursor rule here. * * Respects `dryRun` (reports only). Best-effort + graceful: a missing shared * file is logged, not fatal. Only deploys to a target whose tool is in scope. @@ -683,7 +720,7 @@ function printPlan( `Tools: ${plan.tools.length > 0 ? plan.tools.join(", ") : "(none)"}`, ); l.step( - `A timestamped safety backup of existing tool homes WILL be taken first (R14).`, + `A timestamped safety backup of existing restore targets WILL be taken first (R14).`, ); if (plan.missingClis.length > 0) { l.step(`CLIs to auto-install: ${plan.missingClis.join(", ")}`); @@ -937,7 +974,7 @@ export async function run( // ---- 6. R14 safety backup BEFORE any overwrite -------------------------- const iso = new Date().toISOString(); log.info("Creating safety backups of existing tool homes (R14)…"); - const backups = await safetyBackup(tools, iso); + const backups = await safetyBackup(tools, iso, os); if (backups.length === 0) { log.debug("restore: no existing tool homes needed backing up."); } diff --git a/src/core/manifest/index.ts b/src/core/manifest/index.ts index 0e753df..45dc999 100644 --- a/src/core/manifest/index.ts +++ b/src/core/manifest/index.ts @@ -287,9 +287,8 @@ export function buildSharedInstructionsFile(content: string): CapturedFile { /** * The destinations the single shared instructions file deploys to on restore, * expressed relative to each tool's home dir. Claude reads CLAUDE.md; Codex reads - * AGENTS.md. (Cursor rule deployment is handled by the cursor adapter, which - * reads shared/instructions.md from the repo root directly — it is intentionally - * not listed here because its destination is not a fixed tool-home-relative path.) + * AGENTS.md. Cursor User Rules live in Cursor settings, so Arbella does not + * synthesize a global Cursor rule from this file. */ export function sharedInstructionsTargets(): Array<{ tool: ToolId; relPath: string }> { return [ diff --git a/src/core/sanitizer/index.ts b/src/core/sanitizer/index.ts index 7eb92fe..d448825 100644 --- a/src/core/sanitizer/index.ts +++ b/src/core/sanitizer/index.ts @@ -141,11 +141,11 @@ export function createSanitizer(): SanitizerService { * they reach the repo — the gap that sanitizeText (value-patterns only) leaves * open. Behavior: * - * - If `source` looks like JSON (".json" basename) AND JSON.parse succeeds: - * run sanitizeJson on the parsed object, then re-serialize with the file's - * detected indentation. Returns changed=true iff the serialized output - * differs from the input (so callers/reporting see a real redaction). - * - Otherwise (non-JSON, or JSON that fails to parse): fall back to the + * - If `source` looks like JSON (".json" basename) AND JSON/JSONC parsing + * succeeds: run sanitizeJson on the parsed object, then re-serialize with + * the file's detected indentation. Comments/trailing commas are not + * preserved, but opaque secrets under secret key names are redacted. + * - Otherwise (non-JSON, or JSON/JSONC parsing fails): fall back to the * pattern-based sanitizeText (which still catches known token shapes and * the key-name-aware env-assignment pattern in free text / hook scripts). * @@ -154,14 +154,12 @@ export function createSanitizer(): SanitizerService { */ function sanitizeFile(content: string, tool: ToolId, source: string): SanitizeResult { if (looksLikeJsonSource(source)) { - let parsed: unknown; - try { - parsed = JSON.parse(content); - } catch { + const parsed = parseJsonOrJsonc(content); + if (!parsed.ok) { // Not valid JSON despite the extension — fall back to the text pass. return sanitizeText(content, tool, source); } - const { value, found } = sanitizeJson(parsed, tool, source); + const { value, found } = sanitizeJson(parsed.value, tool, source); const indent = detectJsonIndent(content); const serialized = JSON.stringify(value, null, indent); return { content: serialized, found, changed: serialized !== content }; @@ -182,6 +180,111 @@ function looksLikeJsonSource(source: string): boolean { return /\.json$/i.test(base); } +function parseJsonOrJsonc(content: string): { ok: true; value: unknown } | { ok: false } { + try { + return { ok: true, value: JSON.parse(content) }; + } catch { + // Cursor and VS Code-style settings files are commonly JSONC. + } + + try { + return { ok: true, value: JSON.parse(stripJsonc(content)) }; + } catch { + return { ok: false }; + } +} + +function stripJsonc(content: string): string { + return removeTrailingCommas(removeJsonComments(content)); +} + +function removeJsonComments(content: string): string { + let out = ""; + let inString = false; + let escaped = false; + + for (let i = 0; i < content.length; i++) { + const ch = content[i]!; + const next = content[i + 1]; + + if (inString) { + out += ch; + if (escaped) { + escaped = false; + } else if (ch === "\\") { + escaped = true; + } else if (ch === "\"") { + inString = false; + } + continue; + } + + if (ch === "\"") { + inString = true; + out += ch; + continue; + } + + if (ch === "/" && next === "/") { + while (i < content.length && content[i] !== "\n") i++; + if (i < content.length) out += "\n"; + continue; + } + + if (ch === "/" && next === "*") { + i += 2; + while (i < content.length && !(content[i] === "*" && content[i + 1] === "/")) { + if (content[i] === "\n") out += "\n"; + i++; + } + i++; + continue; + } + + out += ch; + } + + return out; +} + +function removeTrailingCommas(content: string): string { + let out = ""; + let inString = false; + let escaped = false; + + for (let i = 0; i < content.length; i++) { + const ch = content[i]!; + + if (inString) { + out += ch; + if (escaped) { + escaped = false; + } else if (ch === "\\") { + escaped = true; + } else if (ch === "\"") { + inString = false; + } + continue; + } + + if (ch === "\"") { + inString = true; + out += ch; + continue; + } + + if (ch === ",") { + let j = i + 1; + while (j < content.length && /\s/.test(content[j]!)) j++; + if (content[j] === "}" || content[j] === "]") continue; + } + + out += ch; + } + + return out; +} + /** * Best-effort detection of a JSON document's indentation so re-serialization * preserves the file's style. Looks at the whitespace before the first indented diff --git a/test/integration/capture-restore.test.ts b/test/integration/capture-restore.test.ts index 2c783b4..e53ea9e 100644 --- a/test/integration/capture-restore.test.ts +++ b/test/integration/capture-restore.test.ts @@ -49,6 +49,7 @@ import { fs as realFs } from "../../src/utils/fs.js"; import { createSanitizer } from "../../src/core/sanitizer/index.js"; import { createTemplater } from "../../src/core/templater/index.js"; import { makeVariables } from "../../src/core/templater/variables.js"; +import { loadRestoreData } from "../../src/commands/restore.js"; import { shouldShareInstructions, buildSharedInstructionsFile, @@ -282,6 +283,7 @@ beforeAll(async () => { "snippets/typescript.json", JSON.stringify({ log: { prefix: "cl", body: [`console.log('${srcHome}')`] } }, null, 2), ); + await writeFile(cursorUserDir, "snippets/cache.sqlite", "sqlite cache should be ignored\n"); // ---- Capture orchestration (mirrors the backup command's R9 wiring) ---- const claudeVars = makeVariables(srcHome, "fab", "linux", claudeSrc); @@ -449,6 +451,7 @@ describe("capture: machine paths become placeholders", () => { expect(findFile(cursorCapture.files, "cursor/user/settings.json")).toBeDefined(); expect(findFile(cursorCapture.files, "cursor/user/keybindings.json")).toBeDefined(); expect(findFile(cursorCapture.files, "cursor/user/snippets/typescript.json")).toBeDefined(); + expect(findFile(cursorCapture.files, "cursor/user/snippets/cache.sqlite")).toBeUndefined(); expect(findFile(cursorCapture.files, "cursor/files/skills/local/SKILL.md")).toBeDefined(); expect(cursorCapture.symlinks).toContainEqual({ @@ -495,14 +498,19 @@ describe("restore: files reappear correctly in a fresh $HOME", () => { beforeAll(async () => { // Materialize the captured repo tree on disk under repoRoot. for (const f of [...claudeCapture.files, ...codexCapture.files, ...cursorCapture.files]) { - const abs = path.join(repoRoot, ...f.repoPath.split("/")); - await fsp.mkdir(path.dirname(abs), { recursive: true }); - if (f.binary) { - await fsp.writeFile(abs, Buffer.from(f.content, "base64")); - } else { - await fsp.writeFile(abs, f.content); - } - } + const abs = path.join(repoRoot, ...f.repoPath.split("/")); + await fsp.mkdir(path.dirname(abs), { recursive: true }); + if (f.binary) { + await fsp.writeFile(abs, Buffer.from(f.content, "base64")); + } else { + await fsp.writeFile(abs, f.content); + } + } + for (const link of [...claudeCapture.symlinks, ...codexCapture.symlinks, ...cursorCapture.symlinks]) { + const abs = path.join(repoRoot, ...link.repoPath.split("/")); + await fsp.mkdir(path.dirname(abs), { recursive: true }); + await fsp.symlink(link.target, abs); + } // Write the single shared instructions file into the repo. const sharedAbs = path.join(repoRoot, ...sharedFile.repoPath.split("/")); await fsp.mkdir(path.dirname(sharedAbs), { recursive: true }); @@ -545,11 +553,7 @@ describe("restore: files reappear correctly in a fresh $HOME", () => { codexData, ); - const cursorData: RestoreData = { - manifest: cursorCapture.manifest, - files: cursorCapture.files, - symlinks: cursorCapture.symlinks, - }; + const cursorData = await loadRestoreData(repoRoot, "cursor"); await restoreCursor( makeRestoreCtx(cursorDst, path.join(repoRoot, "cursor"), repoRoot, cursorVars), cursorData, @@ -630,12 +634,10 @@ describe("restore: files reappear correctly in a fresh $HOME", () => { const linkTarget = await fsp.readlink(under(path.join(dstHome, ".cursor"), "skills/humanizer")); expect(linkTarget).toBe("../../.agents/skills/humanizer"); - const sharedRule = await fsp.readFile( + const sharedRuleExists = await realFs.exists( under(path.join(dstHome, ".cursor"), "rules/arbella-shared-instructions.mdc"), - "utf8", ); - expect(sharedRule).toContain("alwaysApply: true"); - expect(sharedRule).toContain(SHARED_MD); + expect(sharedRuleExists).toBe(false); }); it("does NOT recreate any secret file in the fresh home", async () => { diff --git a/test/unit/sanitizer.test.ts b/test/unit/sanitizer.test.ts index 56c67d4..6156b48 100644 --- a/test/unit/sanitizer.test.ts +++ b/test/unit/sanitizer.test.ts @@ -172,7 +172,7 @@ describe("sanitizer: sanitizeFile is the key-name-aware production path (§0.6)" expect(textOnly.content).toContain("corp-gateway-OPAQUE-7766aabb"); }); - it("redacts an opaque api_token / apiKeyHelper value in a JSON config", () => { + it("redacts an opaque api_token / apiKeyHelper value in a JSON config", () => { const blob = JSON.stringify( { api_token: "plain-opaque-no-prefix-001122", apiKeyHelper: "echo my-opaque-zzz999" }, null, @@ -181,9 +181,30 @@ describe("sanitizer: sanitizeFile is the key-name-aware production path (§0.6)" const res = sanitizer.sanitizeFile(blob, "cursor", "mcp.json"); expect(res.content).not.toContain("plain-opaque-no-prefix-001122"); expect(res.content).not.toContain("my-opaque-zzz999"); - }); - - it("falls back to the text pass for non-JSON content (hook scripts)", () => { + }); + + it("redacts opaque secret-key values in JSONC Cursor settings", () => { + const jsonc = [ + "{", + " // Cursor settings allow comments", + " \"mcpServers\": {", + " \"internal\": {", + " \"env\": {", + " \"ANTHROPIC_AUTH_TOKEN\": \"plain-opaque-jsonc-secret\"", + " },", + " },", + " },", + "}", + ].join("\n"); + + const res = sanitizer.sanitizeFile(jsonc, "cursor", "settings.json"); + expect(res.changed).toBe(true); + expect(res.content).not.toContain("plain-opaque-jsonc-secret"); + expect(res.content).toContain(REDACTED); + expect(res.found.some((ref) => ref.source.includes("ANTHROPIC_AUTH_TOKEN"))).toBe(true); + }); + + it("falls back to the text pass for non-JSON content (hook scripts)", () => { const hook = 'export ANTHROPIC_API_KEY="sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAA"'; const res = sanitizer.sanitizeFile(hook, "claude", "hooks/run.sh"); expect(res.content).toContain(REDACTED); From eb1c2bf2b7d93a841436cacc3e12196beb48b824 Mon Sep 17 00:00:00 2001 From: Fafo Date: Sun, 31 May 2026 19:44:44 +0200 Subject: [PATCH 4/7] Read package version from package metadata --- src/commands/backup.ts | 5 +++-- src/core/version.ts | 40 ++++++++++++++++++++++++++++++++++++++ src/index.ts | 10 +++++----- test/unit/cli-help.test.ts | 12 ++++++++++++ 4 files changed, 60 insertions(+), 7 deletions(-) create mode 100644 src/core/version.ts diff --git a/src/commands/backup.ts b/src/commands/backup.ts index cc56fcd..fcf23cf 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -88,9 +88,10 @@ import { capture as captureCodex } from "../adapters/codex/capture.js"; import { capture as captureCursor } from "../adapters/cursor/index.js"; import type { Adapter } from "../adapters/adapter.interface.js"; +import { getPackageVersion } from "../core/version.js"; -/** arbella version stamped into arbella.json (kept in sync with package.json). */ -const ARBELLA_VERSION = "0.1.1"; +/** arbella version stamped into arbella.json. */ +const ARBELLA_VERSION = getPackageVersion(); /* -------------------------------------------------------------------------- */ /* Options + CLI registration */ diff --git a/src/core/version.ts b/src/core/version.ts new file mode 100644 index 0000000..7c61052 --- /dev/null +++ b/src/core/version.ts @@ -0,0 +1,40 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +let cachedVersion: string | undefined; + +/** + * Package version used by the CLI and backup metadata. + * + * tsup bundles source modules into dist/index.js, so import.meta.url points at + * different depths in dev (`src/core/version.ts`) and in the packed CLI + * (`dist/index.js`). Try both stable locations and fall back only if package.json + * is unexpectedly unavailable. + */ +export function getPackageVersion(): string { + if (cachedVersion !== undefined) return cachedVersion; + + const here = path.dirname(fileURLToPath(import.meta.url)); + const candidates = [ + path.join(here, "..", "package.json"), + path.join(here, "..", "..", "package.json"), + ]; + + for (const packagePath of candidates) { + try { + const parsed = JSON.parse(fs.readFileSync(packagePath, "utf8")) as { + version?: unknown; + }; + if (typeof parsed.version === "string" && parsed.version.trim() !== "") { + cachedVersion = parsed.version; + return cachedVersion; + } + } catch { + // Try the next candidate. + } + } + + cachedVersion = "0.0.0"; + return cachedVersion; +} diff --git a/src/index.ts b/src/index.ts index 25ff2cc..4f14ea2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,9 +19,8 @@ * top-level handler (log.error + exit 1) so stack traces never leak to the * user. Commander's own --help / --version exits are passed through. * - * VERSION: hard-coded here and kept in sync with package.json. The contract - * explicitly prefers this over a JSON import, because rootDir:"src" puts - * package.json outside the compile root and would otherwise fight tsup/NodeNext. + * VERSION: read from package.json via core/version.ts so release bumps do not + * drift between the published package and `arbella --version`. */ import fs from "node:fs"; @@ -33,6 +32,7 @@ import pc from "picocolors"; import { log, setVerbose } from "./utils/log.js"; import { listAdapters } from "./adapters/registry.js"; +import { getPackageVersion } from "./core/version.js"; import { register as registerInit } from "./commands/init.js"; import { register as registerSetup } from "./commands/setup.js"; @@ -42,8 +42,8 @@ import { register as registerRestore } from "./commands/restore.js"; import { register as registerStatus } from "./commands/status.js"; import { register as registerSecrets } from "./commands/secrets.js"; -/** arbella version, kept in sync with package.json "version". */ -const VERSION = "0.1.0"; +/** arbella version, sourced from package.json. */ +const VERSION = getPackageVersion(); /** Build the configured root program (no parsing yet — easy to unit-test). */ export function buildProgram(): Command { diff --git a/test/unit/cli-help.test.ts b/test/unit/cli-help.test.ts index 97cc2a6..4310753 100644 --- a/test/unit/cli-help.test.ts +++ b/test/unit/cli-help.test.ts @@ -1,8 +1,20 @@ +import fs from "node:fs"; + import { describe, expect, it } from "vitest"; import { buildProgram } from "../../src/index.js"; +import { getPackageVersion } from "../../src/core/version.js"; describe("CLI help", () => { + it("uses the package.json version", () => { + const packageJson = JSON.parse( + fs.readFileSync(new URL("../../package.json", import.meta.url), "utf8"), + ) as { version: string }; + + expect(getPackageVersion()).toBe(packageJson.version); + expect(buildProgram().version()).toBe(packageJson.version); + }); + it("shows auto-push as the public init cadence flag and keeps auto-backup hidden", () => { const program = buildProgram(); const init = program.commands.find((command) => command.name() === "init"); From bbc55cbf5cf192dce8343b1dad919bad174bdf06 Mon Sep 17 00:00:00 2001 From: Fafo Date: Sun, 31 May 2026 20:28:17 +0200 Subject: [PATCH 5/7] Restore all captured tools by default --- src/commands/restore.ts | 20 ++++++------ test/unit/restore-tool-selection.test.ts | 41 ++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 11 deletions(-) create mode 100644 test/unit/restore-tool-selection.test.ts diff --git a/src/commands/restore.ts b/src/commands/restore.ts index 8b67a41..d806f25 100644 --- a/src/commands/restore.ts +++ b/src/commands/restore.ts @@ -7,9 +7,8 @@ * to `config.repo`. Clone it if it is not already present locally, else pull * so the working copy is fresh (R12). * 2. Parse `arbella.json` (ArbellaMeta) at the repo root. - * 3. Decide the set of tools to restore: intersection of the repo's captured - * tools and (a) the `--tools` flag if given, else (b) the configured tools, - * else (c) every captured tool. + * 3. Decide the set of tools to restore: every tool captured in the repo, unless + * the user explicitly narrows it with `--tools`. * 4. For each tool, load its RestoreData (frozen files + symlinks reconstructed * from //files, manifest via parseManifest) and build the * adapter's planned actions. Probe which CLIs are missing (R6). @@ -367,22 +366,21 @@ function parseToolsFlag(raw: string | undefined): ToolId[] | undefined { /** * Decide which tools to restore. Always constrained to what the repo actually - * captured (`meta.tools`). Then narrowed by `--tools` if provided, else by the - * configured tools, else left as the full captured set. Order follows the - * canonical TOOL_IDS order for deterministic output. + * captured (`meta.tools`). `--tools` is the only narrowing mechanism: a stale + * local config on a fresh/old machine must not silently skip tools that are + * present in the backup repo. Order follows the canonical TOOL_IDS order for + * deterministic output. */ -function selectTools( +export function selectToolsForRestore( meta: ArbellaMeta, flagTools: ToolId[] | undefined, - configTools: ToolId[], + _configTools: ToolId[], ): ToolId[] { const captured = new Set(meta.tools); let candidate: Set; if (flagTools && flagTools.length > 0) { candidate = new Set(flagTools); - } else if (configTools.length > 0) { - candidate = new Set(configTools); } else { candidate = new Set(captured); } @@ -937,7 +935,7 @@ export async function run( // ---- 3. Decide which tools to restore ----------------------------------- const config = await loadConfigOrDefault(); const flagTools = parseToolsFlag(opts.tools); - const tools = selectTools(meta, flagTools, config.tools); + const tools = selectToolsForRestore(meta, flagTools, config.tools); if (tools.length === 0) { log.warn( "No tools to restore (the repo + your selection have no overlap). " + diff --git a/test/unit/restore-tool-selection.test.ts b/test/unit/restore-tool-selection.test.ts new file mode 100644 index 0000000..e80f632 --- /dev/null +++ b/test/unit/restore-tool-selection.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { selectToolsForRestore } from "../../src/commands/restore.js"; +import type { ArbellaMeta, ToolId } from "../../src/types.js"; + +function metaWithTools(tools: ToolId[]): ArbellaMeta { + return { + schemaVersion: 1, + arbellaVersion: "0.1.1", + tools, + options: { + includeSecrets: false, + includeMemories: false, + sourceOfTruth: "local", + }, + createdAt: "2026-05-31T00:00:00.000Z", + sharedInstructions: false, + }; +} + +describe("restore tool selection", () => { + it("restores every captured tool by default even when local config is stale", () => { + const tools = selectToolsForRestore( + metaWithTools(["claude", "codex", "cursor"]), + undefined, + ["claude", "codex"], + ); + + expect(tools).toEqual(["claude", "codex", "cursor"]); + }); + + it("still lets --tools explicitly narrow the restore", () => { + const tools = selectToolsForRestore( + metaWithTools(["claude", "codex", "cursor"]), + ["cursor"], + ["claude", "codex"], + ); + + expect(tools).toEqual(["cursor"]); + }); +}); From 89cc5ae0447473a1cef19ab83978838ee5dff300 Mon Sep 17 00:00:00 2001 From: Fafo Date: Sun, 31 May 2026 20:35:47 +0200 Subject: [PATCH 6/7] Normalize captured symlink targets --- src/adapters/claude/capture.ts | 5 +++-- src/adapters/codex/capture.ts | 3 ++- src/adapters/cursor/index.ts | 5 +++-- src/utils/symlink.ts | 11 +++++++++++ test/unit/symlink-target.test.ts | 17 +++++++++++++++++ 5 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 src/utils/symlink.ts create mode 100644 test/unit/symlink-target.test.ts diff --git a/src/adapters/claude/capture.ts b/src/adapters/claude/capture.ts index 729b8c0..3e91da3 100644 --- a/src/adapters/claude/capture.ts +++ b/src/adapters/claude/capture.ts @@ -44,6 +44,7 @@ import { denylistFor, matchesDeny, } from "../../core/sanitizer/denylist.js"; +import { normalizeCapturedSymlinkTarget } from "../../utils/symlink.js"; import { listNpmGlobals } from "../../platform/install.js"; import { REPO_PREFIX, FROZEN_PATHS, INSTRUCTIONS_FILE, paths } from "./paths.js"; @@ -111,7 +112,7 @@ async function walk( if (kind === "symlink") { // A symlink anywhere in the frozen tree is preserved as a link. (Skill // symlinks are special-cased by the caller before recursing into skills/.) - const target = await ctx.fs.readLink(abs); + const target = normalizeCapturedSymlinkTarget(await ctx.fs.readLink(abs)); symlinks.push({ repoPath: repoPathFor(rel), target }); ctx.log.debug(`claude: symlink ${rel} -> ${target}`); return; @@ -225,7 +226,7 @@ async function captureSkills( const kind = await ctx.fs.statKind(abs); if (kind === "symlink") { // skills.sh: ~/.claude/skills/ -> ../../.agents/skills/ - const target = await ctx.fs.readLink(abs); + const target = normalizeCapturedSymlinkTarget(await ctx.fs.readLink(abs)); symlinks.push({ repoPath: repoPathFor(rel), target }); skills.push({ name, diff --git a/src/adapters/codex/capture.ts b/src/adapters/codex/capture.ts index 61a2a76..39b5818 100644 --- a/src/adapters/codex/capture.ts +++ b/src/adapters/codex/capture.ts @@ -32,6 +32,7 @@ import type { } from "../../types.js"; import { denylistFor, matchesDeny } from "../../core/sanitizer/denylist.js"; import { listNpmGlobals } from "../../platform/install.js"; +import { normalizeCapturedSymlinkTarget } from "../../utils/symlink.js"; import { CONFIG_FILE, FROZEN_PATHS, @@ -173,7 +174,7 @@ async function captureSymlink( ): Promise { let target: string; try { - target = await ctx.fs.readLink(absPath); + target = normalizeCapturedSymlinkTarget(await ctx.fs.readLink(absPath)); } catch { out.warnings.push(`codex: could not read symlink ${rel}; skipped`); return; diff --git a/src/adapters/cursor/index.ts b/src/adapters/cursor/index.ts index 1713370..e6b5735 100644 --- a/src/adapters/cursor/index.ts +++ b/src/adapters/cursor/index.ts @@ -46,6 +46,7 @@ import type { import { denylistFor, matchesDeny } from "../../core/sanitizer/denylist.js"; import { cliBinaryName, detectOS, installCommandFor } from "../../platform/os.js"; import { runInstall, which } from "../../platform/install.js"; +import { normalizeCapturedSymlinkTarget } from "../../utils/symlink.js"; import { FROZEN_PATHS, @@ -248,7 +249,7 @@ async function walkFrozen( } if (kind === "symlink") { - const target = await ctx.fs.readLink(abs); + const target = normalizeCapturedSymlinkTarget(await ctx.fs.readLink(abs)); symlinks.push({ repoPath: repoPathBuilder(rel), target }); ctx.log.debug(`cursor: symlink ${rel} -> ${target}`); return; @@ -299,7 +300,7 @@ async function captureSkills( const kind = await ctx.fs.statKind(abs); if (kind === "symlink") { - const target = await ctx.fs.readLink(abs); + const target = normalizeCapturedSymlinkTarget(await ctx.fs.readLink(abs)); symlinks.push({ repoPath: repoPathFor(rel), target }); skills.push({ name, diff --git a/src/utils/symlink.ts b/src/utils/symlink.ts new file mode 100644 index 0000000..4109cbb --- /dev/null +++ b/src/utils/symlink.ts @@ -0,0 +1,11 @@ +/** + * Normalize captured symlink metadata into the repo's portable representation. + * + * Node may report a relative symlink target with native separators on Windows + * even when the original target was created with "/". Backup metadata should be + * deterministic across capture OSes, so store relative-style targets with POSIX + * separators. + */ +export function normalizeCapturedSymlinkTarget(target: string): string { + return target.replace(/\\/g, "/"); +} diff --git a/test/unit/symlink-target.test.ts b/test/unit/symlink-target.test.ts new file mode 100644 index 0000000..6a2dd11 --- /dev/null +++ b/test/unit/symlink-target.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { normalizeCapturedSymlinkTarget } from "../../src/utils/symlink.js"; + +describe("normalizeCapturedSymlinkTarget", () => { + it("serializes relative symlink targets with POSIX separators", () => { + expect(normalizeCapturedSymlinkTarget("..\\..\\.agents\\skills\\humanizer")).toBe( + "../../.agents/skills/humanizer", + ); + }); + + it("leaves already-POSIX targets unchanged", () => { + expect(normalizeCapturedSymlinkTarget("../../.agents/skills/humanizer")).toBe( + "../../.agents/skills/humanizer", + ); + }); +}); From bd153c3ee8b6f3e4b27f88e846f72ae8386d735e Mon Sep 17 00:00:00 2001 From: Fafo Date: Sun, 31 May 2026 20:44:58 +0200 Subject: [PATCH 7/7] Respect Cursor config paths --- src/adapters/adapter.interface.ts | 3 +++ src/adapters/cursor/index.ts | 11 ++++++++--- src/adapters/cursor/paths.ts | 13 ++++-------- src/commands/_context.ts | 2 ++ src/commands/backup.ts | 2 ++ src/commands/restore.ts | 4 +++- src/commands/status.ts | 2 ++ src/platform/os.ts | 16 ++++++++++++++- src/types.ts | 3 +++ test/integration/capture-restore.test.ts | 6 +++++- test/unit/cursor-paths.test.ts | 25 ++++++++++++++++++++++++ 11 files changed, 72 insertions(+), 15 deletions(-) create mode 100644 test/unit/cursor-paths.test.ts diff --git a/src/adapters/adapter.interface.ts b/src/adapters/adapter.interface.ts index 239d174..cac0bd7 100644 --- a/src/adapters/adapter.interface.ts +++ b/src/adapters/adapter.interface.ts @@ -24,6 +24,7 @@ import type { TemplaterService, TemplateVariables, SourceOfTruth, + EnvVars, } from "../types.js"; /* Re-export ToolManifest so consumers can import it from the interface module @@ -47,6 +48,8 @@ export interface CoreServices { vars: TemplateVariables; /** Current OS (from platform/os.ts). */ os: OS; + /** Process environment, injected so path logic can respect platform env vars in tests. */ + env: EnvVars; } /* -------------------------------------------------------------------------- */ diff --git a/src/adapters/cursor/index.ts b/src/adapters/cursor/index.ts index e6b5735..4888f57 100644 --- a/src/adapters/cursor/index.ts +++ b/src/adapters/cursor/index.ts @@ -27,6 +27,7 @@ */ import path from "node:path"; +import process from "node:process"; import { execa } from "execa"; @@ -91,7 +92,8 @@ function targetFromRepoPath(repoPath: string): CursorTarget | undefined { /** Resolve a CursorTarget onto the target machine. */ function targetAbsFor(ctx: RestoreContext, target: CursorTarget): string { - const root = target.root === "home" ? ctx.toolHome : cursorUserPaths(ctx.toolHome, ctx.os).userDir; + const root = + target.root === "home" ? ctx.toolHome : cursorUserPaths(ctx.toolHome, ctx.os, ctx.env).userDir; return path.join(root, ...target.rel.split("/").filter(Boolean)); } @@ -450,7 +452,7 @@ export async function capture( const manifest = emptyCursorManifest(); const p = paths(ctx.toolHome); - const userPaths = cursorUserPaths(p.home, ctx.os); + const userPaths = cursorUserPaths(p.home, ctx.os, ctx.env); const deny = denylistFor("cursor"); // Graceful absence: Cursor may not be installed at all. Never block the rest @@ -661,7 +663,10 @@ export async function restore(ctx: RestoreContext, data: RestoreData): Promise { const p = paths(); - return (await fsExistsDir(p.home)) || (await fsExistsDir(cursorUserPaths(p.home, detectOS()).userDir)); + return ( + (await fsExistsDir(p.home)) || + (await fsExistsDir(cursorUserPaths(p.home, detectOS(), process.env).userDir)) + ); } /** Local lstat-free existence-as-dir probe via the default fs service. */ diff --git a/src/adapters/cursor/paths.ts b/src/adapters/cursor/paths.ts index 054ae4d..b30fe35 100644 --- a/src/adapters/cursor/paths.ts +++ b/src/adapters/cursor/paths.ts @@ -19,8 +19,8 @@ import path from "node:path"; -import type { OS } from "../../types.js"; -import { toolHomeDir } from "../../platform/os.js"; +import type { EnvVars, OS } from "../../types.js"; +import { appUserConfigRoot, toolHomeDir } from "../../platform/os.js"; /** Absolute path to ~/.cursor on this machine. */ export function home(): string { @@ -79,14 +79,9 @@ export function paths(overrideHome?: string): CursorPaths { * Build Cursor's platform-specific app User data paths from the tool home. The * tests pass a fixture tool home; live code passes the real ~/.cursor. */ -export function cursorUserPaths(toolHome: string, os: OS): CursorUserPaths { +export function cursorUserPaths(toolHome: string, os: OS, env: EnvVars = {}): CursorUserPaths { const userHome = path.dirname(toolHome); - const userDir = - os === "darwin" - ? path.join(userHome, "Library", "Application Support", "Cursor", "User") - : os === "win32" - ? path.join(userHome, "AppData", "Roaming", "Cursor", "User") - : path.join(userHome, ".config", "Cursor", "User"); + const userDir = path.join(appUserConfigRoot(os, userHome, env), "Cursor", "User"); return { userDir, diff --git a/src/commands/_context.ts b/src/commands/_context.ts index 25df735..5a00c12 100644 --- a/src/commands/_context.ts +++ b/src/commands/_context.ts @@ -16,6 +16,7 @@ */ import { confirm, isCancel, password } from "@clack/prompts"; +import process from "node:process"; import type { CaptureContext, @@ -53,6 +54,7 @@ export function buildCoreServices(toolHome: string): CoreServices { templater, vars: buildVariables(toolHome), os: detectOS(), + env: process.env, }; } diff --git a/src/commands/backup.ts b/src/commands/backup.ts index fcf23cf..9df5922 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -43,6 +43,7 @@ */ import path from "node:path"; +import process from "node:process"; import type { Command } from "commander"; @@ -179,6 +180,7 @@ function buildCoreServices(toolHome: string): CoreServices { templater, vars: buildVariables(toolHome), os: detectOS(), + env: process.env, }; } diff --git a/src/commands/restore.ts b/src/commands/restore.ts index d806f25..aa0c9cb 100644 --- a/src/commands/restore.ts +++ b/src/commands/restore.ts @@ -39,6 +39,7 @@ */ import path from "node:path"; +import process from "node:process"; import type { Command } from "commander"; @@ -166,6 +167,7 @@ function buildCoreServices(toolHome: string, os: OS): CoreServices { templater: createTemplater(), vars: buildVariables(toolHome), os, + env: process.env, }; } @@ -455,7 +457,7 @@ function safetySourcesForTool( if (tool === "cursor") { sources.push({ label: "cursor User data", - source: cursorUserPaths(toolHome, os).userDir, + source: cursorUserPaths(toolHome, os, process.env).userDir, dest: path.join(backupsRoot, `cursor-user-${stamp}`), }); } diff --git a/src/commands/status.ts b/src/commands/status.ts index 1d4700c..f4a57ef 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -30,6 +30,7 @@ */ import path from "node:path"; +import process from "node:process"; import type { Command } from "commander"; @@ -325,6 +326,7 @@ function buildCaptureContext( templater, vars: buildVariables(toolHome), os: detectOS(), + env: process.env, toolHome, includeSecrets, includeMemories, diff --git a/src/platform/os.ts b/src/platform/os.ts index 19984c6..3bace3f 100644 --- a/src/platform/os.ts +++ b/src/platform/os.ts @@ -8,7 +8,7 @@ import os from "node:os"; import path from "node:path"; import process from "node:process"; -import type { OS, ToolId } from "../types.js"; +import type { EnvVars, OS, ToolId } from "../types.js"; /** Detect the current platform, narrowed to the OSes arbella supports. */ export function detectOS(): OS { @@ -74,6 +74,20 @@ export function toolHomeDir(tool: ToolId): string { } } +/** + * Root directory used by Electron/VS Code-style desktop apps for user config. + * Cursor's User data lives below this root. + */ +export function appUserConfigRoot(targetOS: OS, userHome: string, env: EnvVars = {}): string { + if (targetOS === "darwin") { + return path.join(userHome, "Library", "Application Support"); + } + if (targetOS === "win32") { + return env.APPDATA ?? path.join(userHome, "AppData", "Roaming"); + } + return env.XDG_CONFIG_HOME ?? path.join(userHome, ".config"); +} + /* -------------------------------------------------------------------------- */ /* CLI install command mapping (R6) */ /* -------------------------------------------------------------------------- */ diff --git a/src/types.ts b/src/types.ts index 6dbff40..083c37d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,6 +30,9 @@ export const TOOL_IDS: readonly ToolId[] = ["claude", "codex", "cursor"] as cons /** node:os style platform identifiers arbella supports. */ export type OS = "darwin" | "linux" | "win32"; +/** Environment variables visible to command/adapters. Injected for testability. */ +export type EnvVars = Readonly>; + /* -------------------------------------------------------------------------- */ /* Captured files (the "frozen" half of hybrid capture) */ /* -------------------------------------------------------------------------- */ diff --git a/test/integration/capture-restore.test.ts b/test/integration/capture-restore.test.ts index e53ea9e..2db9c52 100644 --- a/test/integration/capture-restore.test.ts +++ b/test/integration/capture-restore.test.ts @@ -58,6 +58,7 @@ import { import type { CaptureResult, CapturedFile, + EnvVars, TemplateVariables, } from "../../src/types.js"; import type { @@ -99,7 +100,7 @@ const silentLog = { const sanitizer = createSanitizer(); const templater = createTemplater(); -function makeCaptureCtx(toolHome: string, vars: TemplateVariables): CaptureContext { +function makeCaptureCtx(toolHome: string, vars: TemplateVariables, env: EnvVars = {}): CaptureContext { return { fs: realFs, log: silentLog, @@ -107,6 +108,7 @@ function makeCaptureCtx(toolHome: string, vars: TemplateVariables): CaptureConte templater, vars, os: "linux", + env, toolHome, includeSecrets: false, includeMemories: false, @@ -119,6 +121,7 @@ function makeRestoreCtx( repoToolDir: string, repoRoot: string, vars: TemplateVariables, + env: EnvVars = {}, ): RestoreContext { return { fs: realFs, @@ -127,6 +130,7 @@ function makeRestoreCtx( templater, vars, os: "linux", + env, toolHome, repoToolDir, repoRoot, diff --git a/test/unit/cursor-paths.test.ts b/test/unit/cursor-paths.test.ts new file mode 100644 index 0000000..0e02aa2 --- /dev/null +++ b/test/unit/cursor-paths.test.ts @@ -0,0 +1,25 @@ +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { cursorUserPaths } from "../../src/adapters/cursor/paths.js"; + +describe("Cursor paths", () => { + it("uses XDG_CONFIG_HOME for Linux Cursor User data", () => { + const paths = cursorUserPaths("/home/fab/.cursor", "linux", { + XDG_CONFIG_HOME: "/mnt/config", + }); + + expect(paths.userDir).toBe(path.join("/mnt/config", "Cursor", "User")); + expect(paths.settingsJson).toBe(path.join("/mnt/config", "Cursor", "User", "settings.json")); + }); + + it("uses APPDATA for Windows Cursor User data", () => { + const paths = cursorUserPaths(path.join("C:", "Users", "fab", ".cursor"), "win32", { + APPDATA: path.join("D:", "Roaming"), + }); + + expect(paths.userDir).toBe(path.join("D:", "Roaming", "Cursor", "User")); + expect(paths.keybindingsJson).toBe(path.join("D:", "Roaming", "Cursor", "User", "keybindings.json")); + }); +});