diff --git a/Sources/ContainerCommands/Application.swift b/Sources/ContainerCommands/Application.swift index fc702db72..9331641d0 100644 --- a/Sources/ContainerCommands/Application.swift +++ b/Sources/ContainerCommands/Application.swift @@ -158,7 +158,11 @@ public struct Application: AsyncLoggableCommand { .appending(FilePath.Component("container")) .appending(FilePath.Component("plugins")) let installRootPluginsURL = URL(fileURLWithPath: installRootPluginsPath.string) + // user-level plugins directory (stable across upgrades) + let userHomePluginsURL = PluginLoader.userHomePluginsDir() + let pluginDirectories = [ + userHomePluginsURL, userPluginsURL, appBundlePluginsURL, installRootPluginsURL, diff --git a/Sources/ContainerCommands/DefaultCommand.swift b/Sources/ContainerCommands/DefaultCommand.swift index 3df8f570e..1bfcf380e 100644 --- a/Sources/ContainerCommands/DefaultCommand.swift +++ b/Sources/ContainerCommands/DefaultCommand.swift @@ -62,7 +62,8 @@ struct DefaultCommand: AsyncLoggableCommand { .appending(FilePath.Component("container")) .appending(FilePath.Component("plugins")) let installRootPluginsURL = URL(fileURLWithPath: installRootPluginsPath.string) - let hintPaths = [userPluginsURL, installRootPluginsURL] + let hintPaths = [PluginLoader.userHomePluginsDir(), userPluginsURL, installRootPluginsURL] + .compactMap { $0 } .map { $0.appendingPathComponent(command).path(percentEncoded: false) } .joined(separator: "\n - ") diff --git a/Sources/ContainerPlugin/PluginLoader.swift b/Sources/ContainerPlugin/PluginLoader.swift index 0c156a5a0..3a6952141 100644 --- a/Sources/ContainerPlugin/PluginLoader.swift +++ b/Sources/ContainerPlugin/PluginLoader.swift @@ -64,6 +64,27 @@ public struct PluginLoader: Sendable { .appending(path: "container-plugins") .resolvingSymlinksInPath() } + + /// Returns the user-level plugins directory outside any versioned install root. + /// Respects ``XDG_DATA_HOME``; falls back to ``~/.local/share``. + /// Returns nil when the directory does not exist (plugins are opt-in). + static public func userHomePluginsDir() -> URL? { + let fm = FileManager.default + let base: String + if let xdg = ProcessInfo.processInfo.environment["XDG_DATA_HOME"], !xdg.isEmpty { + base = xdg + } else { + base = fm.homeDirectoryForCurrentUser + .appendingPathComponent(".local/share") + .path + } + let url = URL(fileURLWithPath: "\(base)/container/plugins") + var isDir: ObjCBool = false + guard fm.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue else { + return nil + } + return url + } } extension PluginLoader { diff --git a/Tests/ContainerPluginTests/PluginLoaderTest.swift b/Tests/ContainerPluginTests/PluginLoaderTest.swift index 6433a36a5..42ec2de24 100644 --- a/Tests/ContainerPluginTests/PluginLoaderTest.swift +++ b/Tests/ContainerPluginTests/PluginLoaderTest.swift @@ -15,6 +15,7 @@ //===----------------------------------------------------------------------===// import Foundation +import Logging import Testing @testable import ContainerPlugin @@ -257,6 +258,84 @@ struct PluginLoaderTest { #expect(!programArguments.contains("--debug")) } + // MARK: - userHomePluginsDir + + @Test + func testUserHomePluginsDirWithXDG() async throws { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let pluginsDir = tempDir.appendingPathComponent("container/plugins") + try FileManager.default.createDirectory(at: pluginsDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + + setenv("XDG_DATA_HOME", tempDir.path, 1) + defer { unsetenv("XDG_DATA_HOME") } + + let result = PluginLoader.userHomePluginsDir() + #expect(result != nil) + #expect(result?.path == pluginsDir.path) + } + + @Test + func testUserHomePluginsDirFallback() async throws { + // Without XDG_DATA_HOME, should fall back to ~/.local/share/container/plugins + // which doesn't exist in CI, so expect nil. + unsetenv("XDG_DATA_HOME") + let result = PluginLoader.userHomePluginsDir() + #expect(result == nil) + } + + @Test + func testUserHomePluginsDirNonexistent() async throws { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tempDir) } + + setenv("XDG_DATA_HOME", tempDir.path, 1) + defer { unsetenv("XDG_DATA_HOME") } + + // Directory doesn't exist → nil + let result = PluginLoader.userHomePluginsDir() + #expect(result == nil) + } + + @Test + func testUserHomePluginsDirPriorityOverInstallRoot() async throws { + // When the same plugin name exists in both user-home and install-root dirs, + // the user-home plugin wins (earlier in search order). + let homeDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let homePluginsDir = homeDir.appendingPathComponent("container/plugins") + try FileManager.default.createDirectory(at: homePluginsDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: homeDir) } + + setenv("XDG_DATA_HOME", homeDir.path, 1) + defer { unsetenv("XDG_DATA_HOME") } + + let installDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: installDir) } + + let factory = try setupMock(tempURL: installDir) + + // Create a "cli" plugin in the user-home dir with a different abstract + try FileManager.default.createDirectory(at: homePluginsDir.appendingPathComponent("cli/bin"), withIntermediateDirectories: true) + try Data().write(to: homePluginsDir.appendingPathComponent("cli/bin/cli")) + try """ + abstract = "user-home-plugin" + author = "test" + """.write(to: homePluginsDir.appendingPathComponent("cli/config.toml"), atomically: true, encoding: .utf8) + + let userHomeURL = try #require(PluginLoader.userHomePluginsDir()) + let loader = try PluginLoader( + appRoot: homeDir, + installRoot: URL(filePath: "/usr/local/"), + logRoot: nil, + pluginDirectories: [userHomeURL, installDir], + pluginFactories: [DefaultPluginFactory(logger: Logger(label: "test")), factory] + ) + let plugins = loader.findPlugins() + let cliPlugin = plugins.first { $0.name == "cli" } + #expect(cliPlugin != nil) + #expect(cliPlugin?.config.abstract == "user-home-plugin") + } + private func setupMock(tempURL: URL) throws -> MockPluginFactory { let cliConfig = PluginConfig(abstract: "cli", author: "CLI", servicesConfig: nil) let cliPlugin: Plugin = Plugin(binaryURL: URL(filePath: "/bin/cli"), config: cliConfig)