Skip to content
Open
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
4 changes: 4 additions & 0 deletions Sources/ContainerCommands/Application.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion Sources/ContainerCommands/DefaultCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 - ")

Expand Down
21 changes: 21 additions & 0 deletions Sources/ContainerPlugin/PluginLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
79 changes: 79 additions & 0 deletions Tests/ContainerPluginTests/PluginLoaderTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
//===----------------------------------------------------------------------===//

import Foundation
import Logging
import Testing

@testable import ContainerPlugin
Expand Down Expand Up @@ -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)
Expand Down