Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions Sources/OrreryCore/Commands/PrepareClaudeLaunchCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,11 @@ public struct PrepareClaudeLaunchCommand: ParsableCommand {
)
}

// v3.1: generalize workspace linking. Move any shareable account dir
// (skills, plugins, or anything claude adds later) into the pinned
// workspace and symlink it, so accounts on the same workspace share it.
// Best-effort — link failures must never block claude launch.
let linkWarnings = ClaudeAccountDirectory.linkAccountDirsToWorkspace(
// Launch only mirrors the workspace into the account: symlink any shared
// dir the account is missing. It never moves/merges account dirs into the
// workspace — that account→workspace seeding happens once at pin time
// (`orrery pin` → prepareDirectory). Best-effort; never blocks launch.
let linkWarnings = ClaudeAccountDirectory.mirrorWorkspaceDirsToAccount(
accountDir: acctDirURL, workspaceDir: wsDir)
for w in linkWarnings {
FileHandle.standardError.write(
Expand Down
64 changes: 64 additions & 0 deletions Sources/OrreryCore/Setup/ClaudeAccountDirectory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,70 @@ public enum ClaudeAccountDirectory {
warnings.append("\(name): \(error.localizedDescription)")
}
}

// Second pass — mirror the workspace back into the account. Pin runs both
// passes (migrate + mirror); launch runs only the mirror.
warnings.append(contentsOf: mirrorWorkspaceDirsToAccount(
accountDir: accountDir, workspaceDir: workspaceDir))

return warnings
}

/// Ensure every non-private directory in `workspaceDir` has a symlink in
/// `accountDir` (workspace→account). A dir another account created in the
/// shared workspace (or one added directly) has no counterpart here, so this
/// fills that gap.
///
/// This is the ONLY linking done at claude launch: it never moves or merges
/// account data. The account→workspace seeding (moving a real account dir
/// into the workspace) happens once at pin time via `linkAccountDirsToWorkspace`.
///
/// Gap-fill only — names the account already has (a real dir, a plain file, or
/// an existing symlink) are left untouched. Skips the private set, dotfiles,
/// and workspace files (directories only). Best-effort; returns per-entry
/// warnings.
@discardableResult
public static func mirrorWorkspaceDirsToAccount(
accountDir: URL,
workspaceDir: URL
) -> [String] {
let fm = FileManager.default
var warnings: [String] = []
guard let wsEntries = try? fm.contentsOfDirectory(
at: workspaceDir,
includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],
options: []
) else {
return warnings
}
for entry in wsEntries {
let name = entry.lastPathComponent
if name.hasPrefix(".") { continue }
if privateSubdirs.contains(name) { continue }
let vals = try? entry.resourceValues(
forKeys: [.isDirectoryKey, .isSymbolicLinkKey])
let isRealDir = (vals?.isDirectory ?? false)
&& !(vals?.isSymbolicLink ?? false)
if !isRealDir { continue } // only mirror directories

let link = accountDir.appendingPathComponent(name)
// lstat-aware occupancy check (fileExists follows symlinks and would
// miss a dangling one).
let occupied = fm.fileExists(atPath: link.path)
|| (try? fm.destinationOfSymbolicLink(atPath: link.path)) != nil
if occupied { continue }

// Point at workspaceDir/name (the caller's path), matching the symlink
// targets pass 1 creates — not the resolved enumeration URL (which
// standardizes /var → /private/var).
let target = workspaceDir.appendingPathComponent(name)
do {
try fm.createSymbolicLink(at: link, withDestinationURL: target)
} catch {
warnings.append(
"\(name): could not mirror workspace dir into account: \(error.localizedDescription)")
}
}
return warnings
}

Expand Down
16 changes: 16 additions & 0 deletions Sources/OrreryThirdParty/Steps/CopyFileExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,22 @@ public enum CopyFileExecutor {
withIntermediateDirectories: true)
if fm.fileExists(atPath: dst.path) { try fm.removeItem(at: dst) }
try fm.copyItem(at: src, to: dst)

// Migration: when installing into the workspace, remove any stale copy of
// the same file left at the pre-workspace ACCOUNT location (older installs
// put e.g. statusline.js in the account dir). Otherwise a dead account
// copy lingers after the account's settings.json is re-pointed to the
// workspace path by the patchSettings step. Never touch a real directory.
if to.hasPrefix(workspaceMarker) {
let rel = String(to.dropFirst(workspaceMarker.count))
let stale = claudeDir.appendingPathComponent(rel)
let isSymlink = (try? fm.destinationOfSymbolicLink(atPath: stale.path)) != nil
var isDir: ObjCBool = false
let existsFollowing = fm.fileExists(atPath: stale.path, isDirectory: &isDir)
if isSymlink || (existsFollowing && !isDir.boolValue) {
try? fm.removeItem(at: stale)
}
}
return [to]
}

