From edd14eff6cd4a3a46f59cbb186d2023ef6a578cd Mon Sep 17 00:00:00 2001 From: Grady Zhuo Date: Tue, 7 Jul 2026 14:31:47 +0800 Subject: [PATCH 1/3] [FIX] mirror all non-private workspace dirs into the account at launch linkAccountDirsToWorkspace only moved account dirs INTO the workspace. A dir another account created in the shared workspace (or one added directly) never got a symlink back in this account, so it was invisible here. Add a second pass: for every non-private directory in the pinned workspace that the account lacks, create a symlink account/ -> workspace/. Gap-fill only: names the account already has (a dir merged+symlinked by pass 1, a plain file, or an existing symlink) are left untouched. Skips the private set (backups/cache/cc-statusline), dotfiles, and workspace files (only dirs). Runs on every claude launch via _prepare-claude-launch, so shared dirs converge across all accounts pinned to the same workspace. Idempotent. --- .../Setup/ClaudeAccountDirectory.swift | 42 +++++++++++ .../ClaudeAccountDirectoryTests.swift | 71 +++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/Sources/OrreryCore/Setup/ClaudeAccountDirectory.swift b/Sources/OrreryCore/Setup/ClaudeAccountDirectory.swift index d7aec96..0977287 100644 --- a/Sources/OrreryCore/Setup/ClaudeAccountDirectory.swift +++ b/Sources/OrreryCore/Setup/ClaudeAccountDirectory.swift @@ -116,6 +116,48 @@ public enum ClaudeAccountDirectory { warnings.append("\(name): \(error.localizedDescription)") } } + + // Second pass — mirror the workspace back into the account. A dir another + // account created in the shared workspace (or one added directly) has no + // counterpart here, so ensure every non-private workspace directory has a + // symlink in the account dir. Only fills gaps: names the account already + // has (a real dir already merged+symlinked above, a plain file, or an + // existing symlink) are left untouched. + if let wsEntries = try? fm.contentsOfDirectory( + at: workspaceDir, + includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], + options: [] + ) { + 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 } diff --git a/Tests/OrreryTests/ClaudeAccountDirectoryTests.swift b/Tests/OrreryTests/ClaudeAccountDirectoryTests.swift index 529d56b..1d59ad8 100644 --- a/Tests/OrreryTests/ClaudeAccountDirectoryTests.swift +++ b/Tests/OrreryTests/ClaudeAccountDirectoryTests.swift @@ -329,6 +329,77 @@ 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("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() From 9e11aea205abdbca8e0fd8bf6ebbac30affe196e Mon Sep 17 00:00:00 2001 From: Grady Zhuo Date: Tue, 7 Jul 2026 14:31:47 +0800 Subject: [PATCH 2/3] [FIX] workspace-marker install removes the stale account copy of the file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installing a package whose file targets /… (e.g. the statusline program) left an older pre-workspace copy behind at the account path when it had no lock recording it. The account's settings.json gets re-pointed to the workspace path (patchSettings overwrites the command), but the dead account copy of statusline.js lingered and confused the setup. CopyFileExecutor.apply now, on a workspace-marker copy, removes any stale account-relative file (or symlink) of the same basename. Never removes a real directory. This migrates pre-v3.1.4 per-account statusline installs cleanly. --- .../Steps/CopyFileExecutor.swift | 16 ++++++++ .../CopyExecutorTests.swift | 41 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/Sources/OrreryThirdParty/Steps/CopyFileExecutor.swift b/Sources/OrreryThirdParty/Steps/CopyFileExecutor.swift index ab349aa..a1376a5 100644 --- a/Sources/OrreryThirdParty/Steps/CopyFileExecutor.swift +++ b/Sources/OrreryThirdParty/Steps/CopyFileExecutor.swift @@ -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] } diff --git a/Tests/OrreryThirdPartyTests/CopyExecutorTests.swift b/Tests/OrreryThirdPartyTests/CopyExecutorTests.swift index d845b11..2f6d011 100644 --- a/Tests/OrreryThirdPartyTests/CopyExecutorTests.swift +++ b/Tests/OrreryThirdPartyTests/CopyExecutorTests.swift @@ -47,6 +47,47 @@ struct CopyExecutorTests { #expect(!FileManager.default.fileExists(atPath: dst.appendingPathComponent("a.js").path)) } + @Test("copyFile to 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: "/statusline.js"), + sourceDir: src, claudeDir: dst, workspaceDir: ws + ) + + #expect(record == ["/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: "/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") From 86df2e6a2aafad371dc87eba9657a1549aac8ed6 Mon Sep 17 00:00:00 2001 From: Grady Zhuo Date: Tue, 7 Jul 2026 14:46:21 +0800 Subject: [PATCH 3/3] =?UTF-8?q?[FIX]=20launch=20only=20mirrors=20workspace?= =?UTF-8?q?=E2=86=92account;=20seed=20account=E2=86=92workspace=20at=20pin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refines the earlier two-way mirror per design decision: the account→workspace move/merge is a one-time SEED that belongs at pin, not something to re-run on every launch. Split the direction: - Extract mirrorWorkspaceDirsToAccount (workspace→account): symlink every non-private workspace dir the account is missing; never moves or merges account data. linkAccountDirsToWorkspace (pin, via prepareDirectory) still does the account→workspace migration and then delegates to the mirror. - _prepare-claude-launch now calls mirrorWorkspaceDirsToAccount only, so launch never migrates. A real dir claude created in the account stays put until the next `orrery pin` seeds it into the workspace. Reworks the launch tests to the new contract (mirror in, no migration) and adds a direct mirror-only test proving an account real dir is left untouched. --- .../Commands/PrepareClaudeLaunchCommand.swift | 10 +- .../Setup/ClaudeAccountDirectory.swift | 94 ++++++++++++------- .../ClaudeAccountDirectoryTests.swift | 28 ++++++ .../PrepareClaudeLaunchCommandTests.swift | 66 ++++++++----- 4 files changed, 132 insertions(+), 66 deletions(-) diff --git a/Sources/OrreryCore/Commands/PrepareClaudeLaunchCommand.swift b/Sources/OrreryCore/Commands/PrepareClaudeLaunchCommand.swift index 0aa6170..85816f6 100644 --- a/Sources/OrreryCore/Commands/PrepareClaudeLaunchCommand.swift +++ b/Sources/OrreryCore/Commands/PrepareClaudeLaunchCommand.swift @@ -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( diff --git a/Sources/OrreryCore/Setup/ClaudeAccountDirectory.swift b/Sources/OrreryCore/Setup/ClaudeAccountDirectory.swift index 0977287..656579a 100644 --- a/Sources/OrreryCore/Setup/ClaudeAccountDirectory.swift +++ b/Sources/OrreryCore/Setup/ClaudeAccountDirectory.swift @@ -117,47 +117,69 @@ public enum ClaudeAccountDirectory { } } - // Second pass — mirror the workspace back into the account. A dir another - // account created in the shared workspace (or one added directly) has no - // counterpart here, so ensure every non-private workspace directory has a - // symlink in the account dir. Only fills gaps: names the account already - // has (a real dir already merged+symlinked above, a plain file, or an - // existing symlink) are left untouched. - if let wsEntries = try? fm.contentsOfDirectory( + // 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: [] - ) { - 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)") - } + ) 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 } diff --git a/Tests/OrreryTests/ClaudeAccountDirectoryTests.swift b/Tests/OrreryTests/ClaudeAccountDirectoryTests.swift index 1d59ad8..43ae884 100644 --- a/Tests/OrreryTests/ClaudeAccountDirectoryTests.swift +++ b/Tests/OrreryTests/ClaudeAccountDirectoryTests.swift @@ -383,6 +383,34 @@ struct ClaudeAccountDirectoryLinkTests { 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() diff --git a/Tests/OrreryTests/PrepareClaudeLaunchCommandTests.swift b/Tests/OrreryTests/PrepareClaudeLaunchCommandTests.swift index 0d8d9f8..025f829 100644 --- a/Tests/OrreryTests/PrepareClaudeLaunchCommandTests.swift +++ b/Tests/OrreryTests/PrepareClaudeLaunchCommandTests.swift @@ -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 @@ -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") } } @@ -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( @@ -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() @@ -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") } } }