Expand Down
99 changes: 99 additions & 0 deletions Tests/OrreryTests/ClaudeAccountDirectoryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,105 @@ struct ClaudeAccountDirectoryLinkTests {
#expect((try? String(contentsOf: moved, encoding: .utf8)) == "hello")
}

@Test("mirrors a workspace-only dir back into the account as a symlink")
func mirrorsWorkspaceOnlyDir() throws {
let (acct, ws, base) = try makeTempPair()
defer { try? FileManager.default.removeItem(at: base) }
let fm = FileManager.default

// A dir another account created in the shared workspace; this account
// has no counterpart for it.
let wsOnly = ws.appendingPathComponent("sandboxes")
try fm.createDirectory(at: wsOnly, withIntermediateDirectories: true)
try Data("x".utf8).write(to: wsOnly.appendingPathComponent("a.txt"))

let warnings = ClaudeAccountDirectory.linkAccountDirsToWorkspace(
accountDir: acct, workspaceDir: ws)
#expect(warnings.isEmpty)

let dest = try fm.destinationOfSymbolicLink(
atPath: acct.appendingPathComponent("sandboxes").path)
#expect(dest == wsOnly.path)
}

@Test("does not mirror a private (blacklisted) workspace dir into the account")
func skipsPrivateWorkspaceDir() throws {
let (acct, ws, base) = try makeTempPair()
defer { try? FileManager.default.removeItem(at: base) }
let fm = FileManager.default

try fm.createDirectory(
at: ws.appendingPathComponent("cache"), withIntermediateDirectories: true)

_ = ClaudeAccountDirectory.linkAccountDirsToWorkspace(
accountDir: acct, workspaceDir: ws)

#expect(!fm.fileExists(atPath: acct.appendingPathComponent("cache").path))
#expect((try? fm.destinationOfSymbolicLink(
atPath: acct.appendingPathComponent("cache").path)) == nil)
}

@Test("does not mirror a workspace file (only directories are mirrored)")
func skipsWorkspaceFile() throws {
let (acct, ws, base) = try makeTempPair()
defer { try? FileManager.default.removeItem(at: base) }
let fm = FileManager.default

try Data("prog".utf8).write(to: ws.appendingPathComponent("statusline.js"))

_ = ClaudeAccountDirectory.linkAccountDirsToWorkspace(
accountDir: acct, workspaceDir: ws)

#expect(!fm.fileExists(atPath: acct.appendingPathComponent("statusline.js").path))
#expect((try? fm.destinationOfSymbolicLink(
atPath: acct.appendingPathComponent("statusline.js").path)) == nil)
}

@Test("mirrorWorkspaceDirsToAccount symlinks workspace dirs but never migrates account dirs")
func mirrorOnlyNeverMigrates() throws {
let (acct, ws, base) = try makeTempPair()
defer { try? FileManager.default.removeItem(at: base) }
let fm = FileManager.default

// workspace has `skills`; account has a real `local` dir not in workspace.
try fm.createDirectory(
at: ws.appendingPathComponent("skills"), withIntermediateDirectories: true)
let local = acct.appendingPathComponent("local")
try fm.createDirectory(at: local, withIntermediateDirectories: true)
try Data("keep".utf8).write(to: local.appendingPathComponent("k.txt"))

let warnings = ClaudeAccountDirectory.mirrorWorkspaceDirsToAccount(
accountDir: acct, workspaceDir: ws)
#expect(warnings.isEmpty)

// skills mirrored in as a symlink…
#expect((try? fm.destinationOfSymbolicLink(
atPath: acct.appendingPathComponent("skills").path))
== ws.appendingPathComponent("skills").path)
// …but the account's real dir is left alone (not turned into a symlink,
// not moved into the workspace).
#expect((try? fm.destinationOfSymbolicLink(atPath: local.path)) == nil)
#expect(fm.fileExists(atPath: local.appendingPathComponent("k.txt").path))
#expect(!fm.fileExists(atPath: ws.appendingPathComponent("local").path))
}

@Test("mirror pass is idempotent — second run leaves the symlink and no warnings")
func mirrorIsIdempotent() throws {
let (acct, ws, base) = try makeTempPair()
defer { try? FileManager.default.removeItem(at: base) }
let fm = FileManager.default
try fm.createDirectory(
at: ws.appendingPathComponent("sandboxes"), withIntermediateDirectories: true)

_ = ClaudeAccountDirectory.linkAccountDirsToWorkspace(accountDir: acct, workspaceDir: ws)
let warnings = ClaudeAccountDirectory.linkAccountDirsToWorkspace(accountDir: acct, workspaceDir: ws)

#expect(warnings.isEmpty)
let dest = try fm.destinationOfSymbolicLink(
atPath: acct.appendingPathComponent("sandboxes").path)
#expect(dest == ws.appendingPathComponent("sandboxes").path)
}

@Test("union merge keeps the workspace copy and backs up the account copy")
func unionWorkspaceWins() throws {
let (acct, ws, base) = try makeTempPair()
Expand Down
66 changes: 41 additions & 25 deletions Tests/OrreryTests/PrepareClaudeLaunchCommandTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ struct PrepareClaudeLaunchCommandTests {
}
}

@Test("launch links a new shareable account dir into the workspace")
func linksNewShareableDir() throws {
@Test("launch mirrors a workspace dir into the account and does NOT migrate account dirs")
func launchMirrorsWorkspaceDirWithoutMigrating() throws {
try withIsolatedHome {
let acctStore = AccountStore.default
let envStore = EnvironmentStore.default
Expand All @@ -91,22 +91,38 @@ struct PrepareClaudeLaunchCommandTests {

let acctDir = acctStore.accountDir(id: acct.id, tool: .claude)
let wsDir = envStore.claudeWorkspaceDir(workspace: "work")
let fm = FileManager.default

// Simulate claude having created a brand-new folder in the account dir.
let skills = acctDir.appendingPathComponent("skills")
try FileManager.default.createDirectory(
at: skills, withIntermediateDirectories: true)
try Data("x".utf8).write(to: skills.appendingPathComponent("a.md"))
// A dir already in the workspace (e.g. seeded by another account) —
// launch should mirror it into this account.
let wsSkills = wsDir.appendingPathComponent("skills")
try fm.createDirectory(at: wsSkills, withIntermediateDirectories: true)
try Data("s".utf8).write(to: wsSkills.appendingPathComponent("a.md"))

// A real dir claude created in the account that the workspace lacks —
// launch must NOT migrate it (seeding happens only at pin time).
let localOnly = acctDir.appendingPathComponent("local-only")
try fm.createDirectory(at: localOnly, withIntermediateDirectories: true)
try Data("x".utf8).write(to: localOnly.appendingPathComponent("keep.txt"))

var cmd = try PrepareClaudeLaunchCommand.parse(["--account-dir", acctDir.path])
try cmd.run()

let fm = FileManager.default
let dest = try fm.destinationOfSymbolicLink(
atPath: acctDir.appendingPathComponent("skills").path)
#expect(dest == wsDir.appendingPathComponent("skills").path)
#expect(fm.fileExists(
atPath: wsDir.appendingPathComponent("skills/a.md").path))
// Mirrored: account/skills -> workspace/skills.
#expect((try? fm.destinationOfSymbolicLink(
atPath: acctDir.appendingPathComponent("skills").path))
== wsDir.appendingPathComponent("skills").path)

// NOT migrated: account/local-only stays a real dir with its data;
// the workspace never receives it.
let localLink = acctDir.appendingPathComponent("local-only")
#expect((try? fm.destinationOfSymbolicLink(atPath: localLink.path)) == nil,
"launch must not turn an account dir into a symlink (no migration)")
var isDir: ObjCBool = false
#expect(fm.fileExists(atPath: localLink.path, isDirectory: &isDir) && isDir.boolValue)
#expect(fm.fileExists(atPath: localLink.appendingPathComponent("keep.txt").path))
#expect(!fm.fileExists(atPath: wsDir.appendingPathComponent("local-only").path),
"launch must not seed account dirs into the workspace")
}
}

Expand All @@ -127,23 +143,21 @@ struct PrepareClaudeLaunchCommandTests {
try ClaudeJsonMerge.saveJSON(["userID": "uid-alice"],
at: ClaudeJsonMerge.identityFileURL(accountDir: acctDir))

// A brand-new shareable folder in the account dir (like plugins).
let plugins = acctDir.appendingPathComponent("plugins")
// A dir already present in the workspace — launch mirrors it in.
let wsPlugins = wsDir.appendingPathComponent("plugins")
try FileManager.default.createDirectory(
at: plugins, withIntermediateDirectories: true)
try Data("x".utf8).write(to: plugins.appendingPathComponent("config.json"))
at: wsPlugins, withIntermediateDirectories: true)
try Data("x".utf8).write(to: wsPlugins.appendingPathComponent("config.json"))

var cmd = try PrepareClaudeLaunchCommand.parse(
["--account-dir", acctDir.path, "--links-only"])
try cmd.run()

let fm = FileManager.default
// plugins is now a symlink into the workspace (linker ran).
// plugins mirrored into the account as a symlink to the workspace.
let dest = try fm.destinationOfSymbolicLink(
atPath: acctDir.appendingPathComponent("plugins").path)
#expect(dest == wsDir.appendingPathComponent("plugins").path)
#expect(fm.fileExists(
atPath: wsDir.appendingPathComponent("plugins/config.json").path))

// .claude.json was NOT merged — identity fields must be absent.
let claudeJSON = ClaudeJsonMerge.loadJSON(
Expand All @@ -165,10 +179,11 @@ struct PrepareClaudeLaunchCommandTests {
let acctDir = acctStore.accountDir(id: acct.id, tool: .claude)
let wsDir = envStore.claudeWorkspaceDir(workspace: "origin")

let plugins = acctDir.appendingPathComponent("plugins")
// A dir already in the workspace to be mirrored in.
let wsPlugins = wsDir.appendingPathComponent("plugins")
try FileManager.default.createDirectory(
at: plugins, withIntermediateDirectories: true)
try Data("x".utf8).write(to: plugins.appendingPathComponent("config.json"))
at: wsPlugins, withIntermediateDirectories: true)
try Data("x".utf8).write(to: wsPlugins.appendingPathComponent("config.json"))

// Simulate ~/.claude: a symlink that points at the account dir.
let link = acctDir.deletingLastPathComponent()
Expand All @@ -181,11 +196,12 @@ struct PrepareClaudeLaunchCommandTests {
["--account-dir", link.path, "--links-only"])
try cmd.run()

// The REAL account dir's plugins must now be a symlink into the workspace.
// The mirror must resolve the symlinked account dir and create the
// symlink in the REAL account dir.
let dest = try FileManager.default.destinationOfSymbolicLink(
atPath: acctDir.appendingPathComponent("plugins").path)
#expect(dest == wsDir.appendingPathComponent("plugins").path,
"linker must follow the symlinked account dir and link plugins")
"mirror must follow the symlinked account dir")
}
}
}
Expand Down
41 changes: 41 additions & 0 deletions Tests/OrreryThirdPartyTests/CopyExecutorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,47 @@ struct CopyExecutorTests {
#expect(!FileManager.default.fileExists(atPath: dst.appendingPathComponent("a.js").path))
}

@Test("copyFile to <WORKSPACE_CLAUDE_DIR> removes a stale account-relative copy of the same file")
func workspaceInstallRemovesStaleAccountFile() throws {
let (src, dst) = try makeTempTree() // dst = account claude dir
let ws = dst.deletingLastPathComponent().appendingPathComponent("ws")
try FileManager.default.createDirectory(at: ws, withIntermediateDirectories: true)
try Data("new".utf8).write(to: src.appendingPathComponent("statusline.js"))
// Stale pre-workspace copy left in the account dir by an older install.
try Data("old".utf8).write(to: dst.appendingPathComponent("statusline.js"))

let record = try CopyFileExecutor.apply(
.copyFile(from: "statusline.js", to: "<WORKSPACE_CLAUDE_DIR>/statusline.js"),
sourceDir: src, claudeDir: dst, workspaceDir: ws
)

#expect(record == ["<WORKSPACE_CLAUDE_DIR>/statusline.js"])
// Installed to the workspace…
#expect(try String(contentsOf: ws.appendingPathComponent("statusline.js"), encoding: .utf8) == "new")
// …and the stale account copy is gone (so settings.json's re-pointed
// workspace command is the only statusline.js in play).
#expect(!FileManager.default.fileExists(atPath: dst.appendingPathComponent("statusline.js").path))
}

@Test("workspace install never removes a same-named real DIRECTORY in the account")
func workspaceInstallKeepsAccountDir() throws {
let (src, dst) = try makeTempTree()
let ws = dst.deletingLastPathComponent().appendingPathComponent("ws")
try FileManager.default.createDirectory(at: ws, withIntermediateDirectories: true)
try Data("new".utf8).write(to: src.appendingPathComponent("shared"))
// Account has a real directory with the same basename — must not be deleted.
let acctDir = dst.appendingPathComponent("shared")
try FileManager.default.createDirectory(at: acctDir, withIntermediateDirectories: true)
try Data("keep".utf8).write(to: acctDir.appendingPathComponent("keep.txt"))

_ = try CopyFileExecutor.apply(
.copyFile(from: "shared", to: "<WORKSPACE_CLAUDE_DIR>/shared"),
sourceDir: src, claudeDir: dst, workspaceDir: ws
)

#expect(FileManager.default.fileExists(atPath: acctDir.appendingPathComponent("keep.txt").path))
}

@Test("resolveInstalledPath maps marker to workspace, plain to account")
func resolvePath() {
let acct = URL(fileURLWithPath: "/acct")
Expand Down
Loading