From 1943bae33fef56189f9063b3ff3e47b12aaa92a4 Mon Sep 17 00:00:00 2001 From: Wyller Gomes Date: Fri, 25 Sep 2026 04:13:35 +0000 Subject: [PATCH 1/4] feat: install the Apple plugin as a symlink install-plugin is replaced by plugin --install and plugin --uninstall. Homebrew upgrades follow the opt symlink so brew does not write /usr/local. --- CONTRIBUTING.md | 4 +- README.md | 12 +- .../Commands/DoctorCommand.swift | 465 +++++++- ...luginCommand.swift => PluginCommand.swift} | 30 +- .../Support/CommandSurface.swift | 75 +- Sources/adevcontainer/AdevcontainerMain.swift | 19 +- .../adevcontainerTests/AllCommandTests.swift | 62 +- .../ContainerPluginTests.swift | 1011 ++++++++++++++--- scripts/render-homebrew-formula.sh | 25 +- .../plugin-symlink-install/proposal.md | 42 + specs/changes/plugin-symlink-install/spec.md | 287 +++++ specs/changes/plugin-symlink-install/tasks.md | 146 +++ wiki/architecture.md | 10 +- wiki/conventions/release-distribution.md | 6 +- 14 files changed, 1897 insertions(+), 297 deletions(-) rename Sources/ADevContainerLib/Commands/{InstallPluginCommand.swift => PluginCommand.swift} (51%) create mode 100644 specs/changes/plugin-symlink-install/proposal.md create mode 100644 specs/changes/plugin-symlink-install/spec.md create mode 100644 specs/changes/plugin-symlink-install/tasks.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c594d1c..2738dd4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,12 +14,12 @@ swift build # binary: .build/debug/adevcontainer swift build -c release # binary: .build/release/adevcontainer ``` -To install from source onto PATH and restage the Apple CLI plugin (same layout as `install-plugin`): +To install from source onto PATH and link the Apple CLI plugin: ```bash swift build -c release cp .build/release/adevcontainer /usr/local/bin/ # or another directory on PATH -adevcontainer install-plugin # use sudo when the plugin destination requires it +adevcontainer plugin --install # use sudo when the plugin destination requires elevation ``` ## Tests diff --git a/README.md b/README.md index 93d72ee..291ff11 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,11 @@ For implementation details and behavior beyond this quick reference, see the [te ```bash brew install wcgomes/tap/adevcontainer +sudo adevcontainer plugin --install ``` +Run `sudo adevcontainer plugin --install` once after install, and again only after upgrading Apple `container`. `brew upgrade adevcontainer` does not require `plugin --install` again when the plugin symlink already targets `$(brew --prefix)/opt/adevcontainer/bin/adevcontainer`. + ### Release binary Download `adevcontainer-macos-arm64.tar.gz` and `adevcontainer-macos-arm64.tar.gz.sha256` from [GitHub Releases](https://github.com/wcgomes/apple-devcontainers/releases), then run: @@ -33,7 +36,7 @@ Download `adevcontainer-macos-arm64.tar.gz` and `adevcontainer-macos-arm64.tar.g shasum -a 256 -c adevcontainer-macos-arm64.tar.gz.sha256 tar xzf adevcontainer-macos-arm64.tar.gz sudo mv adevcontainer /usr/local/bin/ # or move it to another directory on PATH -sudo adevcontainer install-plugin # restage container dev plugin (sudo when /usr/local/libexec is not writable) +sudo adevcontainer plugin --install # sudo when the plugin destination requires elevation ``` Verify the installation and Apple `container` setup: @@ -77,7 +80,7 @@ TTY it offers to open and retry the config; in automation, edit the printed path ## Apple container plugin -After the plugin is staged, every command also works as `container dev `. PATH `adevcontainer` remains valid. +After `adevcontainer plugin --install`, every command also works as `container dev `. PATH `adevcontainer` remains valid. `plugin --uninstall` removes only the plugin `bin/dev` link and `config.toml`. Apple must be running to dispatch plugins: @@ -94,7 +97,7 @@ container dev list container dev doctor ``` -Apple upgrades wipe the plugin. Restage with PATH `adevcontainer install-plugin` (`sudo` when the destination requires elevation). +Apple upgrades wipe the plugin directory. PATH `adevcontainer` survives. Restage with PATH `adevcontainer plugin --install` (`sudo` when the destination requires elevation), again only after upgrading Apple `container`. `brew upgrade adevcontainer` does not require restage when the symlink already targets the Homebrew opt path. ## Commands @@ -105,7 +108,8 @@ The `start`, `exec`, `stop`, `delete`, `purge`, `rebuild`, and `inspect` command | Command | Purpose | | --- | --- | | `adevcontainer doctor` | Check Apple `container` readiness | -| `adevcontainer install-plugin` | Restage the Apple CLI plugin after an Apple container upgrade | +| `adevcontainer plugin --install` | Link the Apple CLI plugin (`bin/dev` absolute symlink and `config.toml`) | +| `adevcontainer plugin --uninstall` | Remove plugin `bin/dev` and `config.toml` only | | `adevcontainer up [-w ] [--vscode]` | Create or start a dev container from a local folder | | `adevcontainer clone [--vscode] [--resume ]` | Clone a repository into a named volume and start its dev container | | `adevcontainer exec [-it] [--name ] [--] [cmd…]` | Open a shell or run a command in a running managed container | diff --git a/Sources/ADevContainerLib/Commands/DoctorCommand.swift b/Sources/ADevContainerLib/Commands/DoctorCommand.swift index 143cb6e..44e75b5 100644 --- a/Sources/ADevContainerLib/Commands/DoctorCommand.swift +++ b/Sources/ADevContainerLib/Commands/DoctorCommand.swift @@ -49,6 +49,7 @@ public enum ContainerPluginLayout { .path } + /// `config.toml` plus `bin/dev`. A symlink `bin/dev` counts; do not require a regular file. public static func isPresent(installRoot: String, fileManager: FileManager = .default) -> Bool { fileManager.fileExists(atPath: configPath(installRoot: installRoot)) && fileManager.isExecutableFile(atPath: binaryPath(installRoot: installRoot)) @@ -67,78 +68,106 @@ public enum ContainerPluginLayout { } } - /// Prefer the running Mach-O over a relative argv[0] basename (e.g. `sudo adevcontainer`). - public static func resolveSourceExecutable( + /// Absolute symlink target for `bin/dev`. + /// + /// Homebrew formula `adevcontainer` (Cellar path, opt path, or brew `bin` symlink, + /// including sudo with a bare argv0) targets + /// `$(brew --prefix)/opt/adevcontainer/bin/adevcontainer`. + /// That path is formed from the formula layout. It is never a Cellar path and never + /// `resolvingSymlinksInPath` of the keg. If it cannot be formed, this throws. + /// Otherwise the target is the running executable path with no realpath. A bare argv0 + /// is not a relative source path. + /// + /// When argv0 or the identified path is the plugin binary (`bin/dev`, or process name + /// `dev`), the target is never that plugin path. An existing absolute link text is + /// reused (Cellar becomes the opt path; an opt link is kept). A regular file, relative + /// symlink, or self-symlink falls back to PATH `adevcontainer` or the Homebrew opt binary. + public static func requiredSymlinkTarget( argv0: String, - runningExecutablePath: String? = Bundle.main.executableURL?.path, - pathEnvironment: String? = ProcessInfo.processInfo.environment["PATH"], + runningExecutablePath: String?, + pathEnvironment: String?, + installRoot: String, fileManager: FileManager = .default - ) -> String { - func resolvedExisting(_ path: String) -> String? { - guard !path.isEmpty, fileManager.fileExists(atPath: path) else { return nil } - return URL(fileURLWithPath: path).resolvingSymlinksInPath().path - } - - let isBareName = argv0.isEmpty || !argv0.contains("/") - if !isBareName { - let standardized = URL(fileURLWithPath: argv0).standardizedFileURL.path - return resolvedExisting(standardized) ?? standardized - } - - if let running = runningExecutablePath, let resolved = resolvedExisting(running) { - return resolved - } - if let pathEnvironment { - for entry in pathEnvironment.split(separator: ":", omittingEmptySubsequences: false) { - let directory = String(entry) - guard directory.hasPrefix("/") else { continue } - let candidate = (directory as NSString).appendingPathComponent(argv0) - if let resolved = resolvedExisting(candidate) { - return resolved - } - } + ) throws -> String { + let pluginBinary = binaryPath(installRoot: installRoot) + if argv0 == CommandSurface.pluginArgvName || isPluginBinaryPath(argv0, pluginBinary: pluginBinary) { + let installed = try installedExecutableAvoidingPlugin( + pluginBinary: pluginBinary, + pathEnvironment: pathEnvironment, + fileManager: fileManager + ) + return try finalizedTarget(installed, pluginBinary: pluginBinary, fileManager: fileManager) } - if let running = runningExecutablePath, !running.isEmpty { - return running + let identified = identifiedExecutablePath( + argv0: argv0, + runningExecutablePath: runningExecutablePath, + pathEnvironment: pathEnvironment, + fileManager: fileManager + ) + if isPluginBinaryPath(identified, pluginBinary: pluginBinary) { + let installed = try installedExecutableAvoidingPlugin( + pluginBinary: pluginBinary, + pathEnvironment: pathEnvironment, + fileManager: fileManager + ) + return try finalizedTarget(installed, pluginBinary: pluginBinary, fileManager: fileManager) } - return argv0 + return try finalizedTarget(identified, pluginBinary: pluginBinary, fileManager: fileManager) } - public static func stage( + /// Create `bin/dev` as an absolute symlink and write `config.toml` as a regular file. + /// Does not copy the Mach-O or chmod the symlink target. + public static func installSymlink( installRoot: String, - executablePath: String, + symlinkTarget: String, fileManager: FileManager = .default ) throws { - let dir = pluginDirectory(installRoot: installRoot) + guard symlinkTarget.hasPrefix("/") else { + throw relativeSourceError(symlinkTarget) + } let destBinary = binaryPath(installRoot: installRoot) + guard !isPluginBinaryPath(symlinkTarget, pluginBinary: destBinary) else { + throw refusingSelfLinkError(destBinary) + } + let dir = pluginDirectory(installRoot: installRoot) let destConfig = configPath(installRoot: installRoot) let binDir = (destBinary as NSString).deletingLastPathComponent - let sourcePath = URL(fileURLWithPath: executablePath).resolvingSymlinksInPath().path - let sourceExists = fileManager.fileExists(atPath: executablePath) - || fileManager.fileExists(atPath: sourcePath) - guard sourceExists else { + do { + try fileManager.createDirectory(atPath: binDir, withIntermediateDirectories: true) + try replacePluginBinaryIfNeeded( + destBinary: destBinary, + symlinkTarget: symlinkTarget, + fileManager: fileManager + ) + try writeConfigAsRegularFile(at: destConfig, fileManager: fileManager) + } catch let error as CLIError { + throw error + } catch { throw CLIError( code: CLIErrorCode.runtimeFailed, - message: "Could not restage Apple CLI plugin: source executable not found at \(executablePath)", - hint: "Reinstall adevcontainer onto PATH or retry using the full path to the binary" + message: "Could not write Apple CLI plugin layout at \(dir): \(error.localizedDescription)", + hint: installHint(installRoot: installRoot, fileManager: fileManager) ) } + } + + /// Remove `bin/dev` and `config.toml` only. Missing entries are success. + /// Unlink symlinks; do not delete their targets, the keg, or any other path. + public static func uninstall(installRoot: String, fileManager: FileManager = .default) throws { + let destBinary = binaryPath(installRoot: installRoot) + let destConfig = configPath(installRoot: installRoot) + if !layoutEntryExists(destBinary, fileManager: fileManager), + !layoutEntryExists(destConfig, fileManager: fileManager) { + return + } do { - try fileManager.createDirectory(atPath: binDir, withIntermediateDirectories: true) - let destPath = URL(fileURLWithPath: destBinary).resolvingSymlinksInPath().path - if sourcePath != destPath { - if fileManager.fileExists(atPath: destBinary) { - try fileManager.removeItem(atPath: destBinary) - } - try fileManager.copyItem(atPath: sourcePath, toPath: destBinary) - } - try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: destBinary) - try configTOML.write(toFile: destConfig, atomically: true, encoding: .utf8) + try removeLayoutEntry(destBinary, fileManager: fileManager) + try removeLayoutEntry(destConfig, fileManager: fileManager) } catch { throw CLIError( code: CLIErrorCode.runtimeFailed, - message: "Could not write Apple CLI plugin layout at \(dir): \(error.localizedDescription)", - hint: restageHint(installRoot: installRoot, fileManager: fileManager) + message: "Could not remove Apple CLI plugin layout at \(pluginDirectory(installRoot: installRoot)): \(error.localizedDescription)", + hint: uninstallHint(installRoot: installRoot, fileManager: fileManager) ) } } @@ -147,14 +176,338 @@ public enum ContainerPluginLayout { CLIError( code: CLIErrorCode.runtimeFailed, message: "Apple CLI plugin 'dev' is not installed at \(pluginDirectory(installRoot: installRoot))", - hint: restageHint(installRoot: installRoot, fileManager: fileManager) + hint: installHint(installRoot: installRoot, fileManager: fileManager) ) } - static func restageHint(installRoot: String, fileManager: FileManager) -> String { + static func installHint(installRoot: String, fileManager: FileManager) -> String { + remediation( + command: "plugin --install", + action: "install the plugin", + installRoot: installRoot, + fileManager: fileManager + ) + } + + static func uninstallHint(installRoot: String, fileManager: FileManager) -> String { + remediation( + command: "plugin --uninstall", + action: "uninstall the plugin", + installRoot: installRoot, + fileManager: fileManager + ) + } + + private static func remediation( + command: String, + action: String, + installRoot: String, + fileManager: FileManager + ) -> String { requiresElevation(installRoot: installRoot, fileManager: fileManager) - ? "Run 'sudo adevcontainer install-plugin' to restage the plugin (destination requires elevated privileges)" - : "Run 'adevcontainer install-plugin' to restage the plugin" + ? "Run 'sudo adevcontainer \(command)' to \(action) (destination requires elevated privileges)" + : "Run 'adevcontainer \(command)' to \(action)" + } + + /// Prefer an absolute running executable or PATH hit. Never treat a bare argv0 as relative. + private static func identifiedExecutablePath( + argv0: String, + runningExecutablePath: String?, + pathEnvironment: String?, + fileManager: FileManager + ) -> String { + let isBareName = argv0.isEmpty || !argv0.contains("/") + if !isBareName { + if argv0.hasPrefix("/") { + return argv0 + } + // `./adevcontainer` is explicitly relative; absolute-ize lexically, do not realpath. + return URL(fileURLWithPath: argv0).path + } + if let absolute = absolutePath(runningExecutablePath), + fileManager.fileExists(atPath: absolute) { + return absolute + } + if let found = pathHit( + name: argv0.isEmpty ? CommandSurface.pathBinaryName : argv0, + pathEnvironment: pathEnvironment, + fileManager: fileManager + ) { + return found + } + if let absolute = absolutePath(runningExecutablePath) { + return absolute + } + return argv0 + } + + private static func absolutePath(_ path: String?) -> String? { + guard let path, path.hasPrefix("/") else { return nil } + return path + } + + private static func pathHit(name: String, pathEnvironment: String?, fileManager: FileManager) -> String? { + guard let pathEnvironment else { return nil } + for entry in pathEnvironment.split(separator: ":", omittingEmptySubsequences: false) { + let directory = String(entry) + guard directory.hasPrefix("/") else { continue } + let candidate = (directory as NSString).appendingPathComponent(name) + if fileManager.fileExists(atPath: candidate) { + return candidate + } + } + return nil + } + + /// Nil when `executablePath` is not Homebrew formula `adevcontainer`. + /// Throws when it is that formula but the opt path cannot be formed. + private static func homebrewOptExecutable( + executablePath: String, + fileManager: FileManager + ) throws -> String? { + let name = CommandSurface.pathBinaryName + if containsBounded(executablePath, marker: "/Cellar/\(name)") + || containsBounded(executablePath, marker: "/opt/\(name)") { + return try optExecutable(prefix: try brewPrefix(fromFormulaPath: executablePath)) + } + if let prefix = try brewBinPrefix(executablePath, fileManager: fileManager) { + return try optExecutable(prefix: prefix) + } + return nil + } + + private static func brewPrefix(fromFormulaPath path: String) throws -> String { + let name = CommandSurface.pathBinaryName + if let range = boundedRange(in: path, marker: "/Cellar/\(name)") { + return try requireBrewPrefix(String(path[.. String? { + let name = CommandSurface.pathBinaryName + let ns = path as NSString + guard ns.lastPathComponent == name else { return nil } + let binDir = ns.deletingLastPathComponent + guard (binDir as NSString).lastPathComponent == "bin" else { return nil } + guard let destination = symlinkDestination(path, fileManager: fileManager) else { + return nil + } + let namesFormula = containsBounded(destination, marker: "Cellar/\(name)") + || containsBounded(destination, marker: "opt/\(name)") + guard namesFormula else { return nil } + return try requireBrewPrefix((binDir as NSString).deletingLastPathComponent) + } + + private static func requireBrewPrefix(_ prefix: String) throws -> String { + let trimmed = prefix.hasSuffix("/") ? String(prefix.dropLast()) : prefix + guard trimmed.hasPrefix("/"), trimmed != "/" else { + throw unformableOptPathError() + } + return trimmed + } + + private static func optExecutable(prefix: String) throws -> String { + let name = CommandSurface.pathBinaryName + let trimmed = try requireBrewPrefix(prefix) + // String form of $(brew --prefix)/opt/adevcontainer/bin/adevcontainer. + // Do not round-trip through URL — that can rewrite /var vs /private/var. + return "\(trimmed)/opt/\(name)/bin/\(name)" + } + + private static func replacePluginBinaryIfNeeded( + destBinary: String, + symlinkTarget: String, + fileManager: FileManager + ) throws { + if let existing = symlinkDestination(destBinary, fileManager: fileManager) { + if existing == symlinkTarget, existing.hasPrefix("/") { + return + } + // removeItem unlinks the symlink and does not delete its target. + try fileManager.removeItem(atPath: destBinary) + } else if fileManager.fileExists(atPath: destBinary) { + try fileManager.removeItem(atPath: destBinary) + } + try fileManager.createSymbolicLink(atPath: destBinary, withDestinationPath: symlinkTarget) + } + + private static func writeConfigAsRegularFile(at path: String, fileManager: FileManager) throws { + if symlinkDestination(path, fileManager: fileManager) != nil { + try fileManager.removeItem(atPath: path) + } + try configTOML.write(toFile: path, atomically: true, encoding: .utf8) + } + + private static func removeLayoutEntry(_ path: String, fileManager: FileManager) throws { + if symlinkDestination(path, fileManager: fileManager) != nil || isRegularFile(path, fileManager: fileManager) { + try fileManager.removeItem(atPath: path) + } + } + + private static func layoutEntryExists(_ path: String, fileManager: FileManager) -> Bool { + symlinkDestination(path, fileManager: fileManager) != nil || fileManager.fileExists(atPath: path) + } + + private static func isRegularFile(_ path: String, fileManager: FileManager) -> Bool { + if symlinkDestination(path, fileManager: fileManager) != nil { return false } + var isDirectory = ObjCBool(false) + guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory) else { return false } + return !isDirectory.boolValue + } + + private static func symlinkDestination(_ path: String, fileManager: FileManager) -> String? { + try? fileManager.destinationOfSymbolicLink(atPath: path) + } + + private static func containsBounded(_ path: String, marker: String) -> Bool { + boundedRange(in: path, marker: marker) != nil + } + + /// Marker must end at `/` or end-of-string so `adevcontainer-other` does not match. + private static func boundedRange(in path: String, marker: String) -> Range? { + var search = path.startIndex + while let range = path.range(of: marker, range: search.. String { + let target = try targetForIdentifiedExecutable(identified, fileManager: fileManager) + guard !isPluginBinaryPath(target, pluginBinary: pluginBinary) else { + throw refusingSelfLinkError(pluginBinary) + } + return target + } + + /// Homebrew vs non-Homebrew rules for an already-identified executable path. + /// Does not `resolvingSymlinksInPath` the keg. + private static func targetForIdentifiedExecutable( + _ identified: String, + fileManager: FileManager + ) throws -> String { + if let opt = try homebrewOptExecutable(executablePath: identified, fileManager: fileManager) { + guard fileManager.fileExists(atPath: identified) else { + throw sourceNotFoundError(identified) + } + guard opt.hasPrefix("/") else { + throw unformableOptPathError() + } + return opt + } + guard identified.hasPrefix("/") else { + throw relativeSourceError(identified) + } + guard fileManager.fileExists(atPath: identified) else { + throw sourceNotFoundError(identified) + } + return identified + } + + /// Link text of an absolute symlink to a different path, else PATH `adevcontainer` + /// or the Homebrew opt binary. Never returns the plugin path. + private static func installedExecutableAvoidingPlugin( + pluginBinary: String, + pathEnvironment: String?, + fileManager: FileManager + ) throws -> String { + if let link = symlinkDestination(pluginBinary, fileManager: fileManager), + link.hasPrefix("/"), + !isPluginBinaryPath(link, pluginBinary: pluginBinary) { + return link + } + if let found = pathHit( + name: CommandSurface.pathBinaryName, + pathEnvironment: pathEnvironment, + fileManager: fileManager + ), !isPluginBinaryPath(found, pluginBinary: pluginBinary) { + return found + } + if let opt = homebrewOptBinaryOnPath( + pathEnvironment: pathEnvironment, + pluginBinary: pluginBinary, + fileManager: fileManager + ) { + return opt + } + throw refusingSelfLinkError(pluginBinary) + } + + /// `{prefix}/opt/adevcontainer/bin/adevcontainer` when a PATH `bin` directory's prefix has it. + private static func homebrewOptBinaryOnPath( + pathEnvironment: String?, + pluginBinary: String, + fileManager: FileManager + ) -> String? { + guard let pathEnvironment else { return nil } + let name = CommandSurface.pathBinaryName + for entry in pathEnvironment.split(separator: ":", omittingEmptySubsequences: false) { + let directory = String(entry) + guard directory.hasPrefix("/") else { continue } + let ns = directory as NSString + guard ns.lastPathComponent == "bin" else { continue } + let prefix = ns.deletingLastPathComponent + guard !prefix.isEmpty, prefix != "/" else { continue } + let opt = "\(prefix)/opt/\(name)/bin/\(name)" + guard !isPluginBinaryPath(opt, pluginBinary: pluginBinary) else { continue } + guard fileManager.fileExists(atPath: opt) else { continue } + return opt + } + return nil + } + + /// Lexical path equality only. Does not resolve symlinks. + private static func isPluginBinaryPath(_ path: String, pluginBinary: String) -> Bool { + if path == pluginBinary { return true } + guard path.hasPrefix("/"), pluginBinary.hasPrefix("/") else { return false } + return URL(fileURLWithPath: path).standardizedFileURL.path + == URL(fileURLWithPath: pluginBinary).standardizedFileURL.path + } + + private static func refusingSelfLinkError(_ pluginBinary: String) -> CLIError { + CLIError( + code: CLIErrorCode.runtimeFailed, + message: "Could not install Apple CLI plugin: refusing to link \(pluginBinary) to itself", + hint: "Retry with PATH `adevcontainer plugin --install` or the full path to the installed executable" + ) + } + + private static func sourceNotFoundError(_ path: String) -> CLIError { + CLIError( + code: CLIErrorCode.runtimeFailed, + message: "Could not install Apple CLI plugin: source executable not found at \(path)", + hint: "Reinstall adevcontainer onto PATH or retry using the full path to the binary" + ) + } + + private static func relativeSourceError(_ path: String) -> CLIError { + CLIError( + code: CLIErrorCode.runtimeFailed, + message: "Could not install Apple CLI plugin: refusing relative source path '\(path)'", + hint: "Retry using the full path to the adevcontainer binary" + ) + } + + private static func unformableOptPathError() -> CLIError { + CLIError( + code: CLIErrorCode.runtimeFailed, + message: "Could not form Homebrew opt path $(brew --prefix)/opt/adevcontainer/bin/adevcontainer", + hint: "Refusing to link a Cellar path or a realpath-resolved keg. Reinstall the Homebrew formula, then retry 'adevcontainer plugin --install'" + ) } } diff --git a/Sources/ADevContainerLib/Commands/InstallPluginCommand.swift b/Sources/ADevContainerLib/Commands/PluginCommand.swift similarity index 51% rename from Sources/ADevContainerLib/Commands/InstallPluginCommand.swift rename to Sources/ADevContainerLib/Commands/PluginCommand.swift index 5f31fd4..fabbb6f 100644 --- a/Sources/ADevContainerLib/Commands/InstallPluginCommand.swift +++ b/Sources/ADevContainerLib/Commands/PluginCommand.swift @@ -1,13 +1,22 @@ import Foundation -public enum InstallPluginCommand { +public enum PluginCommand { public static func run( + install: Bool, + uninstall: Bool, runtime: AppleContainerRuntime, currentExecutablePath: String = CommandLine.arguments.first ?? "", runningExecutablePath: String? = Bundle.main.executableURL?.path, pathEnvironment: String? = ProcessInfo.processInfo.environment["PATH"], fileManager: FileManager = .default ) throws { + guard install != uninstall else { + throw CLIError( + code: CLIErrorCode.usage, + message: "plugin requires exactly one of --install or --uninstall", + hint: "Usage: adevcontainer plugin --install | adevcontainer plugin --uninstall" + ) + } let path = runtime.executablePath guard runtime.binaryExists(fileManager: fileManager) else { throw CLIError( @@ -17,20 +26,29 @@ public enum InstallPluginCommand { ) } let installRoot = ContainerPluginLayout.installRoot(containerBinaryPath: path) - let source = ContainerPluginLayout.resolveSourceExecutable( + if uninstall { + try ContainerPluginLayout.uninstall(installRoot: installRoot, fileManager: fileManager) + return + } + let target = try ContainerPluginLayout.requiredSymlinkTarget( argv0: currentExecutablePath, runningExecutablePath: runningExecutablePath, pathEnvironment: pathEnvironment, + installRoot: installRoot, fileManager: fileManager ) - try ContainerPluginLayout.stage( + try ContainerPluginLayout.installSymlink( installRoot: installRoot, - executablePath: source, + symlinkTarget: target, fileManager: fileManager ) } - public static func printSuccess() { - print("\(CommandSurface.commandPrefix) install-plugin: staged Apple CLI plugin 'dev'") + public static func printSuccess(installed: Bool) { + if installed { + print("\(CommandSurface.commandPrefix) plugin --install: linked Apple CLI plugin 'dev'") + } else { + print("\(CommandSurface.commandPrefix) plugin --uninstall: removed Apple CLI plugin layout entries") + } } } diff --git a/Sources/ADevContainerLib/Support/CommandSurface.swift b/Sources/ADevContainerLib/Support/CommandSurface.swift index dcda3c6..0b41906 100644 --- a/Sources/ADevContainerLib/Support/CommandSurface.swift +++ b/Sources/ADevContainerLib/Support/CommandSurface.swift @@ -180,12 +180,22 @@ public enum CommandSurface { i += 1 continue } + if a == "--install" { + flags.insert("install") + i += 1 + continue + } + if a == "--uninstall" { + flags.insert("uninstall") + i += 1 + continue + } if a == "--repair" { throw CLIError( code: CLIErrorCode.usage, property: "--repair", message: "Unknown option '--repair'", - hint: "Restage the Apple CLI plugin with: adevcontainer install-plugin" + hint: "Restage the Apple CLI plugin with: adevcontainer plugin --install" ) } // Reject product-non-goal flags explicitly @@ -232,7 +242,7 @@ public enum CommandSurface { code: CLIErrorCode.usage, property: "--repair", message: "Unknown option '--repair'", - hint: "Restage the Apple CLI plugin with: adevcontainer install-plugin" + hint: "Restage the Apple CLI plugin with: adevcontainer plugin --install" ) } if parsed.workspace != nil, subcommand != "up" { @@ -253,6 +263,45 @@ public enum CommandSurface { } } + /// `plugin` requires exactly one of `--install` or `--uninstall`. + /// Other commands reject those flags as unknown options. + public static func enforcePluginInvocation(subcommand: String, parsed: ParsedArgs) throws { + let install = parsed.flags.contains("install") + let uninstall = parsed.flags.contains("uninstall") + if subcommand != "plugin" { + if install { + throw CLIError( + code: CLIErrorCode.usage, + property: "--install", + message: "Unknown option '--install'" + ) + } + if uninstall { + throw CLIError( + code: CLIErrorCode.usage, + property: "--uninstall", + message: "Unknown option '--uninstall'" + ) + } + return + } + if install == uninstall { + throw CLIError( + code: CLIErrorCode.usage, + message: "plugin requires exactly one of --install or --uninstall", + hint: "Usage: \(pathBinaryName) plugin --install | \(pathBinaryName) plugin --uninstall" + ) + } + } + + public static func unknownSubcommandError(subcommand: String) -> CLIError { + CLIError( + code: CLIErrorCode.usage, + message: "Unknown subcommand '\(subcommand)'", + hint: "Try: doctor | plugin | up | clone | rebuild | list | start | exec | stop | delete | purge | inspect" + ) + } + // MARK: - Usage / help public static func usageText() -> String { @@ -265,7 +314,8 @@ public enum CommandSurface { Commands: doctor Check Apple container runtime and plugin layout - install-plugin Restage the Apple CLI plugin layout + plugin --install | --uninstall + Install or remove the Apple CLI plugin (exactly one flag) up [-w path] Create/start/reuse bind-mode dev container (host path) clone Clone repo into volume-mode dev container (managed) list [--json] List managed dev containers (up + clone) @@ -375,19 +425,26 @@ public enum CommandSurface { and the Apple CLI plugin layout under the container install-root. Doctor does not restage the plugin. A missing plugin is restaged with - PATH `adevcontainer install-plugin` (use sudo when the destination + PATH `adevcontainer plugin --install` (use sudo when the destination requires elevated privileges). Doctor does not require a devcontainer.json. """ - case "install-plugin": + case "plugin": return """ - \(cmd) install-plugin + \(cmd) plugin --install + \(cmd) plugin --uninstall + + Install or remove the Apple CLI plugin layout. Exactly one of + --install or --uninstall is required. - Copy this executable to {install-root}/libexec/container-plugins/dev/bin/dev - and write config.toml (CLI plugin: abstract, no [servicesConfig]). + --install creates {install-root}/libexec/container-plugins/dev/bin/dev + as an absolute symlink to the installed executable and writes + config.toml as a regular file (abstract, no [servicesConfig]). + It does not copy the executable or change the symlink target. + --uninstall removes bin/dev and config.toml only. Uses elevated privileges when the destination requires them. Does not require `container system start`. - Restage a missing plugin with PATH `adevcontainer install-plugin` + Restage a missing plugin with PATH `adevcontainer plugin --install` (use sudo when the destination requires elevated privileges). """ case "up": diff --git a/Sources/adevcontainer/AdevcontainerMain.swift b/Sources/adevcontainer/AdevcontainerMain.swift index 331a653..553c90a 100644 --- a/Sources/adevcontainer/AdevcontainerMain.swift +++ b/Sources/adevcontainer/AdevcontainerMain.swift @@ -59,15 +59,22 @@ struct AdevcontainerMain { return 0 } + try CommandSurface.enforcePluginInvocation(subcommand: subcommand, parsed: parsed) + switch subcommand { case "doctor": let report = try DoctorCommand.run(runtime: runtime) DoctorCommand.printReport(report) return 0 - case "install-plugin": - try InstallPluginCommand.run(runtime: runtime) - InstallPluginCommand.printSuccess() + case "plugin": + let installing = parsed.flags.contains("install") + try PluginCommand.run( + install: installing, + uninstall: parsed.flags.contains("uninstall"), + runtime: runtime + ) + PluginCommand.printSuccess(installed: installing) return 0 case "up": @@ -208,11 +215,7 @@ struct AdevcontainerMain { return 0 default: - throw CLIError( - code: CLIErrorCode.usage, - message: "Unknown subcommand '\(subcommand)'", - hint: "Try: doctor | install-plugin | up | clone | rebuild | list | start | exec | stop | delete | purge | inspect" - ) + throw CommandSurface.unknownSubcommandError(subcommand: subcommand) } } diff --git a/Tests/adevcontainerTests/AllCommandTests.swift b/Tests/adevcontainerTests/AllCommandTests.swift index 985d45e..dbfcbad 100644 --- a/Tests/adevcontainerTests/AllCommandTests.swift +++ b/Tests/adevcontainerTests/AllCommandTests.swift @@ -42,32 +42,66 @@ nonisolated(unsafe) let doctorTests: [(String, () throws -> Void)] = [ ) } }), - ("installPluginRestagesWhenSystemNotRunning", { + ("pluginInstallWhileSystemNotRunning", { let root = try PluginTestSupport.makeInstallRoot() defer { try? FileManager.default.removeItem(at: root) } - let sourceBytes = Data("install-plugin-while-stopped-\(UUID().uuidString)\n".utf8) let source = root.appendingPathComponent("adevcontainer-src") - try sourceBytes.write(to: source) - let runtime = try PluginTestSupport.readyRuntime( + try PluginTestSupport.writeExecutable(at: source) + let mock = MockProcessRunner() + mock.handlers = [{ _ in + ProcessResult(exitCode: 1, stdout: Data(), stderr: Data("container system start\n".utf8)) + }] + let runtime = AppleContainerRuntime( executablePath: PluginTestSupport.containerBinary(in: root), - status: "stopped" + runner: mock ) - try InstallPluginCommand.run( + try PluginCommand.run( + install: true, + uninstall: false, runtime: runtime, - currentExecutablePath: source.path + currentExecutablePath: source.path, + pathEnvironment: "" + ) + try MiniTest.expect( + mock.calls.isEmpty, + "plugin --install does not invoke container and does not require container system start" ) - let configPath = ContainerPluginLayout.configPath(installRoot: root.path) let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) + try MiniTest.expectEqual( + try FileManager.default.destinationOfSymbolicLink(atPath: binaryPath), + source.path + ) + try PluginTestSupport.expectRegularCLIConfig(installRoot: root.path) + }), + ("pluginUninstallWhileSystemNotRunning", { + let root = try PluginTestSupport.makeInstallRoot() + defer { try? FileManager.default.removeItem(at: root) } + try PluginTestSupport.stagePluginLayout(installRoot: root) + let mock = MockProcessRunner() + mock.handlers = [{ _ in + ProcessResult(exitCode: 1, stdout: Data(), stderr: Data("container system start\n".utf8)) + }] + let runtime = AppleContainerRuntime( + executablePath: PluginTestSupport.containerBinary(in: root), + runner: mock + ) + try PluginCommand.run( + install: false, + uninstall: true, + runtime: runtime, + pathEnvironment: "" + ) try MiniTest.expect( - FileManager.default.fileExists(atPath: configPath), - "install-plugin restages config.toml even when container services are not running" + mock.calls.isEmpty, + "plugin --uninstall does not invoke container and does not require container system start" ) + let configPath = ContainerPluginLayout.configPath(installRoot: root.path) + let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) + try MiniTest.expect(!FileManager.default.fileExists(atPath: configPath)) + try MiniTest.expect(!FileManager.default.fileExists(atPath: binaryPath)) try MiniTest.expect( - FileManager.default.isExecutableFile(atPath: binaryPath), - "install-plugin restages plugin binary even when container services are not running" + (try? FileManager.default.destinationOfSymbolicLink(atPath: binaryPath)) == nil ) - let staged = try Data(contentsOf: URL(fileURLWithPath: binaryPath)) - try MiniTest.expectEqual(staged, sourceBytes) }), ("doctorDoesNotRestageWhenSystemNotRunning", { let root = try PluginTestSupport.makeInstallRoot() diff --git a/Tests/adevcontainerTests/ContainerPluginTests.swift b/Tests/adevcontainerTests/ContainerPluginTests.swift index 59dcaee..68032fb 100644 --- a/Tests/adevcontainerTests/ContainerPluginTests.swift +++ b/Tests/adevcontainerTests/ContainerPluginTests.swift @@ -9,6 +9,22 @@ final class PluginTestFileManager: FileManager, @unchecked Sendable { } } +/// Records symlink replacement so an already-correct link can be proven untouched. +final class RemoveRecordingFileManager: FileManager, @unchecked Sendable { + var removedPaths: [String] = [] + var createdLinks: [(path: String, destination: String)] = [] + + override func removeItem(atPath path: String) throws { + removedPaths.append(path) + try super.removeItem(atPath: path) + } + + override func createSymbolicLink(atPath path: String, withDestinationPath destPath: String) throws { + createdLinks.append((path, destPath)) + try super.createSymbolicLink(atPath: path, withDestinationPath: destPath) + } +} + enum PluginTestSupport { static func makeInstallRoot() throws -> URL { let root = FileManager.default.temporaryDirectory @@ -65,6 +81,94 @@ enum PluginTestSupport { defer { CommandSurface.commandPrefix = previous } try body() } + + static func homebrewOptPath(prefix: URL) -> String { + "\(prefix.path)/opt/adevcontainer/bin/adevcontainer" + } + + /// Cellar binary is a symlink to `real` so a realpath of the keg is not the opt path. + static func makeHomebrewCellar( + prefix: URL, + contents: Data + ) throws -> (cellar: String, opt: String, real: String) { + let real = prefix.appendingPathComponent("real-keg-mach-o") + try writeExecutable(at: real, contents: contents) + let cellar = prefix + .appendingPathComponent("Cellar") + .appendingPathComponent("adevcontainer") + .appendingPathComponent("1.2.3") + .appendingPathComponent("bin") + .appendingPathComponent("adevcontainer") + try FileManager.default.createDirectory( + at: cellar.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.createSymbolicLink(atPath: cellar.path, withDestinationPath: real.path) + return (cellar.path, homebrewOptPath(prefix: prefix), real.path) + } + + static func installPlugin( + installRoot: URL, + currentExecutablePath: String, + runningExecutablePath: String? = nil, + pathEnvironment: String? = "", + fileManager: FileManager = .default + ) throws { + let runtime = try readyRuntime(executablePath: containerBinary(in: installRoot)) + try PluginCommand.run( + install: true, + uninstall: false, + runtime: runtime, + currentExecutablePath: currentExecutablePath, + runningExecutablePath: runningExecutablePath, + pathEnvironment: pathEnvironment, + fileManager: fileManager + ) + } + + static func uninstallPlugin(installRoot: URL, fileManager: FileManager = .default) throws { + let runtime = try readyRuntime(executablePath: containerBinary(in: installRoot)) + try PluginCommand.run( + install: false, + uninstall: true, + runtime: runtime, + pathEnvironment: "", + fileManager: fileManager + ) + } + + static func expectSymlink(at path: String, target: String, _ message: String) throws { + let destination = try FileManager.default.destinationOfSymbolicLink(atPath: path) + try MiniTest.expectEqual(destination, target, message) + try MiniTest.expect(destination.hasPrefix("/"), "\(message): target is absolute") + } + + static func expectRegularCLIConfig(installRoot: String) throws { + let configPath = ContainerPluginLayout.configPath(installRoot: installRoot) + try MiniTest.expect( + (try? FileManager.default.destinationOfSymbolicLink(atPath: configPath)) == nil, + "config.toml is a regular file, not a symlink" + ) + let toml = try String(contentsOfFile: configPath, encoding: .utf8) + try MiniTest.expect(toml.contains("abstract"), "config.toml includes abstract") + try MiniTest.expect(!toml.contains("[servicesConfig]"), "config.toml omits [servicesConfig]") + try MiniTest.expect(!toml.contains("servicesConfig"), "config.toml omits servicesConfig") + } + + static func posixMode(_ path: String) throws -> Int { + let attrs = try FileManager.default.attributesOfItem(atPath: path) + return (attrs[.posixPermissions] as? NSNumber)?.intValue ?? -1 + } + + static func expectExactlyOneFlagError(_ error: Error) throws { + let err = error as! CLIError + try MiniTest.expectEqual(err.code, CLIErrorCode.usage) + let text = err.message + " " + (err.hint ?? "") + try MiniTest.expect(text.contains("--install"), "names --install") + try MiniTest.expect(text.contains("--uninstall"), "names --uninstall") + try MiniTest.expect(text.contains("exactly one"), "states exactly one is required") + try MiniTest.expect(!text.contains("install-plugin"), "does not name install-plugin") + } } nonisolated(unsafe) let containerPluginTests: [(String, () throws -> Void)] = [ @@ -132,22 +236,37 @@ nonisolated(unsafe) let containerPluginTests: [(String, () throws -> Void)] = [ try MiniTest.expect(doctor.contains("container dev doctor"), "doctor help uses plugin prefix") try MiniTest.expect(!doctor.contains("--repair"), "doctor help does not document --repair") try MiniTest.expect( - doctor.contains("adevcontainer install-plugin"), - "missing-plugin help names PATH adevcontainer install-plugin" + doctor.contains("adevcontainer plugin --install"), + "missing-plugin help names PATH adevcontainer plugin --install" ) try MiniTest.expect( - !doctor.contains("container dev install-plugin"), - "missing-plugin help must not name container dev install-plugin" + !doctor.contains("container dev plugin --install"), + "missing-plugin help must not name container dev plugin --install" ) - try MiniTest.expect(usage.contains("install-plugin"), "usage lists install-plugin") + try MiniTest.expect(!doctor.contains("install-plugin"), "doctor help does not name install-plugin") + try MiniTest.expect(usage.contains("plugin --install"), "usage documents plugin --install") + try MiniTest.expect(usage.contains("--uninstall"), "usage documents --uninstall") + try MiniTest.expect(usage.contains("exactly one"), "usage states exactly one flag is required") + try MiniTest.expect(!usage.contains("install-plugin"), "usage does not document install-plugin") try MiniTest.expect(!usage.contains("--repair"), "usage does not document --repair") - let install = CommandSurface.commandHelpText("install-plugin") ?? "" - try MiniTest.expect(install.contains("install-plugin"), "install-plugin help is present") - try MiniTest.expect(!install.contains("--repair"), "install-plugin help does not document --repair") + let plugin = CommandSurface.commandHelpText("plugin") ?? "" + try MiniTest.expect(plugin.contains("container dev plugin --install"), "plugin help stays invocation-aware") + try MiniTest.expect(plugin.contains("--uninstall"), "plugin help documents --uninstall") + try MiniTest.expect( + plugin.lowercased().contains("exactly one"), + "plugin help states exactly one flag is required" + ) + try MiniTest.expect( + plugin.contains("adevcontainer plugin --install"), + "plugin help restage sentence uses PATH" + ) + try MiniTest.expect(!plugin.contains("install-plugin"), "plugin help does not document install-plugin") + try MiniTest.expect(!plugin.contains("--repair"), "plugin help does not document --repair") + try MiniTest.expect(CommandSurface.commandHelpText("install-plugin") == nil, "install-plugin is not an alias") let hint = ContainerIdentity.nameInUseError(name: "ctr").hint ?? "" try MiniTest.expect(hint.contains("container dev delete --name ctr"), "retry hint uses plugin prefix") try MiniTest.expect( - !hint.contains("install-plugin"), + !hint.contains("plugin --install"), "occupancy hint is not missing-plugin remediation" ) } @@ -163,18 +282,25 @@ nonisolated(unsafe) let containerPluginTests: [(String, () throws -> Void)] = [ try MiniTest.expect(doctor.contains("adevcontainer doctor"), "doctor help uses PATH prefix") try MiniTest.expect(!doctor.contains("--repair"), "doctor help does not document --repair") try MiniTest.expect( - doctor.contains("adevcontainer install-plugin"), - "missing-plugin help names PATH adevcontainer install-plugin" + doctor.contains("adevcontainer plugin --install"), + "missing-plugin help names PATH adevcontainer plugin --install" ) try MiniTest.expect( - !doctor.contains("container dev install-plugin"), - "missing-plugin help must not name container dev install-plugin" + !doctor.contains("container dev plugin --install"), + "missing-plugin help must not name container dev plugin --install" ) - try MiniTest.expect(usage.contains("install-plugin"), "usage lists install-plugin") + try MiniTest.expect(!usage.contains("install-plugin"), "usage does not document install-plugin") try MiniTest.expect(!usage.contains("--repair"), "usage does not document --repair") - let install = CommandSurface.commandHelpText("install-plugin") ?? "" - try MiniTest.expect(install.contains("adevcontainer install-plugin"), "install-plugin help uses PATH prefix") - try MiniTest.expect(!install.contains("--repair"), "install-plugin help does not document --repair") + try MiniTest.expect(usage.contains("plugin --install") && usage.contains("--uninstall")) + let plugin = CommandSurface.commandHelpText("plugin") ?? "" + try MiniTest.expect(plugin.contains("adevcontainer plugin --install"), "plugin help uses PATH prefix") + try MiniTest.expect(plugin.contains("--uninstall")) + try MiniTest.expect(!plugin.contains("install-plugin")) + try MiniTest.expect(!plugin.contains("--repair")) + try MiniTest.expect( + !plugin.contains("container dev plugin --install"), + "PATH plugin help must not name container dev plugin --install" + ) let hint = ContainerIdentity.nameInUseError(name: "ctr").hint ?? "" try MiniTest.expect(hint.contains("adevcontainer delete --name ctr"), "retry hint uses PATH prefix") } @@ -189,7 +315,31 @@ nonisolated(unsafe) let containerPluginTests: [(String, () throws -> Void)] = [ } }), - ("repairFlagFailsAsUsageAndHintsInstallPlugin", { + ("pluginWithNeitherFlagIsUsageError", { + let parsed = try CommandSurface.parseArgs([]) + try MiniTest.expectThrows({ + try CommandSurface.enforcePluginInvocation(subcommand: "plugin", parsed: parsed) + }, validate: PluginTestSupport.expectExactlyOneFlagError) + }), + + ("pluginWithBothFlagsIsUsageError", { + let parsed = try CommandSurface.parseArgs(["--install", "--uninstall"]) + try MiniTest.expectThrows({ + try CommandSurface.enforcePluginInvocation(subcommand: "plugin", parsed: parsed) + }, validate: PluginTestSupport.expectExactlyOneFlagError) + }), + + ("installPluginIsNotACommand", { + try MiniTest.expect(CommandSurface.commandHelpText("install-plugin") == nil, "not an alias") + try MiniTest.expect(!CommandSurface.usageText().contains("install-plugin")) + let err = CommandSurface.unknownSubcommandError(subcommand: "install-plugin") + try MiniTest.expectEqual(err.code, CLIErrorCode.usage) + let hint = err.hint ?? "" + try MiniTest.expect(hint.contains("plugin"), "unknown-command hint names plugin") + try MiniTest.expect(!hint.contains("install-plugin"), "unknown-command hint does not list install-plugin") + }), + + ("repairFlagFailsAsUsageAndHintsPluginInstall", { try MiniTest.expectThrows({ _ = try CommandSurface.parseArgs(["--repair"]) }) { error in @@ -197,13 +347,14 @@ nonisolated(unsafe) let containerPluginTests: [(String, () throws -> Void)] = [ try MiniTest.expectEqual(err.code, CLIErrorCode.usage) try MiniTest.expectEqual(err.property, "--repair") let hint = err.hint ?? "" - try MiniTest.expect(hint.contains("install-plugin"), "parse --repair hints install-plugin") + try MiniTest.expect(hint.contains("plugin --install"), "parse --repair hints plugin --install") + try MiniTest.expect(!hint.contains("install-plugin"), "parse --repair does not name install-plugin") try MiniTest.expect( - !hint.contains("container dev install-plugin"), - "parse --repair must not suggest container dev install-plugin" + !hint.contains("container dev plugin"), + "parse --repair must not suggest container dev plugin --install" ) } - for subcommand in ["doctor", "install-plugin", "up"] { + for subcommand in ["doctor", "plugin", "up"] { try MiniTest.expectThrows({ try CommandSurface.enforceWorkspaceGate( subcommand: subcommand, @@ -215,18 +366,22 @@ nonisolated(unsafe) let containerPluginTests: [(String, () throws -> Void)] = [ try MiniTest.expectEqual(err.property, "--repair") let hint = err.hint ?? "" try MiniTest.expect( - hint.contains("install-plugin"), - "\(subcommand) --repair hints install-plugin" + hint.contains("plugin --install"), + "\(subcommand) --repair hints plugin --install" ) try MiniTest.expect( - !hint.contains("container dev install-plugin"), - "\(subcommand) --repair must not suggest container dev install-plugin" + !hint.contains("install-plugin"), + "\(subcommand) --repair does not name install-plugin" + ) + try MiniTest.expect( + !hint.contains("container dev plugin"), + "\(subcommand) --repair must not suggest container dev plugin --install" ) } } }), - ("doctorMissingPluginReportsPathInstallPluginNotPluginInstallPlugin", { + ("doctorMissingPluginReportsPathPluginInstall", { let root = try PluginTestSupport.makeInstallRoot() defer { try? FileManager.default.removeItem(at: root) } let runtime = try PluginTestSupport.readyRuntime( @@ -240,188 +395,595 @@ nonisolated(unsafe) let containerPluginTests: [(String, () throws -> Void)] = [ let message = err.message try MiniTest.expect(message.lowercased().contains("plugin"), "names the missing plugin") try MiniTest.expect( - hint.contains("adevcontainer install-plugin"), + hint.contains("adevcontainer plugin --install"), "PATH remediation for a missing plugin" ) + try MiniTest.expect(!hint.contains("sudo"), "writable destination does not ask for sudo") try MiniTest.expect( - !hint.contains("container dev install-plugin"), + !hint.contains("container dev plugin --install"), "must not suggest plugin invocation when the plugin is missing" ) + try MiniTest.expect(!hint.contains("install-plugin"), "must not name install-plugin") try MiniTest.expect(!hint.contains("doctor --repair"), "must not hint doctor --repair") } let pluginDir = ContainerPluginLayout.pluginDirectory(installRoot: root.path) try MiniTest.expect(!FileManager.default.fileExists(atPath: pluginDir), "doctor does not restage") }), - ("installPluginStagesPluginLayoutAndSameMachO", { + ("pluginInstallCreatesAbsoluteSymlinkAndConfig", { + let root = try PluginTestSupport.makeInstallRoot() + defer { try? FileManager.default.removeItem(at: root) } + let source = root.appendingPathComponent("bin/adevcontainer") + try PluginTestSupport.writeExecutable(at: source, contents: Data("path-mach-o\n".utf8)) + try PluginTestSupport.installPlugin(installRoot: root, currentExecutablePath: source.path) + let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) + try PluginTestSupport.expectSymlink(at: binaryPath, target: source.path, "absolute symlink to the installed executable") + try PluginTestSupport.expectRegularCLIConfig(installRoot: root.path) + let opt = PluginTestSupport.homebrewOptPath(prefix: root) + try MiniTest.expect( + try FileManager.default.destinationOfSymbolicLink(atPath: binaryPath) != opt, + "a regular file under bin/ is not treated as the Homebrew formula" + ) + }), + + ("pluginInstallReplacesCopiedPluginBinary", { + let root = try PluginTestSupport.makeInstallRoot() + defer { try? FileManager.default.removeItem(at: root) } + let source = root.appendingPathComponent("installed/adevcontainer") + try PluginTestSupport.writeExecutable(at: source, contents: Data("kept-target\n".utf8)) + let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) + try PluginTestSupport.writeExecutable( + at: URL(fileURLWithPath: binaryPath), + contents: Data("old-copy\n".utf8) + ) + try PluginTestSupport.installPlugin(installRoot: root, currentExecutablePath: source.path) + try PluginTestSupport.expectSymlink(at: binaryPath, target: source.path, "replaces the regular-file copy") + let followed = try String(contentsOfFile: binaryPath, encoding: .utf8) + try MiniTest.expectEqual(followed, "kept-target\n") + try MiniTest.expectEqual(try String(contentsOfFile: source.path, encoding: .utf8), "kept-target\n") + }), + + ("pluginInstallReplacesWrongSymlinkWithoutDeletingTarget", { + let root = try PluginTestSupport.makeInstallRoot() + defer { try? FileManager.default.removeItem(at: root) } + let source = root.appendingPathComponent("installed/adevcontainer") + try PluginTestSupport.writeExecutable(at: source) + let other = root.appendingPathComponent("old-target") + try PluginTestSupport.writeExecutable(at: other, contents: Data("do-not-delete\n".utf8)) + let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) + try FileManager.default.createDirectory( + at: URL(fileURLWithPath: binaryPath).deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.createSymbolicLink(atPath: binaryPath, withDestinationPath: other.path) + try PluginTestSupport.installPlugin(installRoot: root, currentExecutablePath: source.path) + try PluginTestSupport.expectSymlink(at: binaryPath, target: source.path, "replaces the wrong absolute symlink") + try MiniTest.expectEqual( + try String(contentsOfFile: other.path, encoding: .utf8), + "do-not-delete\n", + "old symlink target still exists" + ) + }), + + ("pluginInstallReplacesRelativeSymlinkWithoutDeletingTarget", { let root = try PluginTestSupport.makeInstallRoot() defer { try? FileManager.default.removeItem(at: root) } - let sourceBytes = Data("same-mach-o-\(UUID().uuidString)\n".utf8) let source = root.appendingPathComponent("adevcontainer-src") - try sourceBytes.write(to: source) - let runtime = try PluginTestSupport.readyRuntime( - executablePath: PluginTestSupport.containerBinary(in: root) + try PluginTestSupport.writeExecutable(at: source, contents: Data("same-file\n".utf8)) + let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) + try FileManager.default.createDirectory( + at: URL(fileURLWithPath: binaryPath).deletingLastPathComponent(), + withIntermediateDirectories: true ) - try InstallPluginCommand.run( - runtime: runtime, - currentExecutablePath: source.path + let relative = "../../../../adevcontainer-src" + try FileManager.default.createSymbolicLink(atPath: binaryPath, withDestinationPath: relative) + try MiniTest.expectEqual( + URL(fileURLWithPath: binaryPath).resolvingSymlinksInPath().path, + URL(fileURLWithPath: source.path).resolvingSymlinksInPath().path, + "relative symlink would resolve to the required file" + ) + try PluginTestSupport.installPlugin(installRoot: root, currentExecutablePath: source.path) + try PluginTestSupport.expectSymlink( + at: binaryPath, + target: source.path, + "replaces a relative symlink even when it resolves to the same file" ) + try MiniTest.expect(FileManager.default.fileExists(atPath: source.path), "old target still exists") + }), + ("pluginInstallKeepsAlreadyCorrectSymlink", { + let root = try PluginTestSupport.makeInstallRoot() + defer { try? FileManager.default.removeItem(at: root) } + let source = root.appendingPathComponent("installed/adevcontainer") + try PluginTestSupport.writeExecutable(at: source) + let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) + try FileManager.default.createDirectory( + at: URL(fileURLWithPath: binaryPath).deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.createSymbolicLink(atPath: binaryPath, withDestinationPath: source.path) + let recorder = RemoveRecordingFileManager() + try PluginTestSupport.installPlugin( + installRoot: root, + currentExecutablePath: source.path, + fileManager: recorder + ) + try MiniTest.expect( + !recorder.removedPaths.contains(binaryPath), + "already-correct absolute symlink is not removed" + ) + try MiniTest.expect( + !recorder.createdLinks.contains { $0.path == binaryPath }, + "already-correct absolute symlink is not replaced" + ) + try PluginTestSupport.expectSymlink(at: binaryPath, target: source.path, "symlink target unchanged") + try PluginTestSupport.expectRegularCLIConfig(installRoot: root.path) + }), + + ("pluginInstallReplacesSymlinkedConfigWithoutWritingThrough", { + let root = try PluginTestSupport.makeInstallRoot() + defer { try? FileManager.default.removeItem(at: root) } + let source = root.appendingPathComponent("installed/adevcontainer") + try PluginTestSupport.writeExecutable(at: source) + let secret = root.appendingPathComponent("secret-config.toml") + try Data("SECRET-TARGET\n".utf8).write(to: secret) let configPath = ContainerPluginLayout.configPath(installRoot: root.path) + try FileManager.default.createDirectory( + at: URL(fileURLWithPath: configPath).deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.createSymbolicLink(atPath: configPath, withDestinationPath: secret.path) + try PluginTestSupport.installPlugin(installRoot: root, currentExecutablePath: source.path) + try PluginTestSupport.expectRegularCLIConfig(installRoot: root.path) + try MiniTest.expectEqual( + try String(contentsOfFile: secret.path, encoding: .utf8), + "SECRET-TARGET\n", + "previous config.toml symlink target is unchanged" + ) + }), + + ("pluginInstallHomebrewLinksOptPath", { + let root = try PluginTestSupport.makeInstallRoot() + defer { try? FileManager.default.removeItem(at: root) } + let prefix = root.appendingPathComponent("homebrew") + let bytes = Data("keg-bytes-\(UUID().uuidString)\n".utf8) + let formula = try PluginTestSupport.makeHomebrewCellar(prefix: prefix, contents: bytes) + try FileManager.default.setAttributes([.posixPermissions: 0o754], ofItemAtPath: formula.real) + let modeBefore = try PluginTestSupport.posixMode(formula.real) + try PluginTestSupport.installPlugin(installRoot: root, currentExecutablePath: formula.cellar) let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) - try MiniTest.expect(FileManager.default.fileExists(atPath: configPath), "config.toml staged") - try MiniTest.expect(FileManager.default.isExecutableFile(atPath: binaryPath), "plugin binary staged executable") - let toml = try String(contentsOfFile: configPath, encoding: .utf8) - try MiniTest.expect(toml.contains("abstract"), "config.toml includes abstract") - try MiniTest.expect(!toml.contains("[servicesConfig]"), "config.toml omits [servicesConfig]") - try MiniTest.expect(!toml.contains("servicesConfig"), "config.toml omits servicesConfig") - let staged = try Data(contentsOf: URL(fileURLWithPath: binaryPath)) - try MiniTest.expectEqual(staged, sourceBytes) + try PluginTestSupport.expectSymlink(at: binaryPath, target: formula.opt, "Cellar path links the opt path") + try MiniTest.expect(!formula.opt.contains("/Cellar/"), "opt path is not a Cellar path") + try MiniTest.expect(formula.opt != formula.real, "opt path is not the realpath of the keg") + try MiniTest.expectEqual(try Data(contentsOf: URL(fileURLWithPath: formula.real)), bytes) + try MiniTest.expectEqual(try PluginTestSupport.posixMode(formula.real), modeBefore, "does not chmod the keg") + try PluginTestSupport.expectRegularCLIConfig(installRoot: root.path) }), - ("installPluginBareArgv0RestagesFromRunningMachO", { + ("pluginInstallHomebrewBareArgv0LinksOptPath", { let root = try PluginTestSupport.makeInstallRoot() defer { try? FileManager.default.removeItem(at: root) } let cwd = FileManager.default.temporaryDirectory - .appendingPathComponent("adev-bare-argv0-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("adev-sudo-argv0-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: cwd, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: cwd) } let previousCwd = FileManager.default.currentDirectoryPath + try MiniTest.expect(FileManager.default.changeCurrentDirectoryPath(cwd.path)) + defer { FileManager.default.changeCurrentDirectoryPath(previousCwd) } + try MiniTest.expect(!FileManager.default.fileExists(atPath: cwd.appendingPathComponent("adevcontainer").path)) + let prefix = root.appendingPathComponent("homebrew") + let formula = try PluginTestSupport.makeHomebrewCellar( + prefix: prefix, + contents: Data("sudo-keg\n".utf8) + ) + try PluginTestSupport.installPlugin( + installRoot: root, + currentExecutablePath: "adevcontainer", + runningExecutablePath: formula.cellar, + pathEnvironment: "" + ) + let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) + try PluginTestSupport.expectSymlink( + at: binaryPath, + target: formula.opt, + "sudo bare argv0 of a Cellar binary links the opt path" + ) try MiniTest.expect( - FileManager.default.changeCurrentDirectoryPath(cwd.path), - "switch to a cwd that does not contain adevcontainer" + try FileManager.default.destinationOfSymbolicLink(atPath: binaryPath) != "adevcontainer", + "bare argv0 is not the symlink target" ) - defer { FileManager.default.changeCurrentDirectoryPath(previousCwd) } + }), + + ("pluginInstallHomebrewBinSymlinkLinksOptPath", { + let root = try PluginTestSupport.makeInstallRoot() + defer { try? FileManager.default.removeItem(at: root) } + let prefix = root.appendingPathComponent("homebrew") + let formula = try PluginTestSupport.makeHomebrewCellar(prefix: prefix, contents: Data("bin-link\n".utf8)) + let binLink = prefix.appendingPathComponent("bin/adevcontainer") + try FileManager.default.createDirectory(at: binLink.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink( + atPath: binLink.path, + withDestinationPath: "../Cellar/adevcontainer/1.2.3/bin/adevcontainer" + ) + try PluginTestSupport.installPlugin(installRoot: root, currentExecutablePath: binLink.path) + let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) + try PluginTestSupport.expectSymlink(at: binaryPath, target: formula.opt, "brew bin symlink links the opt path") try MiniTest.expect( - !FileManager.default.fileExists(atPath: cwd.appendingPathComponent("adevcontainer").path), - "argv0 basename is not a file in cwd" + try FileManager.default.destinationOfSymbolicLink(atPath: binaryPath) != formula.cellar, + "does not link the Cellar path" ) - let sourceBytes = Data("running-mach-o-\(UUID().uuidString)\n".utf8) - let running = root.appendingPathComponent("running/adevcontainer") - try PluginTestSupport.writeExecutable(at: running, contents: sourceBytes) - let runtime = try PluginTestSupport.readyRuntime( - executablePath: PluginTestSupport.containerBinary(in: root) + try MiniTest.expect( + try FileManager.default.destinationOfSymbolicLink(atPath: binaryPath) != formula.real, + "does not link the realpath of the keg" ) - try InstallPluginCommand.run( - runtime: runtime, - currentExecutablePath: "adevcontainer", - runningExecutablePath: running.path, - pathEnvironment: "" + }), + + ("pluginInstallFailsClosedWhenOptPathCannotBeFormed", { + let root = try PluginTestSupport.makeInstallRoot() + defer { try? FileManager.default.removeItem(at: root) } + let unformable = "/Cellar/adevcontainer/1.0.0/bin/adevcontainer" + try MiniTest.expectThrows({ + try PluginTestSupport.installPlugin(installRoot: root, currentExecutablePath: unformable) + }) { error in + let err = error as! CLIError + try MiniTest.expect( + err.message.contains("opt/adevcontainer/bin/adevcontainer"), + "names the opt path that could not be formed" + ) + try MiniTest.expect(!err.message.contains("linked \(unformable)"), "does not offer the Cellar path as the link") + } + let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) + try MiniTest.expect(!FileManager.default.fileExists(atPath: binaryPath), "does not create a Cellar-path symlink") + try MiniTest.expect( + (try? FileManager.default.destinationOfSymbolicLink(atPath: binaryPath)) == nil, + "does not create a realpath-resolved symlink" ) + }), + + ("pluginInstallNonHomebrewDoesNotRealpath", { + let root = try PluginTestSupport.makeInstallRoot() + defer { try? FileManager.default.removeItem(at: root) } + let real = root.appendingPathComponent("real/adevcontainer") + try PluginTestSupport.writeExecutable(at: real, contents: Data("real-bytes\n".utf8)) + let link = root.appendingPathComponent("linked/adevcontainer") + try FileManager.default.createDirectory(at: link.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(atPath: link.path, withDestinationPath: real.path) + try PluginTestSupport.installPlugin(installRoot: root, currentExecutablePath: link.path) let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) - let staged = try Data(contentsOf: URL(fileURLWithPath: binaryPath)) - try MiniTest.expectEqual(staged, sourceBytes) - let destLink = try URL(fileURLWithPath: binaryPath).resourceValues(forKeys: [.isSymbolicLinkKey]) - try MiniTest.expect(destLink.isSymbolicLink != true, "staged plugin is the Mach-O, not a symlink") + try PluginTestSupport.expectSymlink(at: binaryPath, target: link.path, "does not realpath the running executable") + try MiniTest.expect( + try FileManager.default.destinationOfSymbolicLink(atPath: binaryPath) != real.path, + "symlink target is not the resolved path" + ) }), - ("installPluginBareArgv0SearchesPATHWhenRunningMissing", { + ("pluginInstallBareArgv0IsNotRelativeSourcePath", { let root = try PluginTestSupport.makeInstallRoot() defer { try? FileManager.default.removeItem(at: root) } let cwd = FileManager.default.temporaryDirectory - .appendingPathComponent("adev-path-argv0-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("adev-bare-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: cwd, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: cwd) } let previousCwd = FileManager.default.currentDirectoryPath try MiniTest.expect(FileManager.default.changeCurrentDirectoryPath(cwd.path)) defer { FileManager.default.changeCurrentDirectoryPath(previousCwd) } - let sourceBytes = Data("path-mach-o-\(UUID().uuidString)\n".utf8) + try MiniTest.expect(!FileManager.default.fileExists(atPath: cwd.appendingPathComponent("adevcontainer").path)) + let running = root.appendingPathComponent("running/adevcontainer") + try PluginTestSupport.writeExecutable(at: running, contents: Data("running-abs\n".utf8)) + try PluginTestSupport.installPlugin( + installRoot: root, + currentExecutablePath: "adevcontainer", + runningExecutablePath: running.path, + pathEnvironment: "" + ) + let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) + try PluginTestSupport.expectSymlink(at: binaryPath, target: running.path, "bare argv0 uses the absolute running path") + try MiniTest.expect( + try FileManager.default.destinationOfSymbolicLink(atPath: binaryPath) != "adevcontainer" + ) + try MiniTest.expect( + !FileManager.default.fileExists(atPath: cwd.appendingPathComponent("adevcontainer").path), + "does not create a relative argv0 file in cwd" + ) + }), + + ("pluginInstallBareArgv0UsesPathCandidateWithoutRealpath", { + let root = try PluginTestSupport.makeInstallRoot() + defer { try? FileManager.default.removeItem(at: root) } + let real = root.appendingPathComponent("real/adevcontainer") + try PluginTestSupport.writeExecutable(at: real) let pathDir = root.appendingPathComponent("path-bin", isDirectory: true) + try FileManager.default.createDirectory(at: pathDir, withIntermediateDirectories: true) let onPath = pathDir.appendingPathComponent("adevcontainer") - try PluginTestSupport.writeExecutable(at: onPath, contents: sourceBytes) - let runtime = try PluginTestSupport.readyRuntime( - executablePath: PluginTestSupport.containerBinary(in: root) - ) - try InstallPluginCommand.run( - runtime: runtime, + try FileManager.default.createSymbolicLink(atPath: onPath.path, withDestinationPath: real.path) + try PluginTestSupport.installPlugin( + installRoot: root, currentExecutablePath: "adevcontainer", runningExecutablePath: root.appendingPathComponent("no-such-running").path, pathEnvironment: pathDir.path ) let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) - let staged = try Data(contentsOf: URL(fileURLWithPath: binaryPath)) - try MiniTest.expectEqual(staged, sourceBytes) + try PluginTestSupport.expectSymlink(at: binaryPath, target: onPath.path, "PATH hit is not realpath-resolved") }), - ("installPluginCopiesSymlinkTargetMachO", { + ("pluginInstallArgv0PluginPathDoesNotSelfLink", { let root = try PluginTestSupport.makeInstallRoot() defer { try? FileManager.default.removeItem(at: root) } - let sourceBytes = Data("cellar-mach-o-\(UUID().uuidString)\n".utf8) - let cellar = root.appendingPathComponent("Cellar/adevcontainer/0.1.0/bin/adevcontainer") - try PluginTestSupport.writeExecutable(at: cellar, contents: sourceBytes) - let brewBin = root.appendingPathComponent("opt/homebrew/bin", isDirectory: true) - try FileManager.default.createDirectory(at: brewBin, withIntermediateDirectories: true) - let link = brewBin.appendingPathComponent("adevcontainer") - try FileManager.default.createSymbolicLink( - atPath: link.path, - withDestinationPath: cellar.path + let real = root.appendingPathComponent("real/adevcontainer") + try PluginTestSupport.writeExecutable(at: real, contents: Data("installed-exec\n".utf8)) + let installed = root.appendingPathComponent("linked/adevcontainer") + try FileManager.default.createDirectory(at: installed.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(atPath: installed.path, withDestinationPath: real.path) + let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) + try FileManager.default.createDirectory( + at: URL(fileURLWithPath: binaryPath).deletingLastPathComponent(), + withIntermediateDirectories: true ) - let runtime = try PluginTestSupport.readyRuntime( - executablePath: PluginTestSupport.containerBinary(in: root) + try FileManager.default.createSymbolicLink(atPath: binaryPath, withDestinationPath: installed.path) + let recorder = RemoveRecordingFileManager() + try PluginTestSupport.installPlugin( + installRoot: root, + currentExecutablePath: binaryPath, + runningExecutablePath: real.path, + pathEnvironment: "", + fileManager: recorder ) - try InstallPluginCommand.run( - runtime: runtime, - currentExecutablePath: link.path + try PluginTestSupport.expectSymlink( + at: binaryPath, + target: installed.path, + "plugin argv0 keeps the existing absolute link text" ) + try MiniTest.expect( + try FileManager.default.destinationOfSymbolicLink(atPath: binaryPath) != binaryPath, + "does not symlink bin/dev to itself" + ) + try MiniTest.expect( + try FileManager.default.destinationOfSymbolicLink(atPath: binaryPath) != real.path, + "does not realpath the existing link text" + ) + try MiniTest.expect(!recorder.removedPaths.contains(binaryPath), "already-correct link is not replaced") + }), + + ("pluginInstallProcessNameDevDoesNotSelfLink", { + let root = try PluginTestSupport.makeInstallRoot() + defer { try? FileManager.default.removeItem(at: root) } + let installed = root.appendingPathComponent("installed/adevcontainer") + try PluginTestSupport.writeExecutable(at: installed, contents: Data("from-dev-argv0\n".utf8)) let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) - let staged = try Data(contentsOf: URL(fileURLWithPath: binaryPath)) - try MiniTest.expectEqual(staged, sourceBytes) - let destLink = try URL(fileURLWithPath: binaryPath).resourceValues(forKeys: [.isSymbolicLinkKey]) - try MiniTest.expect(destLink.isSymbolicLink != true, "Homebrew symlink is resolved before copy") + try FileManager.default.createDirectory( + at: URL(fileURLWithPath: binaryPath).deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.createSymbolicLink(atPath: binaryPath, withDestinationPath: installed.path) + try PluginTestSupport.installPlugin( + installRoot: root, + currentExecutablePath: "dev", + runningExecutablePath: binaryPath, + pathEnvironment: "" + ) + try PluginTestSupport.expectSymlink( + at: binaryPath, + target: installed.path, + "process name dev uses the existing absolute link text" + ) + try MiniTest.expect( + try FileManager.default.destinationOfSymbolicLink(atPath: binaryPath) != binaryPath, + "process name dev does not symlink bin/dev to itself" + ) }), - ("installPluginDoesNotUnlinkSelfWhenSourceIsDest", { + ("pluginInstallArgv0PluginPathCellarLinkBecomesOptPath", { let root = try PluginTestSupport.makeInstallRoot() defer { try? FileManager.default.removeItem(at: root) } - let bytes = Data("plugin-self-\(UUID().uuidString)\n".utf8) - try PluginTestSupport.stagePluginLayout(installRoot: root, executableContents: bytes) - let dest = ContainerPluginLayout.binaryPath(installRoot: root.path) - let configPath = ContainerPluginLayout.configPath(installRoot: root.path) - try FileManager.default.removeItem(atPath: configPath) - let runtime = try PluginTestSupport.readyRuntime( - executablePath: PluginTestSupport.containerBinary(in: root) + let prefix = root.appendingPathComponent("homebrew") + let bytes = Data("cellar-via-plugin-argv0\n".utf8) + let formula = try PluginTestSupport.makeHomebrewCellar(prefix: prefix, contents: bytes) + let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) + try FileManager.default.createDirectory( + at: URL(fileURLWithPath: binaryPath).deletingLastPathComponent(), + withIntermediateDirectories: true ) - try InstallPluginCommand.run( - runtime: runtime, - currentExecutablePath: dest + try FileManager.default.createSymbolicLink(atPath: binaryPath, withDestinationPath: formula.cellar) + try PluginTestSupport.installPlugin( + installRoot: root, + currentExecutablePath: binaryPath, + runningExecutablePath: formula.real, + pathEnvironment: "" + ) + try PluginTestSupport.expectSymlink( + at: binaryPath, + target: formula.opt, + "Cellar link text becomes the opt path" ) try MiniTest.expect( - FileManager.default.isExecutableFile(atPath: dest), - "plugin binary still present after plugin-invoked install-plugin" + try FileManager.default.destinationOfSymbolicLink(atPath: binaryPath) != formula.cellar ) - let staged = try Data(contentsOf: URL(fileURLWithPath: dest)) - try MiniTest.expectEqual(staged, bytes) try MiniTest.expect( - FileManager.default.fileExists(atPath: configPath), - "config.toml written without unlinking the running plugin" + try FileManager.default.destinationOfSymbolicLink(atPath: binaryPath) != formula.real, + "does not realpath the keg" ) - let toml = try String(contentsOfFile: configPath, encoding: .utf8) - try MiniTest.expect(toml.contains("abstract")) - try MiniTest.expect(!toml.contains("[servicesConfig]")) + try MiniTest.expect( + try FileManager.default.destinationOfSymbolicLink(atPath: binaryPath) != binaryPath + ) + try MiniTest.expectEqual(try Data(contentsOf: URL(fileURLWithPath: formula.real)), bytes) + try MiniTest.expect( + FileManager.default.fileExists(atPath: formula.cellar), + "Cellar target still exists" + ) + }), + + ("pluginInstallArgv0PluginPathKeepsOptLink", { + let root = try PluginTestSupport.makeInstallRoot() + defer { try? FileManager.default.removeItem(at: root) } + let prefix = root.appendingPathComponent("homebrew") + let formula = try PluginTestSupport.makeHomebrewCellar(prefix: prefix, contents: Data("opt-kept\n".utf8)) + let optURL = URL(fileURLWithPath: formula.opt) + try FileManager.default.createDirectory(at: optURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(atPath: formula.opt, withDestinationPath: formula.real) + let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) + try FileManager.default.createDirectory( + at: URL(fileURLWithPath: binaryPath).deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.createSymbolicLink(atPath: binaryPath, withDestinationPath: formula.opt) + let recorder = RemoveRecordingFileManager() + try PluginTestSupport.installPlugin( + installRoot: root, + currentExecutablePath: binaryPath, + runningExecutablePath: formula.real, + pathEnvironment: "", + fileManager: recorder + ) + try PluginTestSupport.expectSymlink(at: binaryPath, target: formula.opt, "already-correct opt link is kept") + try MiniTest.expect(!recorder.removedPaths.contains(binaryPath), "opt link is not replaced") + try MiniTest.expect( + try FileManager.default.destinationOfSymbolicLink(atPath: binaryPath) != formula.real + ) + }), + + ("pluginInstallArgv0PluginPathRegularFileUsesPathAdevcontainer", { + let root = try PluginTestSupport.makeInstallRoot() + defer { try? FileManager.default.removeItem(at: root) } + let onPath = root.appendingPathComponent("path-bin/adevcontainer") + try PluginTestSupport.writeExecutable(at: onPath, contents: Data("path-install\n".utf8)) + try PluginTestSupport.stagePluginLayout(installRoot: root, executableContents: Data("leftover-copy\n".utf8)) + let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) + try PluginTestSupport.installPlugin( + installRoot: root, + currentExecutablePath: binaryPath, + pathEnvironment: onPath.deletingLastPathComponent().path + ) + try PluginTestSupport.expectSymlink( + at: binaryPath, + target: onPath.path, + "regular-file plugin binary falls back to PATH adevcontainer" + ) + try MiniTest.expect( + try FileManager.default.destinationOfSymbolicLink(atPath: binaryPath) != binaryPath + ) + }), + + ("pluginInstallArgv0PluginPathSelfSymlinkUsesPathAdevcontainer", { + let root = try PluginTestSupport.makeInstallRoot() + defer { try? FileManager.default.removeItem(at: root) } + let onPath = root.appendingPathComponent("path-bin/adevcontainer") + try PluginTestSupport.writeExecutable(at: onPath, contents: Data("not-self\n".utf8)) + let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) + try FileManager.default.createDirectory( + at: URL(fileURLWithPath: binaryPath).deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.createSymbolicLink(atPath: binaryPath, withDestinationPath: binaryPath) + try PluginTestSupport.installPlugin( + installRoot: root, + currentExecutablePath: binaryPath, + pathEnvironment: onPath.deletingLastPathComponent().path + ) + try PluginTestSupport.expectSymlink( + at: binaryPath, + target: onPath.path, + "self-symlink falls back to PATH adevcontainer" + ) + try MiniTest.expect( + try FileManager.default.destinationOfSymbolicLink(atPath: binaryPath) != binaryPath + ) + }), + + ("pluginInstallArgv0PluginPathUsesHomebrewOptBinary", { + let root = try PluginTestSupport.makeInstallRoot() + defer { try? FileManager.default.removeItem(at: root) } + let prefix = root.appendingPathComponent("homebrew") + let formula = try PluginTestSupport.makeHomebrewCellar(prefix: prefix, contents: Data("opt-fallback\n".utf8)) + let optURL = URL(fileURLWithPath: formula.opt) + try FileManager.default.createDirectory(at: optURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(atPath: formula.opt, withDestinationPath: formula.real) + try PluginTestSupport.stagePluginLayout(installRoot: root, executableContents: Data("leftover\n".utf8)) + let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) + let brewBin = prefix.appendingPathComponent("bin").path + try FileManager.default.createDirectory(at: URL(fileURLWithPath: brewBin), withIntermediateDirectories: true) + try PluginTestSupport.installPlugin( + installRoot: root, + currentExecutablePath: binaryPath, + pathEnvironment: brewBin + ) + try PluginTestSupport.expectSymlink( + at: binaryPath, + target: formula.opt, + "regular-file plugin binary falls back to the Homebrew opt binary" + ) + try MiniTest.expect( + try FileManager.default.destinationOfSymbolicLink(atPath: binaryPath) != formula.real, + "opt fallback is not the realpath of the keg" + ) + try MiniTest.expect( + try FileManager.default.destinationOfSymbolicLink(atPath: binaryPath) != binaryPath + ) + }), + + ("pluginInstallArgv0PluginPathFailsInsteadOfSelfLink", { + let root = try PluginTestSupport.makeInstallRoot() + defer { try? FileManager.default.removeItem(at: root) } + try PluginTestSupport.stagePluginLayout(installRoot: root, executableContents: Data("only-copy\n".utf8)) + let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) + try MiniTest.expectThrows({ + try PluginTestSupport.installPlugin( + installRoot: root, + currentExecutablePath: binaryPath, + runningExecutablePath: binaryPath, + pathEnvironment: "" + ) + }) { error in + let err = error as! CLIError + let hint = err.hint ?? "" + try MiniTest.expect(err.message.contains(binaryPath), "names the plugin path it refuses to self-link") + try MiniTest.expect(hint.contains("adevcontainer plugin --install")) + try MiniTest.expect(!hint.contains("container dev plugin --install")) + } + try MiniTest.expect( + (try? FileManager.default.destinationOfSymbolicLink(atPath: binaryPath)) == nil, + "does not create a self-symlink" + ) + try MiniTest.expectEqual( + try String(contentsOfFile: binaryPath, encoding: .utf8), + "only-copy\n", + "leftover copy remains when no other executable can be identified" + ) + }), + + ("pluginInstallDoesNotMutateSymlinkTarget", { + let root = try PluginTestSupport.makeInstallRoot() + defer { try? FileManager.default.removeItem(at: root) } + let source = root.appendingPathComponent("installed/adevcontainer") + let bytes = Data("immutable-\(UUID().uuidString)\n".utf8) + try PluginTestSupport.writeExecutable(at: source, contents: bytes) + try FileManager.default.setAttributes([.posixPermissions: 0o754], ofItemAtPath: source.path) + let modeBefore = try PluginTestSupport.posixMode(source.path) + try PluginTestSupport.installPlugin(installRoot: root, currentExecutablePath: source.path) + let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) + try PluginTestSupport.expectSymlink(at: binaryPath, target: source.path, "plugin binary is a symlink, not a copy") + try MiniTest.expectEqual(try Data(contentsOf: source), bytes, "target contents unchanged") + try MiniTest.expectEqual(try PluginTestSupport.posixMode(source.path), modeBefore, "target mode unchanged") }), - ("installPluginElevationHintWhenDestinationNotWritable", { + ("pluginInstallElevationHintWhenDestinationNotWritable", { let root = try PluginTestSupport.makeInstallRoot() defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: root.path) try? FileManager.default.removeItem(at: root) } try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: root.path) - let source = root.appendingPathComponent("bin/container") - let runtime = try PluginTestSupport.readyRuntime(executablePath: source.path) + let source = PluginTestSupport.containerBinary(in: root) try MiniTest.expectThrows({ - try InstallPluginCommand.run( - runtime: runtime, - currentExecutablePath: source.path - ) + try PluginTestSupport.installPlugin(installRoot: root, currentExecutablePath: source) }) { error in let hint = (error as! CLIError).hint ?? "" try MiniTest.expect(hint.contains("sudo"), "names elevated privileges") try MiniTest.expect( - hint.contains("adevcontainer install-plugin"), - "elevated remediation names PATH adevcontainer install-plugin" + hint.contains("adevcontainer plugin --install"), + "elevated remediation names PATH adevcontainer plugin --install" ) - try MiniTest.expect(!hint.contains("container dev install-plugin")) + try MiniTest.expect(!hint.contains("container dev plugin --install")) + try MiniTest.expect(!hint.contains("install-plugin")) } }), @@ -440,47 +1002,30 @@ nonisolated(unsafe) let containerPluginTests: [(String, () throws -> Void)] = [ }) { error in let hint = (error as! CLIError).hint ?? "" try MiniTest.expect(hint.contains("sudo"), "missing plugin names elevation when required") - try MiniTest.expect(hint.contains("adevcontainer install-plugin")) - try MiniTest.expect(!hint.contains("container dev install-plugin")) + try MiniTest.expect(hint.contains("adevcontainer plugin --install")) + try MiniTest.expect(!hint.contains("container dev plugin --install")) + try MiniTest.expect(!hint.contains("install-plugin")) } }), - ("installPluginMissingSourceDoesNotHintSudo", { + ("pluginInstallMissingSourceDoesNotHintSudo", { let root = try PluginTestSupport.makeInstallRoot() defer { try? FileManager.default.removeItem(at: root) } - let runtime = try PluginTestSupport.readyRuntime( - executablePath: PluginTestSupport.containerBinary(in: root) - ) let missing = root.appendingPathComponent("no-such-adevcontainer-\(UUID().uuidString)") try MiniTest.expectThrows({ - try InstallPluginCommand.run( - runtime: runtime, - currentExecutablePath: missing.path - ) + try PluginTestSupport.installPlugin(installRoot: root, currentExecutablePath: missing.path) }) { error in let err = error as! CLIError let hint = err.hint ?? "" - try MiniTest.expect( - err.message.contains(missing.path), - "names the resolved source path" - ) - try MiniTest.expect( - !err.message.contains("couldn't open"), - "does not claim FileManager's basename-only open error" - ) - try MiniTest.expect( - !hint.contains("sudo"), - "missing source is not an elevation failure" - ) - try MiniTest.expect( - !hint.contains("install-plugin"), - "missing source is not fixed by restaging" - ) - try MiniTest.expect(!hint.contains("container dev install-plugin")) + try MiniTest.expect(err.message.contains(missing.path), "names the resolved source path") + try MiniTest.expect(!err.message.contains("couldn't open"), "does not claim a basename-only open error") + try MiniTest.expect(!hint.contains("sudo"), "missing source is not an elevation failure") + try MiniTest.expect(!hint.contains("plugin --install"), "missing source is not fixed by restaging") + try MiniTest.expect(!hint.contains("install-plugin")) } }), - ("installPluginUnresolvableBareArgv0NamesUsablePath", { + ("pluginInstallUnresolvableBareArgv0NamesUsablePath", { let root = try PluginTestSupport.makeInstallRoot() defer { try? FileManager.default.removeItem(at: root) } let cwd = FileManager.default.temporaryDirectory @@ -490,13 +1035,10 @@ nonisolated(unsafe) let containerPluginTests: [(String, () throws -> Void)] = [ let previousCwd = FileManager.default.currentDirectoryPath try MiniTest.expect(FileManager.default.changeCurrentDirectoryPath(cwd.path)) defer { FileManager.default.changeCurrentDirectoryPath(previousCwd) } - let runtime = try PluginTestSupport.readyRuntime( - executablePath: PluginTestSupport.containerBinary(in: root) - ) let missingRunning = root.appendingPathComponent("no-such-running-\(UUID().uuidString)") try MiniTest.expectThrows({ - try InstallPluginCommand.run( - runtime: runtime, + try PluginTestSupport.installPlugin( + installRoot: root, currentExecutablePath: "adevcontainer", runningExecutablePath: missingRunning.path, pathEnvironment: "" @@ -504,21 +1046,150 @@ nonisolated(unsafe) let containerPluginTests: [(String, () throws -> Void)] = [ }) { error in let err = error as! CLIError let hint = err.hint ?? "" + try MiniTest.expect(err.message.contains(missingRunning.path), "names a usable resolved path, not the bare argv0") + try MiniTest.expect(!err.message.contains("not found at adevcontainer"), "does not use the bare name as the path") + try MiniTest.expect(!err.message.contains("couldn't open")) + try MiniTest.expect(!hint.contains("install-plugin")) + } + let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) + try MiniTest.expect( + (try? FileManager.default.destinationOfSymbolicLink(atPath: binaryPath)) == nil, + "does not create a relative symlink" + ) + }), + + ("pluginUninstallRemovesLayoutEntriesOnly", { + let root = try PluginTestSupport.makeInstallRoot() + defer { try? FileManager.default.removeItem(at: root) } + let target = root.appendingPathComponent("installed/adevcontainer") + try PluginTestSupport.writeExecutable(at: target, contents: Data("survives-uninstall\n".utf8)) + let pluginDir = root.appendingPathComponent("libexec/container-plugins/dev") + let binDir = pluginDir.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) + try FileManager.default.createSymbolicLink(atPath: binaryPath, withDestinationPath: target.path) + try ContainerPluginLayout.configTOML.write( + to: pluginDir.appendingPathComponent("config.toml"), + atomically: true, + encoding: .utf8 + ) + let other = pluginDir.appendingPathComponent("notes.txt") + try Data("keep-me\n".utf8).write(to: other) + try PluginTestSupport.uninstallPlugin(installRoot: root) + try MiniTest.expect(!FileManager.default.fileExists(atPath: binaryPath), "bin/dev removed") + try MiniTest.expect( + (try? FileManager.default.destinationOfSymbolicLink(atPath: binaryPath)) == nil, + "bin/dev symlink removed" + ) + try MiniTest.expect( + !FileManager.default.fileExists(atPath: ContainerPluginLayout.configPath(installRoot: root.path)), + "config.toml removed" + ) + try MiniTest.expectEqual( + try String(contentsOfFile: target.path, encoding: .utf8), + "survives-uninstall\n", + "symlink target still exists" + ) + try MiniTest.expectEqual(try String(contentsOfFile: other.path, encoding: .utf8), "keep-me\n") + try MiniTest.expect(FileManager.default.fileExists(atPath: pluginDir.path), "plugin directory remains") + }), + + ("pluginUninstallRemovesLeftoverCopyWithoutDeletingKeg", { + let root = try PluginTestSupport.makeInstallRoot() + defer { try? FileManager.default.removeItem(at: root) } + let prefix = root.appendingPathComponent("homebrew") + let formula = try PluginTestSupport.makeHomebrewCellar(prefix: prefix, contents: Data("keg-stays\n".utf8)) + let optURL = URL(fileURLWithPath: formula.opt) + try PluginTestSupport.writeExecutable(at: optURL, contents: Data("opt-stays\n".utf8)) + try PluginTestSupport.stagePluginLayout(installRoot: root, executableContents: Data("leftover-copy\n".utf8)) + try PluginTestSupport.uninstallPlugin(installRoot: root) + let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) + try MiniTest.expect(!FileManager.default.fileExists(atPath: binaryPath), "leftover copy removed") + try MiniTest.expect( + !FileManager.default.fileExists(atPath: ContainerPluginLayout.configPath(installRoot: root.path)) + ) + try MiniTest.expectEqual(try Data(contentsOf: URL(fileURLWithPath: formula.real)), Data("keg-stays\n".utf8)) + try MiniTest.expectEqual(try Data(contentsOf: optURL), Data("opt-stays\n".utf8)) + }), + + ("pluginUninstallSucceedsWhenLayoutMissing", { + let root = try PluginTestSupport.makeInstallRoot() + defer { try? FileManager.default.removeItem(at: root) } + let pluginDir = ContainerPluginLayout.pluginDirectory(installRoot: root.path) + try MiniTest.expect(!FileManager.default.fileExists(atPath: pluginDir)) + try PluginTestSupport.uninstallPlugin(installRoot: root) + try MiniTest.expect(!FileManager.default.fileExists(atPath: pluginDir), "uninstall does not create the layout") + }), + + ("pluginUninstallRemovesConfigSymlinkWithoutDeletingTarget", { + let root = try PluginTestSupport.makeInstallRoot() + defer { try? FileManager.default.removeItem(at: root) } + let secret = root.appendingPathComponent("secret-config.toml") + try Data("SECRET-CONFIG\n".utf8).write(to: secret) + let configPath = ContainerPluginLayout.configPath(installRoot: root.path) + try FileManager.default.createDirectory( + at: URL(fileURLWithPath: configPath).deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.createSymbolicLink(atPath: configPath, withDestinationPath: secret.path) + try PluginTestSupport.uninstallPlugin(installRoot: root) + try MiniTest.expect( + (try? FileManager.default.destinationOfSymbolicLink(atPath: configPath)) == nil, + "config.toml symlink unlinked" + ) + try MiniTest.expect(!FileManager.default.fileExists(atPath: configPath)) + try MiniTest.expectEqual(try String(contentsOfFile: secret.path, encoding: .utf8), "SECRET-CONFIG\n") + }), + + ("pluginUninstallElevationHintWhenDestinationNotWritable", { + let root = try PluginTestSupport.makeInstallRoot() + let pluginDir = root.appendingPathComponent("libexec/container-plugins/dev").path + let binDir = (pluginDir as NSString).appendingPathComponent("bin") + defer { + try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binDir) + try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: pluginDir) + try? FileManager.default.removeItem(at: root) + } + try PluginTestSupport.stagePluginLayout(installRoot: root) + try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: binDir) + try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: pluginDir) + try MiniTest.expectThrows({ + try PluginTestSupport.uninstallPlugin(installRoot: root) + }) { error in + let hint = (error as! CLIError).hint ?? "" + try MiniTest.expect(hint.contains("sudo")) try MiniTest.expect( - err.message.contains(missingRunning.path), - "names a usable resolved path, not the bare argv0" - ) - try MiniTest.expect( - !err.message.contains("couldn't open"), - "does not claim FileManager's basename-only open error" - ) - try MiniTest.expect( - !hint.contains("install-plugin"), - "missing source is not fixed by restaging" + hint.contains("adevcontainer plugin --uninstall"), + "elevated remediation names PATH adevcontainer plugin --uninstall" ) + try MiniTest.expect(!hint.contains("container dev plugin --uninstall")) + try MiniTest.expect(!hint.contains("install-plugin")) } }), + ("doctorAcceptsSymlinkLayoutAsPresent", { + let root = try PluginTestSupport.makeInstallRoot() + defer { try? FileManager.default.removeItem(at: root) } + let target = root.appendingPathComponent("installed/adevcontainer") + try PluginTestSupport.writeExecutable(at: target) + let pluginDir = root.appendingPathComponent("libexec/container-plugins/dev") + let binDir = pluginDir.appendingPathComponent("bin", isDirectory: true) + try FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true) + try ContainerPluginLayout.configTOML.write( + to: pluginDir.appendingPathComponent("config.toml"), + atomically: true, + encoding: .utf8 + ) + let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) + try FileManager.default.createSymbolicLink(atPath: binaryPath, withDestinationPath: target.path) + let runtime = try PluginTestSupport.readyRuntime( + executablePath: PluginTestSupport.containerBinary(in: root) + ) + let report = try DoctorCommand.run(runtime: runtime) + try MiniTest.expect(report.ok) + try PluginTestSupport.expectSymlink(at: binaryPath, target: target.path, "doctor does not replace a symlink layout") + }), + ("doctorWithoutWorkspaceConfigStillRunsWhenPluginPresent", { let root = try PluginTestSupport.makeInstallRoot() defer { try? FileManager.default.removeItem(at: root) } @@ -586,7 +1257,7 @@ nonisolated(unsafe) let containerPluginTests: [(String, () throws -> Void)] = [ let configPath = ContainerPluginLayout.configPath(installRoot: root.path) let binaryPath = ContainerPluginLayout.binaryPath(installRoot: root.path) try MiniTest.expect(!FileManager.default.fileExists(atPath: configPath), "up does not write config.toml") - try MiniTest.expect(!FileManager.default.fileExists(atPath: binaryPath), "up does not copy plugin binary") + try MiniTest.expect(!FileManager.default.fileExists(atPath: binaryPath), "up does not create a plugin symlink") }), ("connectionHintsFollowInvocationPrefix", { diff --git a/scripts/render-homebrew-formula.sh b/scripts/render-homebrew-formula.sh index 83b4bcd..4061866 100755 --- a/scripts/render-homebrew-formula.sh +++ b/scripts/render-homebrew-formula.sh @@ -49,21 +49,6 @@ class Adevcontainer < Formula bin.install "adevcontainer" end - def post_install - container_path = "/usr/local/bin/container" - container_path = which("container").to_s unless File.executable?(container_path) - odie "Apple container CLI not found at /usr/local/bin/container or on PATH" if container_path.to_s.empty? - install_root = File.dirname(File.dirname(container_path)) - plugin_root = File.join(install_root, "libexec", "container-plugins", "dev") - plugin_bin = File.join(plugin_root, "bin") - mkdir_p plugin_bin - cp bin/"adevcontainer", File.join(plugin_bin, "dev") - chmod 0755, File.join(plugin_bin, "dev") - File.write(File.join(plugin_root, "config.toml"), <<~TOML) - abstract = "Native Swift CLI for devcontainer.json on Apple container" - TOML - end - test do assert_match version.to_s, shell_output("#{bin}/adevcontainer --version") end @@ -73,12 +58,12 @@ class Adevcontainer < Formula adevcontainer requires the Apple container CLI on the host (not installed by this formula): https://github.com/apple/container - After upgrading Apple container, reinstall this formula so the - \`container dev\` plugin is restaged: - brew reinstall wcgomes/tap/adevcontainer + After install, link the \`container dev\` plugin once (and again only after + upgrading Apple container): + sudo adevcontainer plugin --install - If post_install cannot write /usr/local/libexec, restage with: - sudo adevcontainer install-plugin + brew upgrade adevcontainer does not require plugin --install again when + the plugin symlink already targets \$(brew --prefix)/opt/adevcontainer/bin/adevcontainer. After install, run: adevcontainer doctor diff --git a/specs/changes/plugin-symlink-install/proposal.md b/specs/changes/plugin-symlink-install/proposal.md new file mode 100644 index 0000000..48a6d51 --- /dev/null +++ b/specs/changes/plugin-symlink-install/proposal.md @@ -0,0 +1,42 @@ +# Proposal: Symlink plugin install + +## Intent + +Copying the Mach-O into Apple’s install-root cannot be done by Homebrew: `brew` does not run as root, its sandbox cannot write `/usr/local/libexec`, and a later `cp` or `chmod` through a plugin symlink would mutate the keg. This change replaces `install-plugin`’s copy with an absolute symlink installed by `plugin --install` and removed by `plugin --uninstall`. + +## Scope + +- Change id: **`plugin-symlink-install`**. +- Affected live contract: [plugin.md](../../plugin.md) **Dual install as Apple CLI plugin `dev`** and **Explicit plugin restage after Apple upgrade wipe**; [core.md](../../core.md) **Doctor preflight** as already modified by the active [install-plugin-command](../install-plugin-command/spec.md) delta. +- This change supersedes the overlapping requirements in that still-active delta (copy the Mach-O; `install-plugin`; Homebrew `post_install` restages `/usr/local`). Do not edit or archive `specs/changes/install-plugin-command/` in this change. Where both active deltas speak, this change is the contract. +- In-scope outcomes: `plugin --install` / `plugin --uninstall`; symlink target rules; elevation and doctor remediation; lifecycle still does not restage; Homebrew formula renderer, README, and CONTRIBUTING name the new command and stop writing Apple’s install-root. +- Formula name stays `adevcontainer`. Plugin binary name stays `dev`. SPM product stays `adevcontainer`. + +## Non-goals + +- Product-code, test, or docs implementation while writing these artifacts. +- Editing or archiving `specs/changes/install-plugin-command/`. +- Wiki edits. `wiki/architecture.md` and `wiki/conventions/release-distribution.md` still describe copy-based `install-plugin` and Homebrew `post_install` restage; that descriptive text conflicts with this confirmed contract and is left stale on purpose. +- Pushing the external tap `wcgomes/homebrew-tap`. The in-repo renderer is the formula content to change; the release workflow publishes it later. +- `design.md`. The decisions below stay understandable without an alternatives write-up. +- Auto-restage on `up` or any other lifecycle command. +- Waiting on unmerged home-dir plugin discovery ([apple/container#1618](https://github.com/apple/container/pull/1618)). +- ContainerAPIClient / XPC migration, or shipping under Apple’s bundled `libexec/container/plugins/`. +- Renaming the formula, the plugin binary, or the SPM product. + +## Approach + +Remove `install-plugin` with no alias. Add `plugin` that requires exactly one of `--install` or `--uninstall`. `--install` writes `config.toml` as a regular file and makes `bin/dev` an absolute symlink to the Homebrew opt path or, outside Homebrew, to the running executable path without realpath. `--uninstall` removes only those two layout entries. Homebrew `post_install` stops writing Apple’s install-root; caveats tell the user to run `sudo adevcontainer plugin --install` once after install and again only after upgrading Apple `container`. + +## Decision index + +- **Command is `plugin`, not `install-plugin`:** `plugin --install` and `plugin --uninstall` are the only restage verbs. `install-plugin` is removed and is not an alias. Neither flag, or both flags, is a usage error. Exactly one flag is required. +- **Install is an absolute symlink, not a copy:** `--install` removes `bin/dev` when it is a regular file (old copy) or a symlink to the wrong target, then creates an absolute symlink. An already-correct absolute symlink is left in place. `config.toml` is always written as a regular file (`abstract`, no `[servicesConfig]`), never by writing through a symlink. `--install` MUST NOT copy the Mach-O into the plugin layout or chmod the symlink target. +- **Homebrew target is the opt path:** When the running executable is Homebrew formula `adevcontainer`, including a versioned Cellar path and including `sudo` with a bare argv0, the symlink target MUST be `$(brew --prefix)/opt/adevcontainer/bin/adevcontainer`. It MUST NOT be a Cellar path and MUST NOT be `resolvingSymlinksInPath` of the keg. If that opt path cannot be formed, fail rather than fall back to Cellar or realpath. +- **Non-Homebrew target is the running executable path:** No realpath resolution. Under `sudo`, a bare argv0 MUST NOT be treated as a relative source path (same rule as today’s `install-plugin`). +- **Plugin invocation must not self-link `bin/dev`:** When argv0 or the identified path is `{install-root}/libexec/container-plugins/dev/bin/dev` (absolute path or process name `dev`), `--install` MUST NOT target that plugin path. An existing absolute link text is reused (Cellar link becomes the opt path; an opt link is kept; do not realpath the keg). A regular file, relative symlink, or self-symlink falls back to PATH `adevcontainer` or the Homebrew opt binary. If neither can be identified, fail instead of creating a self-symlink. PATH `adevcontainer plugin --install` is unchanged. +- **Uninstall removes layout entries only:** `--uninstall` removes `bin/dev` (symlink or leftover copy) and `config.toml` only. It MUST NOT delete the symlink target or the Homebrew keg. A missing layout is success. +- **Elevation and doctor stay PATH-only where the plugin cannot be assumed:** When the destination is not writable, re-exec with elevated privileges or print exact remediation naming PATH `adevcontainer plugin --install` or `adevcontainer plugin --uninstall` with `sudo`. Doctor missing-plugin remediation uses PATH `adevcontainer plugin --install`, never `container dev plugin --install`. Lifecycle commands do not restage. `--repair` remains a usage error whose hint names `plugin --install`. +- **Homebrew does not restage Apple’s install-root:** `post_install` MUST NOT write `/usr/local/libexec/...` (or any Apple install-root plugin path). A `cp` or `chmod` through the new symlink would mutate the keg, and brew cannot write there. Caveats MUST tell the user to run `sudo adevcontainer plugin --install` once after install, and again only after upgrading Apple `container` ([apple/container#1617](https://github.com/apple/container/issues/1617)). `brew upgrade adevcontainer` MUST NOT require restage when the symlink already targets the opt path. +- **Preserved restage limits:** `plugin --install` and `plugin --uninstall` MUST NOT require `container system start`. The product MUST NOT wait on home-dir plugin discovery. +- **No `design.md`:** Target selection, elevation, and the Homebrew sandbox constraint stay understandable in this index and in `tasks.md`. The overflow rule is not met. diff --git a/specs/changes/plugin-symlink-install/spec.md b/specs/changes/plugin-symlink-install/spec.md new file mode 100644 index 0000000..bb46eb3 --- /dev/null +++ b/specs/changes/plugin-symlink-install/spec.md @@ -0,0 +1,287 @@ +# Change Spec: plugin-symlink-install + +This delta supersedes the overlapping requirements in the still-active `specs/changes/install-plugin-command/spec.md`. Do not edit or archive that change as part of this work. Where both active deltas speak, this change is the contract. + +## ADDED Requirements + +### Requirement: Plugin uninstall removes layout entries only + +`plugin --uninstall` MUST remove `{install-root}/libexec/container-plugins/dev/bin/dev` when that path is a symlink or a regular file, and MUST remove `{install-root}/libexec/container-plugins/dev/config.toml`. It MUST NOT delete the symlink target of `bin/dev`, MUST NOT delete the Homebrew keg or the opt-path executable, and MUST NOT remove any other path, including the plugin directory itself. Removing `config.toml` MUST unlink that path and MUST NOT delete a symlink target if `config.toml` is a symlink. When `bin/dev` and `config.toml` are already absent, uninstall MUST succeed. Uninstall MUST NOT require `container system start`. + +When the destination is not writable, uninstall MUST re-exec with elevated privileges to complete the removal, or MUST print exact remediation naming PATH `adevcontainer plugin --uninstall` including `sudo`. That remediation MUST NOT name `container dev plugin --uninstall`. + +#### Scenario: plugin --uninstall removes the plugin binary and config.toml only + +- Given `bin/dev` is a symlink to an existing executable and `config.toml` exists, and the plugin directory contains another file +- When the user runs `plugin --uninstall` +- Then `bin/dev` and `config.toml` are gone, the symlink target still exists, and the other file remains + +#### Scenario: plugin --uninstall removes a leftover copy without deleting the keg + +- Given `bin/dev` is a regular file left by an older copy install, and the Homebrew keg and opt-path executable exist elsewhere +- When the user runs `plugin --uninstall` +- Then the leftover `bin/dev` and `config.toml` are removed and the keg and opt-path executable are unchanged + +#### Scenario: plugin --uninstall succeeds when the layout is missing + +- Given Apple `container` is installed and the plugin layout is already absent +- When the user runs `plugin --uninstall` +- Then the command succeeds and does not create the layout + +#### Scenario: plugin --uninstall uses elevated privileges when required + +- Given the plugin destination is not writable by the current user +- When the user runs `plugin --uninstall` +- Then the product re-execs with elevated privileges or prints exact remediation naming PATH `adevcontainer plugin --uninstall` with `sudo`, and MUST NOT name `container dev plugin --uninstall` + +#### Scenario: plugin --uninstall does not require container system start + +- Given Apple `container` is installed and Apple container services are not running +- When the user runs `adevcontainer plugin --uninstall` +- Then uninstall completes and MUST NOT fail because `container system start` has not been run + +## MODIFIED Requirements + +### Requirement: Dual install as Apple CLI plugin `dev` + +The same Mach-O MUST be reachable as PATH `adevcontainer` and as an Apple `container` CLI plugin invoked as `container dev `. The plugin name, directory, and binary MUST be `dev`. The plugin binary MUST be an absolute symlink to the installed executable, not a second copy of the Mach-O. + +On Unix, install-root MUST be the parent of Apple `container`’s `bin/` directory (typically `/usr/local`). The staged plugin layout MUST be: + +- `{install-root}/libexec/container-plugins/dev/config.toml` +- `{install-root}/libexec/container-plugins/dev/bin/dev` + +`config.toml` MUST be a regular file, MUST include `abstract`, and MUST omit `[servicesConfig]` so Apple treats it as a CLI plugin. Apple discovers plugins by that directory layout, not PATH. When Apple dispatches the plugin, process argv MUST begin with `dev` followed by the product subcommand. The product MUST NOT ship under Apple’s bundled `libexec/container/plugins/`. The SPM executable product name MUST remain `adevcontainer`. Dual install MUST be that absolute symlink, not a second package product and not a second copy of the Mach-O. The Homebrew formula name MUST remain `adevcontainer`. + +#### Scenario: PATH binary remains adevcontainer + +- Given a successful install of this product +- When the user runs the PATH binary +- Then the executable name is `adevcontainer` + +#### Scenario: Plugin layout uses name dev under container install-root + +- Given Apple `container` is installed at `{install-root}/bin/container` +- When the plugin is staged +- Then `{install-root}/libexec/container-plugins/dev/config.toml` and `{install-root}/libexec/container-plugins/dev/bin/dev` exist + +#### Scenario: config.toml is a CLI plugin + +- Given the plugin is staged +- When `config.toml` is read +- Then it is a regular file, includes `abstract`, and omits `[servicesConfig]` + +#### Scenario: Plugin binary is an absolute symlink to the installed executable + +- Given PATH `adevcontainer` is installed and the plugin is staged +- When the plugin binary path is inspected +- Then `{install-root}/libexec/container-plugins/dev/bin/dev` is an absolute symlink to the installed executable, not a second copy of the Mach-O + +#### Scenario: Plugin invocation runs the subcommand + +- Given the plugin is staged and Apple can dispatch plugins +- When the user runs `container dev` with a product subcommand +- Then that subcommand runs + +### Requirement: Explicit plugin restage after Apple upgrade wipe + +Apple installer or Homebrew keg replacement of `libexec` wipes user plugins under `container-plugins/` ([apple/container#1617](https://github.com/apple/container/issues/1617)). PATH `adevcontainer` survives. `container dev` then fails until the plugin layout is installed again. + +The product MUST NOT restage the plugin layout on `up` or any other lifecycle command. `doctor` MUST detect a missing plugin layout and MUST print exact remediation naming PATH `adevcontainer plugin --install`, including `sudo` when the destination requires elevation. It MUST NOT suggest `container dev plugin --install` for a missing layout. A present layout is `config.toml` plus `bin/dev`, including when `bin/dev` is a symlink. Doctor MUST NOT require `bin/dev` to be a regular file and MUST NOT restage. + +The product MUST provide `plugin` with `--install` and `--uninstall`. It MUST NOT provide `install-plugin` as a command or as an alias. `plugin` with neither flag, or with both flags, MUST fail as a usage error that names `--install` and `--uninstall` and states that exactly one is required. Usage and `plugin` help MUST document both flags and MUST NOT document `install-plugin` or `--repair`. + +`plugin --install` and `plugin --uninstall` MUST derive install-root from the parent of Apple `container`’s `bin/` directory. If that binary cannot be resolved, they MUST fail with the existing missing-runtime error. They MUST NOT require `container system start` to succeed. + +`plugin --install` MUST create `{install-root}/libexec/container-plugins/dev/bin/dev` as an absolute symlink and MUST write `config.toml` as a regular file with `abstract` and without `[servicesConfig]`. It MUST remove `bin/dev` first when that path is a regular file or a symlink whose target string is not the required absolute path, including a relative symlink that would resolve to the same file, and MUST NOT delete the old symlink’s target. If `bin/dev` is already an absolute symlink whose target string equals the required path, `--install` MUST succeed without replacing that symlink. It MUST still write `config.toml` as a regular file. If an existing `config.toml` is a symlink, `--install` MUST replace that path with a regular file and MUST NOT write through the symlink. + +The required symlink target depends on how this product is installed. When the running executable is Homebrew formula `adevcontainer` — including when its path is a versioned Cellar path, and including when the process is invoked under `sudo` with a bare argv0 — the target MUST be the absolute path `$(brew --prefix)/opt/adevcontainer/bin/adevcontainer`. It MUST NOT be a versioned Cellar path and MUST NOT be the result of resolving symlinks in the keg. If that opt path cannot be formed, `--install` MUST fail and MUST NOT fall back to a Cellar path or a realpath-resolved path. When the running executable is not that Homebrew formula, the target MUST be the running executable’s path with no realpath resolution. A bare argv0 MUST NOT be treated as a relative source path. + +When argv0 or the identified executable path is the plugin binary `{install-root}/libexec/container-plugins/dev/bin/dev` — an absolute path or the process name `dev` — `--install` MUST NOT create a symlink whose target is that plugin path. It MUST resolve the installed executable instead. If `bin/dev` is already an absolute symlink to a different path, apply the Homebrew vs non-Homebrew target rules to that link text: do not realpath a Cellar keg; a Cellar link becomes the opt path; an already-correct opt link is kept. If `bin/dev` is a regular file, a relative symlink, or a self-symlink, locate PATH `adevcontainer` or the Homebrew opt binary if present and link to that. If no installed executable other than `bin/dev` can be identified, `--install` MUST fail and MUST NOT create a self-symlink. PATH `adevcontainer plugin --install` is unchanged. Doctor missing-plugin remediation MUST still name PATH `adevcontainer plugin --install` and MUST NOT name `container dev plugin --install`. + +`--install` MUST NOT copy the Mach-O into the plugin layout and MUST NOT change the mode or contents of the symlink target. + +When the destination is not writable, `--install` MUST re-exec with elevated privileges to complete the install, or MUST print exact remediation naming PATH `adevcontainer plugin --install` including `sudo`. That remediation MUST NOT name `container dev plugin --install`. + +`--repair` MUST NOT restage. Any `--repair` MUST fail as a usage error whose hint names `plugin --install` and MUST NOT name `install-plugin`. + +Homebrew formula `post_install` MUST NOT write Apple’s install-root plugin layout (`/usr/local/libexec/...` or any other Apple `container` install-root `container-plugins` path) and MUST NOT copy or chmod the formula binary into that layout. Homebrew caveats MUST tell the user to run `sudo adevcontainer plugin --install` once after install, and again only after upgrading Apple `container`. Caveats MUST NOT tell the user to `brew reinstall` or `brew upgrade` this formula in order to restage the plugin. When the plugin symlink already targets `$(brew --prefix)/opt/adevcontainer/bin/adevcontainer`, `brew upgrade adevcontainer` MUST NOT require `plugin --install` again. README and source-install docs MUST name `adevcontainer plugin --install` (with `sudo` when the destination requires elevation) and MUST NOT name `install-plugin`. The product MUST NOT wait on unmerged home-dir plugin discovery. + +#### Scenario: PATH doctor still runs when plugin layout is missing + +- Given PATH `adevcontainer` is installed and the plugin layout is missing +- When the user runs `adevcontainer doctor` +- Then doctor runs as the PATH binary and reports the missing plugin layout + +#### Scenario: Lifecycle commands do not restage a missing plugin + +- Given the plugin layout is missing +- When the user runs `up` or another lifecycle command +- Then the product does not create the plugin symlink or write `config.toml` into the plugin layout + +#### Scenario: Doctor reports missing plugin layout with exact remediation + +- Given the plugin layout is missing +- When the user runs `adevcontainer doctor` +- Then doctor exits non-zero and prints exact remediation naming PATH `adevcontainer plugin --install` (with `sudo` when the destination requires elevation), not `container dev plugin --install` + +#### Scenario: plugin --install creates an absolute symlink and writes config.toml + +- Given the running executable and a writable plugin destination derived from Apple `container`’s install-root +- When the user runs `plugin --install` +- Then `bin/dev` is an absolute symlink to the required target, and `config.toml` is a regular file with `abstract` and without `[servicesConfig]` + +#### Scenario: plugin --install replaces a copied plugin binary + +- Given `bin/dev` is a regular file +- When the user runs `plugin --install` +- Then the product removes that regular file and creates an absolute symlink to the required target + +#### Scenario: plugin --install replaces a symlink to the wrong target + +- Given `bin/dev` is a relative symlink, or an absolute symlink whose target string is not the required path +- When the user runs `plugin --install` +- Then the product removes that symlink without deleting its target and creates an absolute symlink to the required target + +#### Scenario: plugin --install keeps an already-correct symlink + +- Given `bin/dev` is already an absolute symlink whose target string equals the required path +- When the user runs `plugin --install` +- Then the command succeeds without replacing that symlink, and `config.toml` is a regular file with `abstract` and without `[servicesConfig]` + +#### Scenario: plugin --install writes config.toml as a regular file + +- Given an existing `config.toml` is a symlink +- When the user runs `plugin --install` +- Then `config.toml` is replaced with a regular file that includes `abstract` and omits `[servicesConfig]`, and the previous symlink target is unchanged + +#### Scenario: Homebrew install links the opt path + +- Given the running executable is Homebrew formula `adevcontainer`, including a versioned Cellar path and including invocation under `sudo` with a bare argv0 +- When the user runs `plugin --install` +- Then `bin/dev` is an absolute symlink to `$(brew --prefix)/opt/adevcontainer/bin/adevcontainer`, not a versioned Cellar path and not the result of resolving symlinks in the keg +- And if that opt path cannot be formed, the command fails and does not create a Cellar-path or realpath-resolved symlink + +#### Scenario: Non-Homebrew install links the running executable path + +- Given the running executable is not Homebrew formula `adevcontainer` +- When the user runs `plugin --install` +- Then `bin/dev` is an absolute symlink to the running executable’s path, and that path is not realpath-resolved + +#### Scenario: plugin --install does not symlink bin/dev to itself + +- Given argv0 is the plugin binary `{install-root}/libexec/container-plugins/dev/bin/dev` or the process name `dev` +- When the user runs `plugin --install` +- Then `bin/dev` is not a symlink whose target is that plugin path +- And if `bin/dev` was already an absolute symlink to a different path, the target follows the Homebrew or non-Homebrew rule for that link text (a Cellar link becomes the opt path; an already-correct opt link is kept; the keg is not realpath-resolved) +- And if `bin/dev` is a regular file, a relative symlink, or a self-symlink, the target is PATH `adevcontainer` or the Homebrew opt binary when one of those is present +- And if no installed executable other than `bin/dev` can be identified, the command fails and does not create a self-symlink + +#### Scenario: plugin --install does not treat a bare argv0 as a relative source path + +- Given a non-Homebrew install invoked with a bare executable name (for example under `sudo`) +- When the user runs `plugin --install` +- Then the symlink target is the running executable’s absolute path and MUST NOT be the bare name as a relative path + +#### Scenario: plugin --install does not mutate the symlink target + +- Given a symlink target that is the installed executable, including a Homebrew keg binary +- When the user runs `plugin --install` +- Then the product does not copy the Mach-O into the plugin layout and does not change the mode or contents of that target + +#### Scenario: plugin --install uses elevated privileges when required + +- Given the plugin destination is not writable by the current user +- When the user runs `plugin --install` +- Then the product re-execs with elevated privileges or prints exact remediation naming PATH `adevcontainer plugin --install` with `sudo`, and MUST NOT name `container dev plugin --install` + +#### Scenario: plugin --install does not require container system start + +- Given Apple `container` is installed and Apple container services are not running +- When the user runs `adevcontainer plugin --install` +- Then the product installs the plugin layout and MUST NOT fail because `container system start` has not been run + +#### Scenario: plugin with neither flag is a usage error + +- Given the user invokes `plugin` with neither `--install` nor `--uninstall` +- When the command parses +- Then it fails as a usage error that names both flags and states that exactly one is required + +#### Scenario: plugin with both flags is a usage error + +- Given the user invokes `plugin` with both `--install` and `--uninstall` +- When the command parses +- Then it fails as a usage error that names both flags and states that exactly one is required + +#### Scenario: install-plugin is not a command + +- Given the product command surface +- When the user runs `install-plugin` +- Then the command fails as an unknown command, is not an alias for `plugin --install`, and the hint names `plugin` and does not list `install-plugin` as a valid command + +#### Scenario: --repair fails as usage and hints plugin --install + +- Given any command, including `doctor` and `plugin` +- When the user passes `--repair` +- Then the command fails as a usage error and the hint names `plugin --install` and does not name `install-plugin` + +#### Scenario: Homebrew post_install does not write Apple's install-root + +- Given the Homebrew formula rendered by `scripts/render-homebrew-formula.sh` +- When `post_install` is inspected +- Then it does not write Apple’s install-root plugin layout and does not copy or chmod the formula binary into that layout + +#### Scenario: Homebrew caveats name plugin --install + +- Given the Homebrew formula is installed +- When caveats are shown +- Then they tell the user to run `sudo adevcontainer plugin --install` once after install, and again only after upgrading Apple `container` +- And they do not tell the user to `brew reinstall` or `brew upgrade` this formula in order to restage the plugin + +#### Scenario: brew upgrade does not require restage when the symlink targets the opt path + +- Given `bin/dev` is an absolute symlink to `$(brew --prefix)/opt/adevcontainer/bin/adevcontainer` +- When the user upgrades formula `adevcontainer` +- Then the symlink remains valid without running `plugin --install` again + +#### Scenario: Tarball install uses plugin --install + +- Given the user installs from the release tarball onto PATH +- When install instructions are followed +- Then the plugin layout is installed with PATH `adevcontainer plugin --install` (with `sudo` when the destination requires elevation), not with `install-plugin` and not by copying the Mach-O into the plugin layout + +#### Scenario: Source install uses plugin --install + +- Given the user builds from source +- When install instructions are followed +- Then the plugin layout is installed with PATH `adevcontainer plugin --install` (with `sudo` when the destination requires elevation), not with `install-plugin` + +### Requirement: Doctor preflight + +`adevcontainer doctor` MUST verify host readiness before users rely on `up`: Apple `container` binary presence (default path `/usr/local/bin/container` or PATH resolution), invokability, and a reported version suitable for machine use. Doctor MUST also verify the Apple CLI plugin layout for `dev` under the install-root parent of Apple `container`’s `bin/`. Doctor MUST emit a clear pass/fail summary. Doctor MUST NOT require a `devcontainer.json`. + +PATH invocation of `adevcontainer doctor` MUST keep the existing missing-binary failure. Success MUST still report Apple `container` binary path and version and MUST require the plugin layout to be present, including when `bin/dev` is a symlink. When Apple container services are not running, doctor MUST surface that `container system start` is required (Apple needs it to list/dispatch plugins). Doctor MUST NOT restage the plugin layout. Doctor MUST NOT accept `--repair`. When the plugin layout is missing, doctor MUST fail and print PATH `adevcontainer plugin --install` remediation as specified in **Explicit plugin restage after Apple upgrade wipe**. + +#### Scenario: Doctor success + +- Given Apple `container` is installed and runnable and the plugin layout is present +- When the user runs `adevcontainer doctor` +- Then the command exits 0 and reports binary path and version + +#### Scenario: Doctor missing binary + +- Given `container` is not on PATH and not at the default path +- When the user runs `adevcontainer doctor` +- Then the command exits non-zero with a structured error explaining the missing runtime + +#### Scenario: Doctor does not require devcontainer.json + +- Given a directory with no `devcontainer.json` +- When the user runs `adevcontainer doctor` +- Then doctor does not fail for missing configuration + +#### Scenario: Doctor surfaces container system start when not running + +- Given Apple `container` is installed and the system status is not running +- When the user runs `adevcontainer doctor` +- Then doctor exits non-zero and tells the user to run `container system start` diff --git a/specs/changes/plugin-symlink-install/tasks.md b/specs/changes/plugin-symlink-install/tasks.md new file mode 100644 index 0000000..c919863 --- /dev/null +++ b/specs/changes/plugin-symlink-install/tasks.md @@ -0,0 +1,146 @@ +# Tasks: plugin-symlink-install + +Spec ref: `specs/changes/plugin-symlink-install/`. Execute test-first: write the failing tests and confirm the expected failures before changing production code. This change supersedes overlapping `install-plugin` copy and Homebrew `post_install` restage requirements. Do not edit or archive `specs/changes/install-plugin-command/`. Do not edit the wiki. Do not push `wcgomes/homebrew-tap`. Do not stage or commit `.devcontainer/devcontainer.json` or `.devcontainer/install-tools.sh`. Formula name stays `adevcontainer`. Plugin binary name stays `dev`. SPM product stays `adevcontainer`. + +Verified test paths beyond the command modules: `Tests/adevcontainerTests/AllCommandTests.swift` already calls `InstallPluginCommand.run` and expects a copied Mach-O while services are stopped. `Tests/adevcontainerTests/main.swift` is the suite-of-record entry. Both must be updated or the suite cannot match this contract. No other product files are in scope. + +## 1. Failing tests for symlink install, uninstall, and doctor checks + +- [x] 1.1 Retarget plugin tests to this contract: `plugin` with neither flag or both flags is a usage error naming both flags; `install-plugin` is an unknown command and not an alias; usage and help document `plugin --install` and `plugin --uninstall` and do not document `install-plugin` or `--repair`; `--repair` fails as usage and hints `plugin --install`; missing-plugin doctor prints PATH `adevcontainer plugin --install` (`sudo` when elevation is required) and never `container dev plugin --install`; `--install` creates an absolute symlink plus regular-file `config.toml` (`abstract`, no `[servicesConfig]`), replaces a regular-file copy and a wrong or relative symlink without deleting the old target, leaves an already-correct absolute symlink in place, replaces a symlinked `config.toml` without writing through it, and does not copy or chmod the target; Homebrew target is `$(brew --prefix)/opt/adevcontainer/bin/adevcontainer` even for a Cellar path and under `sudo` with a bare argv0, never Cellar and never realpath, and fails closed if the opt path cannot be formed; non-Homebrew target is the running executable path without realpath; bare argv0 is not a relative source path; elevation remediation names PATH `adevcontainer plugin --install` or `plugin --uninstall` with `sudo`; `--uninstall` removes only `bin/dev` and `config.toml`, leaves the target, keg, and other files, and succeeds when the layout is missing; `up`/lifecycle still do not restage; doctor accepts a symlink layout as present and does not restage (path: `Tests/adevcontainerTests/ContainerPluginTests.swift`) +- [x] 1.2 Retarget the stopped-services install test so `plugin --install` installs the symlink layout while Apple container services are not running and succeeds without requiring `container system start`; `plugin --uninstall` likewise does not require `container system start`; doctor still fails for a non-running system and MUST NOT restage [P] (path: `Tests/adevcontainerTests/AllCommandTests.swift`) +- [ ] 1.3 Run the suite of record and confirm failures are limited to the missing `plugin` verb, remaining `install-plugin` copy restage, and stale `install-plugin` remediation (path: `Tests/adevcontainerTests/main.swift`). BLOCKED: this host is Linux and `Package.swift` requires macOS 26, so the suite was not executed. + +## Checkpoint + +- [x] verify **plugin with neither flag is a usage error** +- [x] verify **plugin with both flags is a usage error** +- [x] verify **install-plugin is not a command** +- [x] verify **--repair fails as usage and hints plugin --install** +- [x] verify **Doctor reports missing plugin layout with exact remediation** +- [x] verify **plugin --install creates an absolute symlink and writes config.toml** +- [x] verify **plugin --install replaces a copied plugin binary** +- [x] verify **plugin --install replaces a symlink to the wrong target** +- [x] verify **plugin --install keeps an already-correct symlink** +- [x] verify **plugin --install writes config.toml as a regular file** +- [x] verify **Homebrew install links the opt path** +- [x] verify **Non-Homebrew install links the running executable path** +- [x] verify **plugin --install does not symlink bin/dev to itself** +- [x] verify **plugin --install does not treat a bare argv0 as a relative source path** +- [x] verify **plugin --install does not mutate the symlink target** +- [x] verify **plugin --install uses elevated privileges when required** +- [x] verify **plugin --install does not require container system start** +- [x] verify **plugin --uninstall removes the plugin binary and config.toml only** +- [x] verify **plugin --uninstall removes a leftover copy without deleting the keg** +- [x] verify **plugin --uninstall succeeds when the layout is missing** +- [x] verify **plugin --uninstall uses elevated privileges when required** +- [x] verify **plugin --uninstall does not require container system start** +- [x] verify **Lifecycle commands do not restage a missing plugin** +- [x] verify **PATH doctor still runs when plugin layout is missing** +- [x] verify **Plugin binary is an absolute symlink to the installed executable** + +## 2. Command surface + +- [x] 2.1 Remove `install-plugin` from usage, help, and unknown-command hints. Add `plugin` documenting `--install` and `--uninstall` and that exactly one is required. Neither flag or both flags is a usage error naming both flags. Reject `--repair` on every command as usage with a hint naming `plugin --install` and not `install-plugin`. Missing-plugin help MUST name PATH `adevcontainer plugin --install` and MUST NOT name `container dev plugin --install`. Other help stays invocation-aware (path: `Sources/ADevContainerLib/Support/CommandSurface.swift`) + +## Checkpoint + +- [x] verify **plugin with neither flag is a usage error** +- [x] verify **plugin with both flags is a usage error** +- [x] verify **install-plugin is not a command** +- [x] verify **--repair fails as usage and hints plugin --install** +- [x] verify **Doctor reports missing plugin layout with exact remediation** + +## 3. Symlink install, uninstall, and doctor checks + +- [x] 3.1a Plugin-invocation `--install` must not retarget `bin/dev` at itself. If argv0 or the identified path is the plugin binary (absolute path or process name `dev`), reuse an existing absolute link text (Cellar becomes the opt path; keep an opt link; do not realpath the keg) or locate PATH `adevcontainer` / the Homebrew opt binary. Fail instead of creating a self-symlink. PATH `adevcontainer plugin --install` is unchanged. (path: `Sources/ADevContainerLib/Commands/DoctorCommand.swift`, `Tests/adevcontainerTests/ContainerPluginTests.swift`) +- [x] 3.1 Replace copy/chmod staging with absolute-symlink install and layout-only uninstall. Homebrew target is `$(brew --prefix)/opt/adevcontainer/bin/adevcontainer`, including Cellar-path and sudo bare-argv0 invocations; do not call `resolvingSymlinksInPath` on the keg; if the opt path cannot be formed, fail instead of using a Cellar or realpath target. Non-Homebrew target is the running executable path without realpath; bare argv0 is not a relative source path. Keep an already-correct absolute symlink; replace a regular file or wrong symlink without deleting the old target. Write `config.toml` as a regular file and do not write through an existing symlink. Do not copy the Mach-O or chmod the target. Missing-plugin hint names PATH `adevcontainer plugin --install` (`sudo` when required). Doctor stays checks-only and treats a symlink layout as present. Do not require `container system start` (path: `Sources/ADevContainerLib/Commands/DoctorCommand.swift`) +- [x] 3.2 Replace this command module with `plugin --install` and `plugin --uninstall` (rename the file to `PluginCommand.swift` in this same task; do not keep `InstallPluginCommand` or an `install-plugin` alias). Derive install-root from Apple `container`. Re-exec with elevated privileges or print PATH remediation with `sudo` naming `adevcontainer plugin --install` or `adevcontainer plugin --uninstall`. Missing layout uninstall succeeds. Do not require `container system start` (path: `Sources/ADevContainerLib/Commands/InstallPluginCommand.swift`) +- [x] 3.3 Dispatch `plugin` and remove the `install-plugin` case. Unknown-command hint MUST include `plugin` and MUST NOT list `install-plugin`. `doctor` MUST NOT accept `--repair` (path: `Sources/adevcontainer/AdevcontainerMain.swift`) + +## Checkpoint + +- [x] verify **plugin --install creates an absolute symlink and writes config.toml** +- [x] verify **plugin --install replaces a copied plugin binary** +- [x] verify **plugin --install replaces a symlink to the wrong target** +- [x] verify **plugin --install keeps an already-correct symlink** +- [x] verify **plugin --install writes config.toml as a regular file** +- [x] verify **Homebrew install links the opt path** +- [x] verify **Non-Homebrew install links the running executable path** +- [x] verify **plugin --install does not symlink bin/dev to itself** +- [x] verify **plugin --install does not treat a bare argv0 as a relative source path** +- [x] verify **plugin --install does not mutate the symlink target** +- [x] verify **plugin --install uses elevated privileges when required** +- [x] verify **plugin --install does not require container system start** +- [x] verify **plugin --uninstall removes the plugin binary and config.toml only** +- [x] verify **plugin --uninstall removes a leftover copy without deleting the keg** +- [x] verify **plugin --uninstall succeeds when the layout is missing** +- [x] verify **plugin --uninstall uses elevated privileges when required** +- [x] verify **plugin --uninstall does not require container system start** +- [x] verify **PATH doctor still runs when plugin layout is missing** +- [x] verify **Doctor reports missing plugin layout with exact remediation** +- [x] verify **Lifecycle commands do not restage a missing plugin** +- [x] verify **Doctor success** +- [x] verify **Doctor missing binary** +- [x] verify **Doctor does not require devcontainer.json** +- [x] verify **Doctor surfaces container system start when not running** +- [x] verify **Plugin binary is an absolute symlink to the installed executable** +- [x] verify **config.toml is a CLI plugin** + +## 4. Packaging docs stop restaging Apple's install-root + +- [x] 4.1 Formula name stays `adevcontainer`. Remove `post_install` writes to Apple’s install-root (`/usr/local/libexec/...` or any Apple install-root plugin path), including `cp` and `chmod` of the formula binary into that layout. Caveats MUST tell the user to run `sudo adevcontainer plugin --install` once after install, and again only after upgrading Apple `container`. Caveats MUST state that `brew upgrade adevcontainer` does not require `plugin --install` again when the symlink already targets the opt path. Caveats MUST NOT tell the user to `brew reinstall` or `brew upgrade` this formula to restage the plugin. Do not push the external tap (path: `scripts/render-homebrew-formula.sh`) +- [x] 4.2 Homebrew install instructions tell the user to run `sudo adevcontainer plugin --install` once after `brew install`, and again only after upgrading Apple `container`, and that `brew upgrade adevcontainer` does not require restage when the symlink targets the opt path. Tarball install uses PATH `adevcontainer plugin --install` (`sudo` when the destination requires elevation). Command table and plugin section name `plugin --install` and `plugin --uninstall`. MUST NOT name `install-plugin`, `doctor --repair`, or `container dev plugin --install` for a missing layout, and MUST NOT say `post_install` or `brew reinstall` restages the plugin [P] (path: `README.md`) +- [x] 4.3 Source install uses PATH `adevcontainer plugin --install` (`sudo` when the destination requires elevation) and MUST NOT name `install-plugin` [P] (path: `CONTRIBUTING.md`) + +## Checkpoint + +- [x] verify **Homebrew post_install does not write Apple's install-root** +- [x] verify **Homebrew caveats name plugin --install** +- [x] verify **brew upgrade does not require restage when the symlink targets the opt path** +- [x] verify **Tarball install uses plugin --install** +- [x] verify **Source install uses plugin --install** + +## 5. Suite of record + +- [ ] 5.1 Run `swift run adevcontainerTests` and confirm every scenario above maps to a passing test or packaging checkpoint (path: `Tests/adevcontainerTests/main.swift`). BLOCKED: this host is Linux and `Package.swift` requires macOS 26, so the suite was not executed and must not be reported as passed. + +## Checkpoint + +- [x] verify **PATH binary remains adevcontainer** +- [x] verify **Plugin layout uses name dev under container install-root** +- [x] verify **config.toml is a CLI plugin** +- [x] verify **Plugin binary is an absolute symlink to the installed executable** +- [x] verify **Plugin invocation runs the subcommand** +- [x] verify **PATH doctor still runs when plugin layout is missing** +- [x] verify **Lifecycle commands do not restage a missing plugin** +- [x] verify **Doctor reports missing plugin layout with exact remediation** +- [x] verify **plugin --install creates an absolute symlink and writes config.toml** +- [x] verify **plugin --install replaces a copied plugin binary** +- [x] verify **plugin --install replaces a symlink to the wrong target** +- [x] verify **plugin --install keeps an already-correct symlink** +- [x] verify **plugin --install writes config.toml as a regular file** +- [x] verify **Homebrew install links the opt path** +- [x] verify **Non-Homebrew install links the running executable path** +- [x] verify **plugin --install does not symlink bin/dev to itself** +- [x] verify **plugin --install does not treat a bare argv0 as a relative source path** +- [x] verify **plugin --install does not mutate the symlink target** +- [x] verify **plugin --install uses elevated privileges when required** +- [x] verify **plugin --install does not require container system start** +- [x] verify **plugin with neither flag is a usage error** +- [x] verify **plugin with both flags is a usage error** +- [x] verify **install-plugin is not a command** +- [x] verify **--repair fails as usage and hints plugin --install** +- [x] verify **Homebrew post_install does not write Apple's install-root** +- [x] verify **Homebrew caveats name plugin --install** +- [x] verify **brew upgrade does not require restage when the symlink targets the opt path** +- [x] verify **Tarball install uses plugin --install** +- [x] verify **Source install uses plugin --install** +- [x] verify **plugin --uninstall removes the plugin binary and config.toml only** +- [x] verify **plugin --uninstall removes a leftover copy without deleting the keg** +- [x] verify **plugin --uninstall succeeds when the layout is missing** +- [x] verify **plugin --uninstall uses elevated privileges when required** +- [x] verify **plugin --uninstall does not require container system start** +- [x] verify **Doctor success** +- [x] verify **Doctor missing binary** +- [x] verify **Doctor does not require devcontainer.json** +- [x] verify **Doctor surfaces container system start when not running** diff --git a/wiki/architecture.md b/wiki/architecture.md index dc8d18c..28e8ea8 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -10,7 +10,7 @@ Greenfield native Swift executable (arm64). Reads `devcontainer.json`, drives Ap | CLI language | Swift 6.x (SPM; full Xcode not required) | | User runtime dep | Apple `container` CLI (install separately — [apple/container](https://github.com/apple/container); tested with 1.2.x JSON) | | Apple container binary (typical) | `/usr/local/bin/container` | -| Product binary | `adevcontainer` (PATH; same Mach-O also staged as Apple plugin `dev`) | +| Product binary | `adevcontainer` (PATH). Plugin `bin/dev` is an absolute symlink, not a copy | | GitHub repo | [wcgomes/apple-devcontainers](https://github.com/wcgomes/apple-devcontainers) (ex-`apple-dev-containers`, ex-`dev-containerization`) | | Release / install | CI + GitHub Release tarball; Homebrew primary — [release-distribution.md](conventions/release-distribution.md) | @@ -18,13 +18,13 @@ Swift/`ld` injects Xcode-shaped `CommandLineTools/Developer/...` paths that CLT ## Dual surface -PATH `adevcontainer` and `container dev …` are the same Mach-O (SPM product stays `adevcontainer`). Apple discovers `{install-root}/libexec/container-plugins/dev/{config.toml,bin/dev}` (`abstract`, no `[servicesConfig]`), not PATH. Install-root is the parent of Apple `container`’s `bin/` (typically `/usr/local`). `container system start` is required for Apple to list/dispatch plugins. +PATH `adevcontainer` and `container dev …` share one Mach-O: plugin `bin/dev` is an absolute symlink, not a copy (SPM product stays `adevcontainer`). Apple discovers `{install-root}/libexec/container-plugins/dev/{config.toml,bin/dev}` (`abstract`, no `[servicesConfig]`), not PATH. Install-root is the parent of Apple `container`’s `bin/` (typically `/usr/local`). `container system start` is required for Apple to list/dispatch plugins. Help/hints follow the invocation (`container dev` vs `adevcontainer`). Managed labels, guest paths, and cache names stay `adevcontainer`. Runtime still shells to Apple `container` (prefer `/usr/local/bin/container`); never the plugin binary. -Apple upgrades wipe that plugin dir ([apple/container#1617](https://github.com/apple/container/issues/1617)). PATH `adevcontainer` survives. Restage with PATH `adevcontainer install-plugin` (`sudo` when needed) — never `container dev install-plugin` when missing. Doctor is checks-only. Lifecycle does not restage. Contract: [`specs/plugin.md`](../specs/plugin.md); active [`install-plugin-command`](../specs/changes/install-plugin-command/). Doctor/identity: [`specs/core.md`](../specs/core.md). +`install-plugin` is removed. `plugin --install` creates that `bin/dev` symlink; Homebrew target is `$(brew --prefix)/opt/adevcontainer/bin/adevcontainer`, so `brew upgrade adevcontainer` updates the plugin without writing `/usr/local`. `plugin --uninstall` removes only `bin/dev` and `config.toml`. Brew is not sudo; formula `post_install` does not write Apple’s install-root. Apple upgrades wipe the plugin dir ([apple/container#1617](https://github.com/apple/container/issues/1617)); PATH `adevcontainer` survives. Run `sudo adevcontainer plugin --install` once, and again only after that wipe — never `container dev plugin --install` when the layout is missing. Doctor is checks-only and hints that PATH command. Lifecycle does not restage. Contract: [`specs/plugin.md`](../specs/plugin.md) and [`specs/changes/plugin-symlink-install/`](../specs/changes/plugin-symlink-install/). Where the still-active [`install-plugin-command`](../specs/changes/install-plugin-command/) delta says copy or `install-plugin`, the symlink change is the contract. Doctor/identity: [`specs/core.md`](../specs/core.md). ## Package layout @@ -65,8 +65,8 @@ Volume mode exists for better metadata I/O (git status, node_modules, many small | Command | Role | |---------|------| -| `doctor` | Host/runtime readiness; plugin layout. Checks-only (no restage). Missing plugin: PATH `adevcontainer install-plugin` (`sudo` when needed) — never `container dev install-plugin`. | -| `install-plugin` | Restage Apple plugin from PATH `adevcontainer` (`sudo` when needed). | +| `doctor` | Host/runtime readiness; plugin layout. Checks-only (no restage). Missing plugin: PATH `adevcontainer plugin --install` (`sudo` when needed) — never `container dev plugin --install`. | +| `plugin --install` / `plugin --uninstall` | Install or remove the Apple plugin layout. `--install` writes an absolute symlink at `bin/dev` (not a Mach-O copy). `--uninstall` removes only `bin/dev` and `config.toml`. | | `up` | **Bind-mode** only: resolve config from host workspace, create/start/reuse; ensure named volumes; workspace bind; Features; lifecycle hooks in scope. Optional `--vscode` after success (see [VS Code flow](#vs-code-flow)). Reuse only when stamped config hash matches; mismatch → fail `config_hash_mismatch` (remediate with `rebuild`). Eligible bring-up failure with an editable host `devcontainer.json` → [bring-up recovery](#bring-up-recovery). Author synchronization is not part of `up` fresh-create. | | `clone [--resume]` | **Volume-mode** workspace (VS Code clone-in-volume analogue): host sparse/shallow **config-only** fetch → resolve (workspaceFolder default + `${localWorkspaceFolderBasename}` = **git URL repo basename**, not temp dir name) → **author identity before Features/create:** host `git -C config --get user.name/email` (includeIf-aware; env `ADEVCONTAINER_GIT_AUTHOR_*`); both env → skip prompt; TTY confirm/override or collect; non-TTY silent + warn if incomplete → **ensure Features `ghcr.io/devcontainers/features/git:1` when no `git`/`common-utils`** (Features path, not apt; `up` unchanged) → ensure workspace volume → create + start (**SSH:** inject `create --ssh` when `SSH_AUTH_SOCK` set) → **in-container full `git clone`** + verify `.git` (**HTTPS:** host `git credential fill` one-shot → guest `credential.helper store`; no GCM-in-guest; no host full+tar happy path) → **author both:** a complete chosen pair writes `/.git/config` at local scope (persistence/source of truth) and the resolved connection user’s global Git config so sibling repositories/hooks inherit it; local config remains authoritative for the main repo; incomplete → warn, no partial → create-path hooks. Global HOME is container-scoped, not the persistence mechanism; workspace-local config survives via the bind path or workspace volume. Eligible failure retains the config checkout (not always-clean). Optional `--vscode` after success. Resume: `clone --resume ` — [bring-up recovery](#bring-up-recovery) | | `rebuild [--name]` | **Forced-rebuild** path (realized in domain specs; archive [`20260810-rebuild`](../specs/changes/archive/20260810-rebuild/)): managed selection (`--name` / auto-single / picker); read **current** stamped `devcontainer.json` before any delete and capture the existing workspace-local Git author identity as the sole source of truth (bind: host workspace; volume: old container workspace); after config/host/Features succeed → container-only delete old → create under the **live computed** name (user `name` change and old `adev-*` migrate). Reuse stem-keyed workspace `*-ws` and `${devcontainerId}` volumes; after replacement preparation, synchronize a complete pair to the replacement connection user’s global Git config before create-path hooks; no host config or other container users changed; missing/incomplete → no invented value. User-literal sources stay as written (data preserved, never re-clone). Foreign occupant of the new name: do not delete selected; TTY Y/n + new name. Volume/clone-origin with no host workspace still runs host `initializeCommand` from a temp guest-config root (not skip). Optional `--skip-pull` / `--vscode` / `--json`. Recovery mode-split (TTY prompt Y/n, retain): [gaps](domain/devcontainer-apple-gaps.md#failed-rebuild-recovery-mode-split) | diff --git a/wiki/conventions/release-distribution.md b/wiki/conventions/release-distribution.md index 444299c..46d6bf0 100644 --- a/wiki/conventions/release-distribution.md +++ b/wiki/conventions/release-distribution.md @@ -5,7 +5,7 @@ How `adevcontainer` binaries are built, versioned, and installed. Decided 2026-0 ## Repo identity - **GitHub:** [wcgomes/apple-devcontainers](https://github.com/wcgomes/apple-devcontainers) (renamed 2026-08 from `wcgomes/apple-dev-containers`, previously `wcgomes/dev-containerization`; old URLs 301). -- **Product/binary:** still `adevcontainer` (PATH). Same Mach-O is also staged as Apple plugin `dev`. Local clone folder may still be named `dev-containerization`. +- **Product/binary:** still `adevcontainer` (PATH). Plugin `bin/dev` is an absolute symlink, not a copied Mach-O. Local clone folder may still be named `dev-containerization`. - **Homebrew tap:** unchanged (`wcgomes/homebrew-tap`); formula homepage/url point at the new repo. ## Constraints @@ -58,7 +58,7 @@ Do not paste workflow YAML into the wiki — edit the files under `.github/workf ## Install UX (priority order) -1. **Homebrew (primary)** — public tap [wcgomes/homebrew-tap](https://github.com/wcgomes/homebrew-tap); sole formula SoT `Formula/adevcontainer.rb`. Install: `brew tap wcgomes/tap && brew install adevcontainer`. Non-prerelease release auto-bumps the tap (`HOMEBREW_TAP_TOKEN` + `scripts/render-homebrew-formula.sh`). No in-repo formula mirror. `post_install` restages the Apple plugin under `{install-root}/libexec/container-plugins/dev/`. Caveats: Apple `container` is a separate runtime install; reinstall this formula after upgrading `container` (upgrade wipes user plugins). Dual-surface contract: [`specs/plugin.md`](../../specs/plugin.md). +1. **Homebrew (primary)** — public tap [wcgomes/homebrew-tap](https://github.com/wcgomes/homebrew-tap); sole formula SoT `Formula/adevcontainer.rb`. Install: `brew tap wcgomes/tap && brew install adevcontainer`. Non-prerelease release auto-bumps the tap (`HOMEBREW_TAP_TOKEN` + `scripts/render-homebrew-formula.sh`). No in-repo formula mirror. Formula `post_install` does not write Apple’s install-root (brew is not sudo). Caveats: Apple `container` is a separate runtime install; run `sudo adevcontainer plugin --install` once after install. That makes `{install-root}/libexec/container-plugins/dev/bin/dev` an absolute symlink to `$(brew --prefix)/opt/adevcontainer/bin/adevcontainer`, so `brew upgrade adevcontainer` updates the plugin without writing `/usr/local`. Run it again only after an Apple `container` upgrade wipes the plugin dir ([apple/container#1617](https://github.com/apple/container/issues/1617)). `plugin --uninstall` removes only `bin/dev` and `config.toml`. Contract: [`specs/plugin.md`](../../specs/plugin.md); [`specs/changes/plugin-symlink-install/`](../../specs/changes/plugin-symlink-install/). 2. **GitHub Release curl/tar** — download tarball + verify sha256; fallback for non-brew users. 3. **Source build** — documented in README (`swift build -c release` on macOS 26+ Apple Silicon). @@ -68,5 +68,5 @@ Do not paste workflow YAML into the wiki — edit the files under `.github/workf - Bump/tag version; release workflow owns inject + artifact + GH Release + Homebrew bump (non-prerelease). - Ensure `HOMEBREW_TAP_TOKEN` is configured for non-prerelease ships; missing token fails the job. Prereleases skip brew bump. - Formula content comes from `scripts/render-homebrew-formula.sh` — do not hand-edit sha/version in the tap formula as part of a normal release. -- Keep formula `post_install` restaging the plugin and caveats telling users to reinstall after upgrading `container`. +- Do not write Apple’s install-root from formula `post_install`. Caveats name `sudo adevcontainer plugin --install` once after install, and again only after upgrading Apple `container`. - Still no notarize unless an explicit decision supersedes this page. From 8cddee76698355b624332aca6b7fcf205f2e5cff Mon Sep 17 00:00:00 2001 From: Wyller Gomes Date: Fri, 25 Sep 2026 05:21:37 +0000 Subject: [PATCH 2/4] docs: archive plugin symlink specs Both plugin changes are archived; `specs/plugin.md` holds the symlink contract; wiki links point at the archive. --- .../proposal.md | 40 +++ .../20260925-install-plugin-command/spec.md | 246 ++++++++++++++++++ .../20260925-install-plugin-command/tasks.md | 19 ++ .../proposal.md | 6 +- .../20260925-plugin-symlink-install}/spec.md | 2 +- .../20260925-plugin-symlink-install}/tasks.md | 2 +- .../install-plugin-command/proposal.md | 39 --- specs/changes/install-plugin-command/spec.md | 119 --------- specs/changes/install-plugin-command/tasks.md | 90 ------- specs/core.md | 6 +- specs/plugin.md | 224 +++++++++++++--- wiki/architecture.md | 2 +- wiki/conventions/release-distribution.md | 2 +- 13 files changed, 507 insertions(+), 290 deletions(-) create mode 100644 specs/changes/archive/20260925-install-plugin-command/proposal.md create mode 100644 specs/changes/archive/20260925-install-plugin-command/spec.md create mode 100644 specs/changes/archive/20260925-install-plugin-command/tasks.md rename specs/changes/{plugin-symlink-install => archive/20260925-plugin-symlink-install}/proposal.md (88%) rename specs/changes/{plugin-symlink-install => archive/20260925-plugin-symlink-install}/spec.md (98%) rename specs/changes/{plugin-symlink-install => archive/20260925-plugin-symlink-install}/tasks.md (95%) delete mode 100644 specs/changes/install-plugin-command/proposal.md delete mode 100644 specs/changes/install-plugin-command/spec.md delete mode 100644 specs/changes/install-plugin-command/tasks.md diff --git a/specs/changes/archive/20260925-install-plugin-command/proposal.md b/specs/changes/archive/20260925-install-plugin-command/proposal.md new file mode 100644 index 0000000..f7e11c6 --- /dev/null +++ b/specs/changes/archive/20260925-install-plugin-command/proposal.md @@ -0,0 +1,40 @@ +# Proposal: Plugin restage aligned to symlink install + +## Intent + +`doctor --repair` mixed host preflight with plugin restage. This change originally replaced that flag with `install-plugin` and a Mach-O copy. Before archive, those overlapping requirements are corrected to the symlink contract: `plugin --install` and `plugin --uninstall`, an absolute symlink, and no Homebrew write to Apple’s install-root. + +## Scope + +- Change id: **`install-plugin-command`**. +- Affected domains: [plugin.md](../../../plugin.md) **Explicit plugin restage after Apple upgrade wipe**; [core.md](../../../core.md) **Doctor preflight**. +- Authoritative contract: [plugin-symlink-install](../20260925-plugin-symlink-install/spec.md). This corrected delta matches that contract and does not add requirements. +- Restage is `plugin --install` / `plugin --uninstall`. The plugin binary is an absolute symlink, not a copy. Homebrew target is `$(brew --prefix)/opt/adevcontainer/bin/adevcontainer`. Uninstall removes layout entries only. Formula `post_install` does not write `/usr/local`. Doctor hints PATH `adevcontainer plugin --install`. There is no `install-plugin` command and no `doctor --repair` restage. + +## Non-goals + +- Requirements that are not in the symlink change. +- Auto-restage on `up` or any other lifecycle command. +- Wiki edits. +- Waiting on unmerged home-dir plugin discovery ([apple/container#1618](https://github.com/apple/container/pull/1618)). +- ContainerAPIClient / XPC migration, or shipping under Apple’s bundled `libexec/container/plugins/`. +- Renaming the formula, the plugin binary, or the SPM product. + +## Approach + +Rewrite the overlapping requirements to the symlink contract, then archive. `plugin` requires exactly one of `--install` or `--uninstall`. `--install` writes `config.toml` as a regular file and makes `bin/dev` an absolute symlink to the Homebrew opt path or, outside Homebrew, to the running executable path without realpath. `--uninstall` removes only those two layout entries. Homebrew `post_install` does not write Apple’s install-root. Doctor hints PATH `adevcontainer plugin --install`. + +## Decision index + +- **Command is `plugin`, not `install-plugin`:** `plugin --install` and `plugin --uninstall` are the only restage verbs. `install-plugin` is not a command and is not an alias. Neither flag, or both flags, is a usage error. Exactly one flag is required. +- **Install is an absolute symlink, not a copy:** `--install` removes `bin/dev` when it is a regular file or a symlink to the wrong target, then creates an absolute symlink. An already-correct absolute symlink is left in place. `config.toml` is always written as a regular file. `--install` MUST NOT copy the Mach-O into the plugin layout or change the mode or contents of the symlink target. +- **Homebrew target is the opt path:** When the running executable is Homebrew formula `adevcontainer`, including a versioned Cellar path and including `sudo` with a bare argv0, the symlink target MUST be `$(brew --prefix)/opt/adevcontainer/bin/adevcontainer`. It MUST NOT be a Cellar path and MUST NOT be a realpath of the keg. If that opt path cannot be formed, fail rather than fall back. +- **Non-Homebrew target is the running executable path:** No realpath resolution. A bare argv0 MUST NOT be treated as a relative source path. +- **Uninstall removes layout entries only:** `--uninstall` removes `bin/dev` and `config.toml` only. It MUST NOT delete the symlink target, the Homebrew keg, or any other path. A missing layout is success. +- **Doctor hints PATH `plugin --install`:** Missing-plugin remediation names PATH `adevcontainer plugin --install`, including `sudo` when the destination requires elevation. It MUST NOT name `container dev plugin --install`. `--repair` does not restage; any `--repair` fails as usage and hints `plugin --install`. Lifecycle commands do not restage. +- **Homebrew does not write Apple’s install-root:** `post_install` MUST NOT write `/usr/local/libexec/...` or any other Apple `container` install-root plugin path, and MUST NOT copy or chmod the formula binary into that layout. Caveats tell the user to run `sudo adevcontainer plugin --install` once after install, and again only after upgrading Apple `container`. + +## Clarifications + +- **Q:** The active `install-plugin-command` delta still required a Mach-O copy, `install-plugin`, and `post_install` restage. Archive it with `plugin-symlink-install`? +- **A:** Correct the overlapping requirements to the symlink contract first, then archive both. The symlink change is authoritative. Do not keep the copy text in the source spec. diff --git a/specs/changes/archive/20260925-install-plugin-command/spec.md b/specs/changes/archive/20260925-install-plugin-command/spec.md new file mode 100644 index 0000000..8eb85be --- /dev/null +++ b/specs/changes/archive/20260925-install-plugin-command/spec.md @@ -0,0 +1,246 @@ +# Change Spec: install-plugin-command + +Corrected before archive to match the symlink contract in [plugin-symlink-install](../20260925-plugin-symlink-install/spec.md). This delta no longer requires a Mach-O copy, `install-plugin`, or Homebrew `post_install` restage of Apple’s install-root. The requirements below are that contract’s overlapping restage and doctor requirements, plus uninstall layout-only. They are not new. Where both deltas speak, `plugin-symlink-install` is authoritative. + +## ADDED Requirements + +### Requirement: Plugin uninstall removes layout entries only + +`plugin --uninstall` MUST remove `{install-root}/libexec/container-plugins/dev/bin/dev` when that path is a symlink or a regular file, and MUST remove `{install-root}/libexec/container-plugins/dev/config.toml`. It MUST NOT delete the symlink target of `bin/dev`, MUST NOT delete the Homebrew keg or the opt-path executable, and MUST NOT remove any other path, including the plugin directory itself. Removing `config.toml` MUST unlink that path and MUST NOT delete a symlink target if `config.toml` is a symlink. When `bin/dev` and `config.toml` are already absent, uninstall MUST succeed. Uninstall MUST NOT require `container system start`. + +When the destination is not writable, uninstall MUST re-exec with elevated privileges to complete the removal, or MUST print exact remediation naming PATH `adevcontainer plugin --uninstall` including `sudo`. That remediation MUST NOT name `container dev plugin --uninstall`. + +#### Scenario: plugin --uninstall removes the plugin binary and config.toml only + +- Given `bin/dev` is a symlink to an existing executable and `config.toml` exists, and the plugin directory contains another file +- When the user runs `plugin --uninstall` +- Then `bin/dev` and `config.toml` are gone, the symlink target still exists, and the other file remains + +#### Scenario: plugin --uninstall removes a leftover copy without deleting the keg + +- Given `bin/dev` is a regular file left by an older copy install, and the Homebrew keg and opt-path executable exist elsewhere +- When the user runs `plugin --uninstall` +- Then the leftover `bin/dev` and `config.toml` are removed and the keg and opt-path executable are unchanged + +#### Scenario: plugin --uninstall succeeds when the layout is missing + +- Given Apple `container` is installed and the plugin layout is already absent +- When the user runs `plugin --uninstall` +- Then the command succeeds and does not create the layout + +#### Scenario: plugin --uninstall uses elevated privileges when required + +- Given the plugin destination is not writable by the current user +- When the user runs `plugin --uninstall` +- Then the product re-execs with elevated privileges or prints exact remediation naming PATH `adevcontainer plugin --uninstall` with `sudo`, and MUST NOT name `container dev plugin --uninstall` + +#### Scenario: plugin --uninstall does not require container system start + +- Given Apple `container` is installed and Apple container services are not running +- When the user runs `adevcontainer plugin --uninstall` +- Then uninstall completes and MUST NOT fail because `container system start` has not been run + +## MODIFIED Requirements + +### Requirement: Explicit plugin restage after Apple upgrade wipe + +Apple installer or Homebrew keg replacement of `libexec` wipes user plugins under `container-plugins/` ([apple/container#1617](https://github.com/apple/container/issues/1617)). PATH `adevcontainer` survives. `container dev` then fails until the plugin layout is installed again. + +The product MUST NOT restage the plugin layout on `up` or any other lifecycle command. `doctor` MUST detect a missing plugin layout and MUST print exact remediation naming PATH `adevcontainer plugin --install`, including `sudo` when the destination requires elevation. It MUST NOT suggest `container dev plugin --install` for a missing layout. A present layout is `config.toml` plus `bin/dev`, including when `bin/dev` is a symlink. Doctor MUST NOT require `bin/dev` to be a regular file and MUST NOT restage. + +The product MUST provide `plugin` with `--install` and `--uninstall`. It MUST NOT provide `install-plugin` as a command or as an alias. `plugin` with neither flag, or with both flags, MUST fail as a usage error that names `--install` and `--uninstall` and states that exactly one is required. Usage and `plugin` help MUST document both flags and MUST NOT document `install-plugin` or `--repair`. + +`plugin --install` and `plugin --uninstall` MUST derive install-root from the parent of Apple `container`’s `bin/` directory. If that binary cannot be resolved, they MUST fail with the existing missing-runtime error. They MUST NOT require `container system start` to succeed. + +`plugin --install` MUST create `{install-root}/libexec/container-plugins/dev/bin/dev` as an absolute symlink and MUST write `config.toml` as a regular file with `abstract` and without `[servicesConfig]`. It MUST remove `bin/dev` first when that path is a regular file or a symlink whose target string is not the required absolute path, including a relative symlink that would resolve to the same file, and MUST NOT delete the old symlink’s target. If `bin/dev` is already an absolute symlink whose target string equals the required path, `--install` MUST succeed without replacing that symlink. It MUST still write `config.toml` as a regular file. If an existing `config.toml` is a symlink, `--install` MUST replace that path with a regular file and MUST NOT write through the symlink. + +The required symlink target depends on how this product is installed. When the running executable is Homebrew formula `adevcontainer` — including when its path is a versioned Cellar path, and including when the process is invoked under `sudo` with a bare argv0 — the target MUST be the absolute path `$(brew --prefix)/opt/adevcontainer/bin/adevcontainer`. It MUST NOT be a versioned Cellar path and MUST NOT be the result of resolving symlinks in the keg. If that opt path cannot be formed, `--install` MUST fail and MUST NOT fall back to a Cellar path or a realpath-resolved path. When the running executable is not that Homebrew formula, the target MUST be the running executable’s path with no realpath resolution. A bare argv0 MUST NOT be treated as a relative source path. + +When argv0 or the identified executable path is the plugin binary `{install-root}/libexec/container-plugins/dev/bin/dev` — an absolute path or the process name `dev` — `--install` MUST NOT create a symlink whose target is that plugin path. It MUST resolve the installed executable instead. If `bin/dev` is already an absolute symlink to a different path, apply the Homebrew vs non-Homebrew target rules to that link text: do not realpath a Cellar keg; a Cellar link becomes the opt path; an already-correct opt link is kept. If `bin/dev` is a regular file, a relative symlink, or a self-symlink, locate PATH `adevcontainer` or the Homebrew opt binary if present and link to that. If no installed executable other than `bin/dev` can be identified, `--install` MUST fail and MUST NOT create a self-symlink. PATH `adevcontainer plugin --install` is unchanged. Doctor missing-plugin remediation MUST still name PATH `adevcontainer plugin --install` and MUST NOT name `container dev plugin --install`. + +`--install` MUST NOT copy the Mach-O into the plugin layout and MUST NOT change the mode or contents of the symlink target. + +When the destination is not writable, `--install` MUST re-exec with elevated privileges to complete the install, or MUST print exact remediation naming PATH `adevcontainer plugin --install` including `sudo`. That remediation MUST NOT name `container dev plugin --install`. + +`--repair` MUST NOT restage. Any `--repair` MUST fail as a usage error whose hint names `plugin --install` and MUST NOT name `install-plugin`. + +Homebrew formula `post_install` MUST NOT write Apple’s install-root plugin layout (`/usr/local/libexec/...` or any other Apple `container` install-root `container-plugins` path) and MUST NOT copy or chmod the formula binary into that layout. Homebrew caveats MUST tell the user to run `sudo adevcontainer plugin --install` once after install, and again only after upgrading Apple `container`. Caveats MUST NOT tell the user to `brew reinstall` or `brew upgrade` this formula in order to restage the plugin. When the plugin symlink already targets `$(brew --prefix)/opt/adevcontainer/bin/adevcontainer`, `brew upgrade adevcontainer` MUST NOT require `plugin --install` again. README and source-install docs MUST name `adevcontainer plugin --install` (with `sudo` when the destination requires elevation) and MUST NOT name `install-plugin`. The product MUST NOT wait on unmerged home-dir plugin discovery. + +#### Scenario: PATH doctor still runs when plugin layout is missing + +- Given PATH `adevcontainer` is installed and the plugin layout is missing +- When the user runs `adevcontainer doctor` +- Then doctor runs as the PATH binary and reports the missing plugin layout + +#### Scenario: Lifecycle commands do not restage a missing plugin + +- Given the plugin layout is missing +- When the user runs `up` or another lifecycle command +- Then the product does not create the plugin symlink or write `config.toml` into the plugin layout + +#### Scenario: Doctor reports missing plugin layout with exact remediation + +- Given the plugin layout is missing +- When the user runs `adevcontainer doctor` +- Then doctor exits non-zero and prints exact remediation naming PATH `adevcontainer plugin --install` (with `sudo` when the destination requires elevation), not `container dev plugin --install` + +#### Scenario: plugin --install creates an absolute symlink and writes config.toml + +- Given the running executable and a writable plugin destination derived from Apple `container`’s install-root +- When the user runs `plugin --install` +- Then `bin/dev` is an absolute symlink to the required target, and `config.toml` is a regular file with `abstract` and without `[servicesConfig]` + +#### Scenario: plugin --install replaces a copied plugin binary + +- Given `bin/dev` is a regular file +- When the user runs `plugin --install` +- Then the product removes that regular file and creates an absolute symlink to the required target + +#### Scenario: plugin --install replaces a symlink to the wrong target + +- Given `bin/dev` is a relative symlink, or an absolute symlink whose target string is not the required path +- When the user runs `plugin --install` +- Then the product removes that symlink without deleting its target and creates an absolute symlink to the required target + +#### Scenario: plugin --install keeps an already-correct symlink + +- Given `bin/dev` is already an absolute symlink whose target string equals the required path +- When the user runs `plugin --install` +- Then the command succeeds without replacing that symlink, and `config.toml` is a regular file with `abstract` and without `[servicesConfig]` + +#### Scenario: plugin --install writes config.toml as a regular file + +- Given an existing `config.toml` is a symlink +- When the user runs `plugin --install` +- Then `config.toml` is replaced with a regular file that includes `abstract` and omits `[servicesConfig]`, and the previous symlink target is unchanged + +#### Scenario: Homebrew install links the opt path + +- Given the running executable is Homebrew formula `adevcontainer`, including a versioned Cellar path and including invocation under `sudo` with a bare argv0 +- When the user runs `plugin --install` +- Then `bin/dev` is an absolute symlink to `$(brew --prefix)/opt/adevcontainer/bin/adevcontainer`, not a versioned Cellar path and not the result of resolving symlinks in the keg +- And if that opt path cannot be formed, the command fails and does not create a Cellar-path or realpath-resolved symlink + +#### Scenario: Non-Homebrew install links the running executable path + +- Given the running executable is not Homebrew formula `adevcontainer` +- When the user runs `plugin --install` +- Then `bin/dev` is an absolute symlink to the running executable’s path, and that path is not realpath-resolved + +#### Scenario: plugin --install does not symlink bin/dev to itself + +- Given argv0 is the plugin binary `{install-root}/libexec/container-plugins/dev/bin/dev` or the process name `dev` +- When the user runs `plugin --install` +- Then `bin/dev` is not a symlink whose target is that plugin path +- And if `bin/dev` was already an absolute symlink to a different path, the target follows the Homebrew or non-Homebrew rule for that link text (a Cellar link becomes the opt path; an already-correct opt link is kept; the keg is not realpath-resolved) +- And if `bin/dev` is a regular file, a relative symlink, or a self-symlink, the target is PATH `adevcontainer` or the Homebrew opt binary when one of those is present +- And if no installed executable other than `bin/dev` can be identified, the command fails and does not create a self-symlink + +#### Scenario: plugin --install does not treat a bare argv0 as a relative source path + +- Given a non-Homebrew install invoked with a bare executable name (for example under `sudo`) +- When the user runs `plugin --install` +- Then the symlink target is the running executable’s absolute path and MUST NOT be the bare name as a relative path + +#### Scenario: plugin --install does not mutate the symlink target + +- Given a symlink target that is the installed executable, including a Homebrew keg binary +- When the user runs `plugin --install` +- Then the product does not copy the Mach-O into the plugin layout and does not change the mode or contents of that target + +#### Scenario: plugin --install uses elevated privileges when required + +- Given the plugin destination is not writable by the current user +- When the user runs `plugin --install` +- Then the product re-execs with elevated privileges or prints exact remediation naming PATH `adevcontainer plugin --install` with `sudo`, and MUST NOT name `container dev plugin --install` + +#### Scenario: plugin --install does not require container system start + +- Given Apple `container` is installed and Apple container services are not running +- When the user runs `adevcontainer plugin --install` +- Then the product installs the plugin layout and MUST NOT fail because `container system start` has not been run + +#### Scenario: plugin with neither flag is a usage error + +- Given the user invokes `plugin` with neither `--install` nor `--uninstall` +- When the command parses +- Then it fails as a usage error that names both flags and states that exactly one is required + +#### Scenario: plugin with both flags is a usage error + +- Given the user invokes `plugin` with both `--install` and `--uninstall` +- When the command parses +- Then it fails as a usage error that names both flags and states that exactly one is required + +#### Scenario: install-plugin is not a command + +- Given the product command surface +- When the user runs `install-plugin` +- Then the command fails as an unknown command, is not an alias for `plugin --install`, and the hint names `plugin` and does not list `install-plugin` as a valid command + +#### Scenario: --repair fails as usage and hints plugin --install + +- Given any command, including `doctor` and `plugin` +- When the user passes `--repair` +- Then the command fails as a usage error and the hint names `plugin --install` and does not name `install-plugin` + +#### Scenario: Homebrew post_install does not write Apple's install-root + +- Given the Homebrew formula rendered by `scripts/render-homebrew-formula.sh` +- When `post_install` is inspected +- Then it does not write Apple’s install-root plugin layout and does not copy or chmod the formula binary into that layout + +#### Scenario: Homebrew caveats name plugin --install + +- Given the Homebrew formula is installed +- When caveats are shown +- Then they tell the user to run `sudo adevcontainer plugin --install` once after install, and again only after upgrading Apple `container` +- And they do not tell the user to `brew reinstall` or `brew upgrade` this formula in order to restage the plugin + +#### Scenario: brew upgrade does not require restage when the symlink targets the opt path + +- Given `bin/dev` is an absolute symlink to `$(brew --prefix)/opt/adevcontainer/bin/adevcontainer` +- When the user upgrades formula `adevcontainer` +- Then the symlink remains valid without running `plugin --install` again + +#### Scenario: Tarball install uses plugin --install + +- Given the user installs from the release tarball onto PATH +- When install instructions are followed +- Then the plugin layout is installed with PATH `adevcontainer plugin --install` (with `sudo` when the destination requires elevation), not with `install-plugin` and not by copying the Mach-O into the plugin layout + +#### Scenario: Source install uses plugin --install + +- Given the user builds from source +- When install instructions are followed +- Then the plugin layout is installed with PATH `adevcontainer plugin --install` (with `sudo` when the destination requires elevation), not with `install-plugin` + +### Requirement: Doctor preflight + +`adevcontainer doctor` MUST verify host readiness before users rely on `up`: Apple `container` binary presence (default path `/usr/local/bin/container` or PATH resolution), invokability, and a reported version suitable for machine use. Doctor MUST also verify the Apple CLI plugin layout for `dev` under the install-root parent of Apple `container`’s `bin/`. Doctor MUST emit a clear pass/fail summary. Doctor MUST NOT require a `devcontainer.json`. + +PATH invocation of `adevcontainer doctor` MUST keep the existing missing-binary failure. Success MUST still report Apple `container` binary path and version and MUST require the plugin layout to be present, including when `bin/dev` is a symlink. When Apple container services are not running, doctor MUST surface that `container system start` is required (Apple needs it to list/dispatch plugins). Doctor MUST NOT restage the plugin layout. Doctor MUST NOT accept `--repair`. When the plugin layout is missing, doctor MUST fail and print PATH `adevcontainer plugin --install` remediation as specified in **Explicit plugin restage after Apple upgrade wipe**. + +#### Scenario: Doctor success + +- Given Apple `container` is installed and runnable and the plugin layout is present +- When the user runs `adevcontainer doctor` +- Then the command exits 0 and reports binary path and version + +#### Scenario: Doctor missing binary + +- Given `container` is not on PATH and not at the default path +- When the user runs `adevcontainer doctor` +- Then the command exits non-zero with a structured error explaining the missing runtime + +#### Scenario: Doctor does not require devcontainer.json + +- Given a directory with no `devcontainer.json` +- When the user runs `adevcontainer doctor` +- Then doctor does not fail for missing configuration + +#### Scenario: Doctor surfaces container system start when not running + +- Given Apple `container` is installed and the system status is not running +- When the user runs `adevcontainer doctor` +- Then doctor exits non-zero and tells the user to run `container system start` diff --git a/specs/changes/archive/20260925-install-plugin-command/tasks.md b/specs/changes/archive/20260925-install-plugin-command/tasks.md new file mode 100644 index 0000000..de3c237 --- /dev/null +++ b/specs/changes/archive/20260925-install-plugin-command/tasks.md @@ -0,0 +1,19 @@ +# Tasks: install-plugin-command + +Spec ref: `specs/changes/archive/20260925-install-plugin-command/`. + +Corrected before archive. This checklist no longer mandates a Mach-O copy, `install-plugin`, or Homebrew `post_install` restage. The authoritative contract, and the implementation tasks that realized it, are `specs/changes/archive/20260925-plugin-symlink-install/`. Do not edit the wiki. Do not edit other archived specs. + +## 1. Align overlapping requirements to the symlink contract + +- [x] 1.1 Rewrite this change so its requirements match the symlink contract: `plugin --install` / `plugin --uninstall`; absolute symlink, not a copy; Homebrew opt target `$(brew --prefix)/opt/adevcontainer/bin/adevcontainer`; uninstall removes layout entries only; `post_install` does not write Apple’s install-root; doctor hints PATH `adevcontainer plugin --install`; `install-plugin` is not a command; `--repair` does not restage (path: `specs/changes/archive/20260925-install-plugin-command/spec.md`) + +## Checkpoint + +- [x] verify **plugin --install creates an absolute symlink and writes config.toml** +- [x] verify **Homebrew install links the opt path** +- [x] verify **plugin --uninstall removes the plugin binary and config.toml only** +- [x] verify **Homebrew post_install does not write Apple's install-root** +- [x] verify **Doctor reports missing plugin layout with exact remediation** +- [x] verify **install-plugin is not a command** +- [x] verify **--repair fails as usage and hints plugin --install** diff --git a/specs/changes/plugin-symlink-install/proposal.md b/specs/changes/archive/20260925-plugin-symlink-install/proposal.md similarity index 88% rename from specs/changes/plugin-symlink-install/proposal.md rename to specs/changes/archive/20260925-plugin-symlink-install/proposal.md index 48a6d51..e9c05cb 100644 --- a/specs/changes/plugin-symlink-install/proposal.md +++ b/specs/changes/archive/20260925-plugin-symlink-install/proposal.md @@ -7,15 +7,15 @@ Copying the Mach-O into Apple’s install-root cannot be done by Homebrew: `brew ## Scope - Change id: **`plugin-symlink-install`**. -- Affected live contract: [plugin.md](../../plugin.md) **Dual install as Apple CLI plugin `dev`** and **Explicit plugin restage after Apple upgrade wipe**; [core.md](../../core.md) **Doctor preflight** as already modified by the active [install-plugin-command](../install-plugin-command/spec.md) delta. -- This change supersedes the overlapping requirements in that still-active delta (copy the Mach-O; `install-plugin`; Homebrew `post_install` restages `/usr/local`). Do not edit or archive `specs/changes/install-plugin-command/` in this change. Where both active deltas speak, this change is the contract. +- Affected live contract: [plugin.md](../../../plugin.md) **Dual install as Apple CLI plugin `dev`** and **Explicit plugin restage after Apple upgrade wipe**; [core.md](../../../core.md) **Doctor preflight** as modified by the corrected [install-plugin-command](../20260925-install-plugin-command/spec.md) delta. +- This change is the authoritative contract. Overlapping requirements in that corrected delta match this contract (absolute symlink, `plugin --install` / `plugin --uninstall`, no Mach-O copy, no `install-plugin`, `post_install` does not write `/usr/local`). Where both deltas speak, this change is the contract. - In-scope outcomes: `plugin --install` / `plugin --uninstall`; symlink target rules; elevation and doctor remediation; lifecycle still does not restage; Homebrew formula renderer, README, and CONTRIBUTING name the new command and stop writing Apple’s install-root. - Formula name stays `adevcontainer`. Plugin binary name stays `dev`. SPM product stays `adevcontainer`. ## Non-goals - Product-code, test, or docs implementation while writing these artifacts. -- Editing or archiving `specs/changes/install-plugin-command/`. +- Further edits to the corrected archive [`20260925-install-plugin-command`](../20260925-install-plugin-command/spec.md) after this archive. - Wiki edits. `wiki/architecture.md` and `wiki/conventions/release-distribution.md` still describe copy-based `install-plugin` and Homebrew `post_install` restage; that descriptive text conflicts with this confirmed contract and is left stale on purpose. - Pushing the external tap `wcgomes/homebrew-tap`. The in-repo renderer is the formula content to change; the release workflow publishes it later. - `design.md`. The decisions below stay understandable without an alternatives write-up. diff --git a/specs/changes/plugin-symlink-install/spec.md b/specs/changes/archive/20260925-plugin-symlink-install/spec.md similarity index 98% rename from specs/changes/plugin-symlink-install/spec.md rename to specs/changes/archive/20260925-plugin-symlink-install/spec.md index bb46eb3..d13d120 100644 --- a/specs/changes/plugin-symlink-install/spec.md +++ b/specs/changes/archive/20260925-plugin-symlink-install/spec.md @@ -1,6 +1,6 @@ # Change Spec: plugin-symlink-install -This delta supersedes the overlapping requirements in the still-active `specs/changes/install-plugin-command/spec.md`. Do not edit or archive that change as part of this work. Where both active deltas speak, this change is the contract. +This delta is the authoritative contract. Overlapping requirements in the corrected [install-plugin-command](../20260925-install-plugin-command/spec.md) delta match this contract. Where both deltas speak, this change is the contract. ## ADDED Requirements diff --git a/specs/changes/plugin-symlink-install/tasks.md b/specs/changes/archive/20260925-plugin-symlink-install/tasks.md similarity index 95% rename from specs/changes/plugin-symlink-install/tasks.md rename to specs/changes/archive/20260925-plugin-symlink-install/tasks.md index c919863..9170031 100644 --- a/specs/changes/plugin-symlink-install/tasks.md +++ b/specs/changes/archive/20260925-plugin-symlink-install/tasks.md @@ -1,6 +1,6 @@ # Tasks: plugin-symlink-install -Spec ref: `specs/changes/plugin-symlink-install/`. Execute test-first: write the failing tests and confirm the expected failures before changing production code. This change supersedes overlapping `install-plugin` copy and Homebrew `post_install` restage requirements. Do not edit or archive `specs/changes/install-plugin-command/`. Do not edit the wiki. Do not push `wcgomes/homebrew-tap`. Do not stage or commit `.devcontainer/devcontainer.json` or `.devcontainer/install-tools.sh`. Formula name stays `adevcontainer`. Plugin binary name stays `dev`. SPM product stays `adevcontainer`. +Spec ref: `specs/changes/archive/20260925-plugin-symlink-install/`. Execute test-first: write the failing tests and confirm the expected failures before changing production code. This change is the authoritative contract and supersedes overlapping copy and Homebrew `post_install` restage requirements. The corrected sibling is `specs/changes/archive/20260925-install-plugin-command/`. Do not edit the wiki. Do not push `wcgomes/homebrew-tap`. Do not stage or commit `.devcontainer/devcontainer.json` or `.devcontainer/install-tools.sh`. Formula name stays `adevcontainer`. Plugin binary name stays `dev`. SPM product stays `adevcontainer`. Verified test paths beyond the command modules: `Tests/adevcontainerTests/AllCommandTests.swift` already calls `InstallPluginCommand.run` and expects a copied Mach-O while services are stopped. `Tests/adevcontainerTests/main.swift` is the suite-of-record entry. Both must be updated or the suite cannot match this contract. No other product files are in scope. diff --git a/specs/changes/install-plugin-command/proposal.md b/specs/changes/install-plugin-command/proposal.md deleted file mode 100644 index bbb19ef..0000000 --- a/specs/changes/install-plugin-command/proposal.md +++ /dev/null @@ -1,39 +0,0 @@ -# Proposal: Dedicated `install-plugin` restage command - -## Intent - -`doctor --repair` mixes host preflight with plugin restage. After an Apple upgrade wipe, users need a PATH-only restage verb that copies the running Mach-O even under `sudo`, and `doctor` must stay a check-only command. This change replaces `doctor --repair` with `adevcontainer install-plugin`. - -## Scope - -- Change id: **`install-plugin-command`**. -- Affected live domains: [plugin.md](../../plugin.md) **Explicit plugin restage after Apple upgrade wipe**; [core.md](../../core.md) **Doctor preflight**. -- Restage is the `install-plugin` verb only. `--repair` is gone. -- `doctor` still detects a missing plugin and prints PATH `adevcontainer install-plugin` (`sudo` when the destination needs elevation). It MUST NOT print `container dev install-plugin` when the layout is missing. -- `install-plugin` only restages (copy running Mach-O + `config.toml`). It MUST NOT require `container system start` to succeed. It MUST resolve the source via the running executable, not a bare argv0. -- Lifecycle still does not restage. Homebrew, tarball, and source docs use `install-plugin`. - -## Non-goals - -- Auto-repair on `up` or any other lifecycle command. -- Changing doctor host checks other than removing restage: binary, version, system running, and plugin present remain. -- Wiki edits or edits to archived `specs/changes/archive/20260917-container-dev-plugin/`. -- Waiting on unmerged home-dir plugin discovery ([apple/container#1618](https://github.com/apple/container/pull/1618)). -- ContainerAPIClient / XPC migration. -- First-party ship under Apple bundled `libexec/container/plugins/`. -- Product-code implementation as part of writing these artifacts. - -## Approach - -Add `install-plugin` to the dispatcher alongside `doctor` / `up` / …. Move restage (copy running Mach-O + `config.toml`, elevation when required) onto that verb. Keep the existing running-executable source resolution (not bare argv0) under `install-plugin`. Strip `--repair` from `doctor` and reject `--repair` as usage with a hint naming `install-plugin`. Point missing-plugin doctor remediation and Homebrew/tarball/source docs at PATH `adevcontainer install-plugin`. - -## Decision index - -- **Restage is `install-plugin`, not a doctor flag:** Plugin restage after Apple upgrade wipe is a first-class verb in the dispatcher. `doctor` remains checks only. -- **`--repair` is gone:** Any `--repair` MUST fail as usage. The usage hint MUST name `install-plugin`. -- **Missing-plugin remediation is PATH-only:** When the layout is missing, `container dev` cannot start. Doctor MUST print PATH `adevcontainer install-plugin` (`sudo` when the destination needs elevation). It MUST NOT suggest `container dev install-plugin`. -- **`install-plugin` only restages:** It MUST copy the running Mach-O and write `config.toml`. It MUST NOT require `container system start` to succeed. It MUST NOT run doctor’s version/status success path as a restage precondition. -- **Source is the running executable:** Restage MUST resolve the source Mach-O from the running executable (PATH / symlink target as needed). It MUST NOT treat a bare argv0 (for example `sudo adevcontainer`) as a relative path. Keep the existing uncommitted argv0 resolution under `install-plugin`. -- **Lifecycle still does not restage:** `up` and other lifecycle commands MUST NOT copy the executable or `config.toml`. -- **Packaging docs use `install-plugin`:** Homebrew `post_install` still restages the same layout. Caveats, tarball, and source install instructions name `adevcontainer install-plugin` instead of `doctor --repair`. Caveats still tell the user to reinstall this formula after upgrading `container`. -- **No `design.md`:** Verb split, source resolution, and doctor-vs-restage separation remain understandable in this index and in `tasks.md`; the overflow rule is not met. diff --git a/specs/changes/install-plugin-command/spec.md b/specs/changes/install-plugin-command/spec.md deleted file mode 100644 index aa2cc8c..0000000 --- a/specs/changes/install-plugin-command/spec.md +++ /dev/null @@ -1,119 +0,0 @@ -# Change Spec: install-plugin-command - -## MODIFIED Requirements - -### Requirement: Explicit plugin restage after Apple upgrade wipe - -Apple installer or Homebrew keg replacement of `libexec` wipes user plugins under `container-plugins/` ([apple/container#1617](https://github.com/apple/container/issues/1617)). PATH `adevcontainer` survives. `container dev` then fails until the plugin layout is restaged. - -The product MUST NOT restage the plugin layout on `up` or any other lifecycle command. `doctor` MUST detect a missing plugin layout and MUST print exact remediation naming PATH `adevcontainer install-plugin`, including `sudo` when the destination requires elevation. It MUST NOT suggest `container dev install-plugin` for a missing layout. - -The product MUST provide `install-plugin` as a command. Explicit `install-plugin` MUST copy the running Mach-O and `config.toml` into the plugin layout, using elevated privileges when the destination requires them. `install-plugin` MUST NOT require `container system start` to succeed. `install-plugin` MUST resolve the source Mach-O from the running executable; it MUST NOT treat a bare argv0 as the source path. - -`--repair` MUST NOT restage. Any `--repair` MUST fail as a usage error whose hint names `install-plugin`. - -Homebrew formula `post_install` MUST restage that same layout. Homebrew caveats MUST tell the user to reinstall this formula after upgrading `container`. Homebrew, tarball, and source install docs MUST name `adevcontainer install-plugin` for restage. The product MUST NOT wait on unmerged home-dir plugin discovery. - -#### Scenario: PATH doctor still runs when plugin layout is missing - -- Given PATH `adevcontainer` is installed and the plugin layout is missing -- When the user runs `adevcontainer doctor` -- Then doctor runs as the PATH binary and reports the missing plugin layout - -#### Scenario: Lifecycle commands do not restage a missing plugin - -- Given the plugin layout is missing -- When the user runs `up` or another lifecycle command -- Then the product does not copy the executable or `config.toml` into the plugin layout - -#### Scenario: Doctor reports missing plugin layout with exact remediation - -- Given the plugin layout is missing -- When the user runs `adevcontainer doctor` -- Then doctor exits non-zero and prints exact remediation naming `adevcontainer install-plugin` (with `sudo` when the destination requires elevation), not `container dev install-plugin` - -#### Scenario: install-plugin stages plugin layout - -- Given the running executable and a writable plugin destination derived from Apple `container`’s install-root -- When the user runs `install-plugin` -- Then the product writes this executable to `{install-root}/libexec/container-plugins/dev/bin/dev` and writes `config.toml` with `abstract` and without `[servicesConfig]` - -#### Scenario: install-plugin uses elevated privileges when required - -- Given the plugin destination is not writable by the current user -- When the user runs `install-plugin` -- Then the product copies using elevated privileges or prints exact remediation that uses them naming PATH `adevcontainer install-plugin` - -#### Scenario: install-plugin does not require container system start - -- Given Apple `container` is installed and Apple container services are not running -- When the user runs `adevcontainer install-plugin` -- Then the product restages the plugin layout and MUST NOT fail because `container system start` has not been run - -#### Scenario: install-plugin resolves source from the running executable - -- Given the process is invoked with a bare executable name (for example under `sudo`) -- When the user runs `install-plugin` -- Then the product copies the running Mach-O into the plugin layout and MUST NOT treat the bare name as a relative source path - -#### Scenario: --repair fails as usage and hints install-plugin - -- Given any command, including `doctor` and `install-plugin` -- When the user passes `--repair` -- Then the command fails as a usage error and the hint names `install-plugin` - -#### Scenario: Homebrew post_install restages the plugin - -- Given the Homebrew formula is installed -- When `post_install` runs -- Then it restages the plugin layout under Apple `container`’s install-root - -#### Scenario: Homebrew caveats tell the user to reinstall after upgrading container - -- Given the Homebrew formula is installed -- When caveats are shown -- Then they tell the user to reinstall this formula after upgrading `container` - -#### Scenario: Tarball install restages the plugin layout - -- Given the user installs from the release tarball onto PATH -- When install instructions are followed -- Then the plugin layout is restaged the same way as `install-plugin` - -#### Scenario: Source install restages the plugin layout - -- Given the user builds from source -- When install instructions are followed -- Then the plugin layout is restaged the same way as `install-plugin` - ---- - -### Requirement: Doctor preflight - -`adevcontainer doctor` MUST verify host readiness before users rely on `up`: Apple `container` binary presence (default path `/usr/local/bin/container` or PATH resolution), invokability, and a reported version suitable for machine use. Doctor MUST also verify the Apple CLI plugin layout for `dev` under the install-root parent of Apple `container`’s `bin/`. Doctor MUST emit a clear pass/fail summary. Doctor MUST NOT require a `devcontainer.json`. - -PATH invocation of `adevcontainer doctor` MUST keep the existing missing-binary failure. Success MUST still report Apple `container` binary path and version and MUST require the plugin layout to be present. When Apple container services are not running, doctor MUST surface that `container system start` is required (Apple needs it to list/dispatch plugins). Doctor MUST NOT restage the plugin layout. Doctor MUST NOT accept `--repair`. When the plugin layout is missing, doctor MUST fail and print PATH `adevcontainer install-plugin` remediation as specified in **Explicit plugin restage after Apple upgrade wipe**. - -#### Scenario: Doctor success - -- Given Apple `container` is installed and runnable and the plugin layout is present -- When the user runs `adevcontainer doctor` -- Then the command exits 0 and reports binary path and version - -#### Scenario: Doctor missing binary - -- Given `container` is not on PATH and not at the default path -- When the user runs `adevcontainer doctor` -- Then the command exits non-zero with a structured error explaining the missing runtime - -#### Scenario: Doctor does not require devcontainer.json - -- Given a directory with no `devcontainer.json` -- When the user runs `adevcontainer doctor` -- Then doctor does not fail for missing configuration - -#### Scenario: Doctor surfaces container system start when not running - -- Given Apple `container` is installed and the system status is not running -- When the user runs `adevcontainer doctor` -- Then doctor exits non-zero and tells the user to run `container system start` diff --git a/specs/changes/install-plugin-command/tasks.md b/specs/changes/install-plugin-command/tasks.md deleted file mode 100644 index f932d47..0000000 --- a/specs/changes/install-plugin-command/tasks.md +++ /dev/null @@ -1,90 +0,0 @@ -# Tasks: install-plugin-command - -Spec ref: `specs/changes/install-plugin-command/`. Execute test-first: write the failing tests and confirm the expected failures before changing production code. Replace `doctor --repair` with `install-plugin`. Keep running-executable source resolution under `install-plugin`. Do not auto-repair on lifecycle commands. Do not edit the wiki. Do not edit archived specs. - -## 1. Failing tests for install-plugin restage and doctor checks - -- [x] 1.1 Retarget plugin tests so missing-plugin doctor prints PATH `adevcontainer install-plugin` (`sudo` when elevation is required) and never `container dev install-plugin`; `install-plugin` stages this Mach-O plus `config.toml` (`abstract`, no `[servicesConfig]`); bare argv0 restages from the running executable (PATH / symlink target as needed); `--repair` fails as usage and hints `install-plugin` on doctor, install-plugin, and other commands; doctor help/usage no longer document `--repair`; `up`/lifecycle still do not restage (path: `Tests/adevcontainerTests/ContainerPluginTests.swift`) -- [x] 1.2 Retarget doctor command tests so `install-plugin` restages while Apple container services are not running and succeeds without requiring `container system start`; doctor still fails for a non-running system and MUST NOT restage [P] (path: `Tests/adevcontainerTests/AllCommandTests.swift`) -- [x] 1.3 Run the suite of record and confirm failures are limited to the missing `install-plugin` verb, remaining `--repair` restage, and stale `doctor --repair` remediation (path: `Tests/adevcontainerTests/main.swift`) - -## Checkpoint - -- [x] verify **PATH doctor still runs when plugin layout is missing** -- [x] verify **Lifecycle commands do not restage a missing plugin** -- [x] verify **Doctor reports missing plugin layout with exact remediation** -- [x] verify **install-plugin stages plugin layout** -- [x] verify **install-plugin uses elevated privileges when required** -- [x] verify **install-plugin does not require container system start** -- [x] verify **install-plugin resolves source from the running executable** -- [x] verify **--repair fails as usage and hints install-plugin** -- [x] verify **Doctor success** -- [x] verify **Doctor missing binary** -- [x] verify **Doctor does not require devcontainer.json** -- [x] verify **Doctor surfaces container system start when not running** - -## 2. Command surface: reject `--repair`, document `install-plugin` - -- [x] 2.1 Reject `--repair` on every command as usage with a hint naming `install-plugin`; add `install-plugin` to usage and per-command help; doctor help/usage MUST NOT document `--repair`; missing-plugin help MUST name PATH `adevcontainer install-plugin` and MUST NOT name `container dev install-plugin` (path: `Sources/ADevContainerLib/Support/CommandSurface.swift`) - -## Checkpoint - -- [x] verify **--repair fails as usage and hints install-plugin** -- [x] verify **Doctor reports missing plugin layout with exact remediation** - -## 3. install-plugin restage and doctor checks only - -- [x] 3.1 Implement `install-plugin`: copy the running Mach-O (not bare argv0) and write `config.toml`; use elevated privileges or print PATH `adevcontainer install-plugin` with `sudo` when required; MUST NOT require `container system start` to succeed (path: `Sources/ADevContainerLib/Commands/InstallPluginCommand.swift`) -- [x] 3.2 Stop restaging from `doctor`; keep binary, version, system-running, and plugin-present checks; missing-plugin error MUST name PATH `adevcontainer install-plugin` (`sudo` when elevation is required) and MUST NOT name `container dev install-plugin` [P] (path: `Sources/ADevContainerLib/Commands/DoctorCommand.swift`) -- [x] 3.3 Dispatch `install-plugin` alongside `doctor` / `up` / …; `doctor` MUST NOT accept `--repair`; unknown-command hint MUST include `install-plugin` (path: `Sources/adevcontainer/AdevcontainerMain.swift`) - -## Checkpoint - -- [x] verify **install-plugin stages plugin layout** -- [x] verify **install-plugin uses elevated privileges when required** -- [x] verify **install-plugin does not require container system start** -- [x] verify **install-plugin resolves source from the running executable** -- [x] verify **PATH doctor still runs when plugin layout is missing** -- [x] verify **Doctor reports missing plugin layout with exact remediation** -- [x] verify **Lifecycle commands do not restage a missing plugin** -- [x] verify **--repair fails as usage and hints install-plugin** -- [x] verify **Doctor success** -- [x] verify **Doctor missing binary** -- [x] verify **Doctor does not require devcontainer.json** -- [x] verify **Doctor surfaces container system start when not running** - -## 4. Packaging docs use `install-plugin` - -- [x] 4.1 Homebrew caveats: keep reinstall-after-upgrade; replace `doctor --repair` with `adevcontainer install-plugin` (`sudo` when `/usr/local/libexec` is not writable); keep `post_install` restaging the same layout (path: `scripts/render-homebrew-formula.sh`) -- [x] 4.2 Tarball install: restage with PATH `adevcontainer install-plugin` after placing PATH `adevcontainer`; MUST NOT tell the user to run `doctor --repair` or `container dev install-plugin` when the plugin is missing [P] (path: `README.md`) -- [x] 4.3 Source install: restage with PATH `adevcontainer install-plugin` (`sudo` when the destination requires it) [P] (path: `CONTRIBUTING.md`) - -## Checkpoint - -- [x] verify **Homebrew post_install restages the plugin** -- [x] verify **Homebrew caveats tell the user to reinstall after upgrading container** -- [x] verify **Tarball install restages the plugin layout** -- [x] verify **Source install restages the plugin layout** - -## 5. Suite of record - -- [x] 5.1 Run `swift run adevcontainerTests` and confirm every scenario above maps to a passing test or packaging checkpoint (path: `Tests/adevcontainerTests/main.swift`) - -## Checkpoint - -- [x] verify **PATH doctor still runs when plugin layout is missing** -- [x] verify **Lifecycle commands do not restage a missing plugin** -- [x] verify **Doctor reports missing plugin layout with exact remediation** -- [x] verify **install-plugin stages plugin layout** -- [x] verify **install-plugin uses elevated privileges when required** -- [x] verify **install-plugin does not require container system start** -- [x] verify **install-plugin resolves source from the running executable** -- [x] verify **--repair fails as usage and hints install-plugin** -- [x] verify **Homebrew post_install restages the plugin** -- [x] verify **Homebrew caveats tell the user to reinstall after upgrading container** -- [x] verify **Tarball install restages the plugin layout** -- [x] verify **Source install restages the plugin layout** -- [x] verify **Doctor success** -- [x] verify **Doctor missing binary** -- [x] verify **Doctor does not require devcontainer.json** -- [x] verify **Doctor surfaces container system start when not running** diff --git a/specs/core.md b/specs/core.md index dfc0a1f..7405b70 100644 --- a/specs/core.md +++ b/specs/core.md @@ -489,24 +489,28 @@ The existing known optional families remain warn-and-ignore in default mode: doc `adevcontainer doctor` MUST verify host readiness before users rely on `up`: Apple `container` binary presence (default path `/usr/local/bin/container` or PATH resolution), invokability, and a reported version suitable for machine use. Doctor MUST also verify the Apple CLI plugin layout for `dev` under the install-root parent of Apple `container`’s `bin/`. Doctor MUST emit a clear pass/fail summary. Doctor MUST NOT require a `devcontainer.json`. -PATH invocation of `adevcontainer doctor` MUST keep the existing missing-binary failure. Success MUST still report Apple `container` binary path and version and MUST require the plugin layout to be present. When Apple container services are not running, doctor MUST surface that `container system start` is required (Apple needs it to list/dispatch plugins). Doctor MUST accept `--repair` to restage the plugin layout as specified in [plugin.md](plugin.md) **Explicit plugin restage after Apple upgrade wipe**. +PATH invocation of `adevcontainer doctor` MUST keep the existing missing-binary failure. Success MUST still report Apple `container` binary path and version and MUST require the plugin layout to be present, including when `bin/dev` is a symlink. When Apple container services are not running, doctor MUST surface that `container system start` is required (Apple needs it to list/dispatch plugins). Doctor MUST NOT restage the plugin layout. Doctor MUST NOT accept `--repair`. When the plugin layout is missing, doctor MUST fail and print PATH `adevcontainer plugin --install` remediation as specified in **Explicit plugin restage after Apple upgrade wipe**. #### Scenario: Doctor success + - Given Apple `container` is installed and runnable and the plugin layout is present - When the user runs `adevcontainer doctor` - Then the command exits 0 and reports binary path and version #### Scenario: Doctor missing binary + - Given `container` is not on PATH and not at the default path - When the user runs `adevcontainer doctor` - Then the command exits non-zero with a structured error explaining the missing runtime #### Scenario: Doctor does not require devcontainer.json + - Given a directory with no `devcontainer.json` - When the user runs `adevcontainer doctor` - Then doctor does not fail for missing configuration #### Scenario: Doctor surfaces container system start when not running + - Given Apple `container` is installed and the system status is not running - When the user runs `adevcontainer doctor` - Then doctor exits non-zero and tells the user to run `container system start` diff --git a/specs/plugin.md b/specs/plugin.md index a32b289..566f1c5 100644 --- a/specs/plugin.md +++ b/specs/plugin.md @@ -8,36 +8,41 @@ Apple `container` CLI plugin surface: dual install of the same Mach-O as PATH `a ### Requirement: Dual install as Apple CLI plugin `dev` -The same Mach-O MUST be installed as PATH `adevcontainer` and as an Apple `container` CLI plugin invoked as `container dev `. The plugin name, directory, and binary MUST be `dev`. +The same Mach-O MUST be reachable as PATH `adevcontainer` and as an Apple `container` CLI plugin invoked as `container dev `. The plugin name, directory, and binary MUST be `dev`. The plugin binary MUST be an absolute symlink to the installed executable, not a second copy of the Mach-O. On Unix, install-root MUST be the parent of Apple `container`’s `bin/` directory (typically `/usr/local`). The staged plugin layout MUST be: - `{install-root}/libexec/container-plugins/dev/config.toml` - `{install-root}/libexec/container-plugins/dev/bin/dev` -`config.toml` MUST include `abstract` and MUST omit `[servicesConfig]` so Apple treats it as a CLI plugin. Apple discovers plugins by that directory layout, not PATH. When Apple dispatches the plugin, process argv MUST begin with `dev` followed by the product subcommand. The product MUST NOT ship under Apple’s bundled `libexec/container/plugins/`. The SPM executable product name MUST remain `adevcontainer`; dual install MUST be a second copy of that Mach-O, not a second package product. +`config.toml` MUST be a regular file, MUST include `abstract`, and MUST omit `[servicesConfig]` so Apple treats it as a CLI plugin. Apple discovers plugins by that directory layout, not PATH. When Apple dispatches the plugin, process argv MUST begin with `dev` followed by the product subcommand. The product MUST NOT ship under Apple’s bundled `libexec/container/plugins/`. The SPM executable product name MUST remain `adevcontainer`. Dual install MUST be that absolute symlink, not a second package product and not a second copy of the Mach-O. The Homebrew formula name MUST remain `adevcontainer`. #### Scenario: PATH binary remains adevcontainer + - Given a successful install of this product - When the user runs the PATH binary - Then the executable name is `adevcontainer` #### Scenario: Plugin layout uses name dev under container install-root + - Given Apple `container` is installed at `{install-root}/bin/container` - When the plugin is staged - Then `{install-root}/libexec/container-plugins/dev/config.toml` and `{install-root}/libexec/container-plugins/dev/bin/dev` exist #### Scenario: config.toml is a CLI plugin + - Given the plugin is staged - When `config.toml` is read -- Then it includes `abstract` and omits `[servicesConfig]` +- Then it is a regular file, includes `abstract`, and omits `[servicesConfig]` -#### Scenario: Same Mach-O serves both surfaces -- Given PATH `adevcontainer` and the plugin binary are both installed -- When the two files are compared -- Then the plugin binary is a copy of the same Mach-O as the PATH executable +#### Scenario: Plugin binary is an absolute symlink to the installed executable + +- Given PATH `adevcontainer` is installed and the plugin is staged +- When the plugin binary path is inspected +- Then `{install-root}/libexec/container-plugins/dev/bin/dev` is an absolute symlink to the installed executable, not a second copy of the Mach-O #### Scenario: Plugin invocation runs the subcommand + - Given the plugin is staged and Apple can dispatch plugins - When the user runs `container dev` with a product subcommand - Then that subcommand runs @@ -83,60 +88,211 @@ The product MUST keep shelling out to Apple `container` as a subprocess. It MUST ### Requirement: Explicit plugin restage after Apple upgrade wipe -Apple installer or Homebrew keg replacement of `libexec` wipes user plugins under `container-plugins/` ([apple/container#1617](https://github.com/apple/container/issues/1617)). PATH `adevcontainer` survives. `container dev` then fails until the plugin layout is restaged. +Apple installer or Homebrew keg replacement of `libexec` wipes user plugins under `container-plugins/` ([apple/container#1617](https://github.com/apple/container/issues/1617)). PATH `adevcontainer` survives. `container dev` then fails until the plugin layout is installed again. + +The product MUST NOT restage the plugin layout on `up` or any other lifecycle command. `doctor` MUST detect a missing plugin layout and MUST print exact remediation naming PATH `adevcontainer plugin --install`, including `sudo` when the destination requires elevation. It MUST NOT suggest `container dev plugin --install` for a missing layout. A present layout is `config.toml` plus `bin/dev`, including when `bin/dev` is a symlink. Doctor MUST NOT require `bin/dev` to be a regular file and MUST NOT restage. + +The product MUST provide `plugin` with `--install` and `--uninstall`. It MUST NOT provide `install-plugin` as a command or as an alias. `plugin` with neither flag, or with both flags, MUST fail as a usage error that names `--install` and `--uninstall` and states that exactly one is required. Usage and `plugin` help MUST document both flags and MUST NOT document `install-plugin` or `--repair`. + +`plugin --install` and `plugin --uninstall` MUST derive install-root from the parent of Apple `container`’s `bin/` directory. If that binary cannot be resolved, they MUST fail with the existing missing-runtime error. They MUST NOT require `container system start` to succeed. + +`plugin --install` MUST create `{install-root}/libexec/container-plugins/dev/bin/dev` as an absolute symlink and MUST write `config.toml` as a regular file with `abstract` and without `[servicesConfig]`. It MUST remove `bin/dev` first when that path is a regular file or a symlink whose target string is not the required absolute path, including a relative symlink that would resolve to the same file, and MUST NOT delete the old symlink’s target. If `bin/dev` is already an absolute symlink whose target string equals the required path, `--install` MUST succeed without replacing that symlink. It MUST still write `config.toml` as a regular file. If an existing `config.toml` is a symlink, `--install` MUST replace that path with a regular file and MUST NOT write through the symlink. -The product MUST NOT restage the plugin layout on `up` or any other lifecycle command. `doctor` MUST detect a missing plugin layout and MUST print exact remediation naming PATH `adevcontainer doctor --repair`, including elevated privileges when the destination requires them. It MUST NOT suggest `container dev doctor --repair` for a missing layout. +The required symlink target depends on how this product is installed. When the running executable is Homebrew formula `adevcontainer` — including when its path is a versioned Cellar path, and including when the process is invoked under `sudo` with a bare argv0 — the target MUST be the absolute path `$(brew --prefix)/opt/adevcontainer/bin/adevcontainer`. It MUST NOT be a versioned Cellar path and MUST NOT be the result of resolving symlinks in the keg. If that opt path cannot be formed, `--install` MUST fail and MUST NOT fall back to a Cellar path or a realpath-resolved path. When the running executable is not that Homebrew formula, the target MUST be the running executable’s path with no realpath resolution. A bare argv0 MUST NOT be treated as a relative source path. -Explicit `doctor --repair` MUST copy this executable and `config.toml` into the plugin layout, using elevated privileges when the destination requires them. `--repair` MUST be valid only on `doctor`. +When argv0 or the identified executable path is the plugin binary `{install-root}/libexec/container-plugins/dev/bin/dev` — an absolute path or the process name `dev` — `--install` MUST NOT create a symlink whose target is that plugin path. It MUST resolve the installed executable instead. If `bin/dev` is already an absolute symlink to a different path, apply the Homebrew vs non-Homebrew target rules to that link text: do not realpath a Cellar keg; a Cellar link becomes the opt path; an already-correct opt link is kept. If `bin/dev` is a regular file, a relative symlink, or a self-symlink, locate PATH `adevcontainer` or the Homebrew opt binary if present and link to that. If no installed executable other than `bin/dev` can be identified, `--install` MUST fail and MUST NOT create a self-symlink. PATH `adevcontainer plugin --install` is unchanged. Doctor missing-plugin remediation MUST still name PATH `adevcontainer plugin --install` and MUST NOT name `container dev plugin --install`. -Homebrew formula `post_install` MUST restage that same layout. Homebrew caveats MUST tell the user to reinstall this formula after upgrading `container`. Tarball install and source install MUST restage the same layout. The product MUST NOT wait on unmerged home-dir plugin discovery. +`--install` MUST NOT copy the Mach-O into the plugin layout and MUST NOT change the mode or contents of the symlink target. + +When the destination is not writable, `--install` MUST re-exec with elevated privileges to complete the install, or MUST print exact remediation naming PATH `adevcontainer plugin --install` including `sudo`. That remediation MUST NOT name `container dev plugin --install`. + +`--repair` MUST NOT restage. Any `--repair` MUST fail as a usage error whose hint names `plugin --install` and MUST NOT name `install-plugin`. + +Homebrew formula `post_install` MUST NOT write Apple’s install-root plugin layout (`/usr/local/libexec/...` or any other Apple `container` install-root `container-plugins` path) and MUST NOT copy or chmod the formula binary into that layout. Homebrew caveats MUST tell the user to run `sudo adevcontainer plugin --install` once after install, and again only after upgrading Apple `container`. Caveats MUST NOT tell the user to `brew reinstall` or `brew upgrade` this formula in order to restage the plugin. When the plugin symlink already targets `$(brew --prefix)/opt/adevcontainer/bin/adevcontainer`, `brew upgrade adevcontainer` MUST NOT require `plugin --install` again. README and source-install docs MUST name `adevcontainer plugin --install` (with `sudo` when the destination requires elevation) and MUST NOT name `install-plugin`. The product MUST NOT wait on unmerged home-dir plugin discovery. #### Scenario: PATH doctor still runs when plugin layout is missing + - Given PATH `adevcontainer` is installed and the plugin layout is missing - When the user runs `adevcontainer doctor` - Then doctor runs as the PATH binary and reports the missing plugin layout #### Scenario: Lifecycle commands do not restage a missing plugin + - Given the plugin layout is missing - When the user runs `up` or another lifecycle command -- Then the product does not copy the executable or `config.toml` into the plugin layout +- Then the product does not create the plugin symlink or write `config.toml` into the plugin layout #### Scenario: Doctor reports missing plugin layout with exact remediation + - Given the plugin layout is missing -- When the user runs `adevcontainer doctor` without `--repair` -- Then doctor exits non-zero and prints exact remediation naming `adevcontainer doctor --repair` (with elevated privileges when required), not `container dev doctor --repair` +- When the user runs `adevcontainer doctor` +- Then doctor exits non-zero and prints exact remediation naming PATH `adevcontainer plugin --install` (with `sudo` when the destination requires elevation), not `container dev plugin --install` + +#### Scenario: plugin --install creates an absolute symlink and writes config.toml + +- Given the running executable and a writable plugin destination derived from Apple `container`’s install-root +- When the user runs `plugin --install` +- Then `bin/dev` is an absolute symlink to the required target, and `config.toml` is a regular file with `abstract` and without `[servicesConfig]` + +#### Scenario: plugin --install replaces a copied plugin binary + +- Given `bin/dev` is a regular file +- When the user runs `plugin --install` +- Then the product removes that regular file and creates an absolute symlink to the required target + +#### Scenario: plugin --install replaces a symlink to the wrong target + +- Given `bin/dev` is a relative symlink, or an absolute symlink whose target string is not the required path +- When the user runs `plugin --install` +- Then the product removes that symlink without deleting its target and creates an absolute symlink to the required target + +#### Scenario: plugin --install keeps an already-correct symlink + +- Given `bin/dev` is already an absolute symlink whose target string equals the required path +- When the user runs `plugin --install` +- Then the command succeeds without replacing that symlink, and `config.toml` is a regular file with `abstract` and without `[servicesConfig]` + +#### Scenario: plugin --install writes config.toml as a regular file + +- Given an existing `config.toml` is a symlink +- When the user runs `plugin --install` +- Then `config.toml` is replaced with a regular file that includes `abstract` and omits `[servicesConfig]`, and the previous symlink target is unchanged + +#### Scenario: Homebrew install links the opt path + +- Given the running executable is Homebrew formula `adevcontainer`, including a versioned Cellar path and including invocation under `sudo` with a bare argv0 +- When the user runs `plugin --install` +- Then `bin/dev` is an absolute symlink to `$(brew --prefix)/opt/adevcontainer/bin/adevcontainer`, not a versioned Cellar path and not the result of resolving symlinks in the keg +- And if that opt path cannot be formed, the command fails and does not create a Cellar-path or realpath-resolved symlink + +#### Scenario: Non-Homebrew install links the running executable path + +- Given the running executable is not Homebrew formula `adevcontainer` +- When the user runs `plugin --install` +- Then `bin/dev` is an absolute symlink to the running executable’s path, and that path is not realpath-resolved + +#### Scenario: plugin --install does not symlink bin/dev to itself + +- Given argv0 is the plugin binary `{install-root}/libexec/container-plugins/dev/bin/dev` or the process name `dev` +- When the user runs `plugin --install` +- Then `bin/dev` is not a symlink whose target is that plugin path +- And if `bin/dev` was already an absolute symlink to a different path, the target follows the Homebrew or non-Homebrew rule for that link text (a Cellar link becomes the opt path; an already-correct opt link is kept; the keg is not realpath-resolved) +- And if `bin/dev` is a regular file, a relative symlink, or a self-symlink, the target is PATH `adevcontainer` or the Homebrew opt binary when one of those is present +- And if no installed executable other than `bin/dev` can be identified, the command fails and does not create a self-symlink + +#### Scenario: plugin --install does not treat a bare argv0 as a relative source path + +- Given a non-Homebrew install invoked with a bare executable name (for example under `sudo`) +- When the user runs `plugin --install` +- Then the symlink target is the running executable’s absolute path and MUST NOT be the bare name as a relative path -#### Scenario: doctor --repair stages plugin layout -- Given the current executable and a writable plugin destination derived from Apple `container`’s install-root -- When the user runs `doctor --repair` -- Then the product writes this executable to `{install-root}/libexec/container-plugins/dev/bin/dev` and writes `config.toml` with `abstract` and without `[servicesConfig]` +#### Scenario: plugin --install does not mutate the symlink target + +- Given a symlink target that is the installed executable, including a Homebrew keg binary +- When the user runs `plugin --install` +- Then the product does not copy the Mach-O into the plugin layout and does not change the mode or contents of that target + +#### Scenario: plugin --install uses elevated privileges when required -#### Scenario: doctor --repair uses elevated privileges when required - Given the plugin destination is not writable by the current user -- When the user runs `doctor --repair` -- Then the product copies using elevated privileges or prints exact remediation that uses them +- When the user runs `plugin --install` +- Then the product re-execs with elevated privileges or prints exact remediation naming PATH `adevcontainer plugin --install` with `sudo`, and MUST NOT name `container dev plugin --install` + +#### Scenario: plugin --install does not require container system start + +- Given Apple `container` is installed and Apple container services are not running +- When the user runs `adevcontainer plugin --install` +- Then the product installs the plugin layout and MUST NOT fail because `container system start` has not been run + +#### Scenario: plugin with neither flag is a usage error + +- Given the user invokes `plugin` with neither `--install` nor `--uninstall` +- When the command parses +- Then it fails as a usage error that names both flags and states that exactly one is required + +#### Scenario: plugin with both flags is a usage error + +- Given the user invokes `plugin` with both `--install` and `--uninstall` +- When the command parses +- Then it fails as a usage error that names both flags and states that exactly one is required + +#### Scenario: install-plugin is not a command -#### Scenario: doctor --repair is only valid on doctor -- Given any command other than `doctor` +- Given the product command surface +- When the user runs `install-plugin` +- Then the command fails as an unknown command, is not an alias for `plugin --install`, and the hint names `plugin` and does not list `install-plugin` as a valid command + +#### Scenario: --repair fails as usage and hints plugin --install + +- Given any command, including `doctor` and `plugin` - When the user passes `--repair` -- Then the command fails as a usage error +- Then the command fails as a usage error and the hint names `plugin --install` and does not name `install-plugin` -#### Scenario: Homebrew post_install restages the plugin -- Given the Homebrew formula is installed -- When `post_install` runs -- Then it restages the plugin layout under Apple `container`’s install-root +#### Scenario: Homebrew post_install does not write Apple's install-root + +- Given the Homebrew formula rendered by `scripts/render-homebrew-formula.sh` +- When `post_install` is inspected +- Then it does not write Apple’s install-root plugin layout and does not copy or chmod the formula binary into that layout + +#### Scenario: Homebrew caveats name plugin --install -#### Scenario: Homebrew caveats tell the user to reinstall after upgrading container - Given the Homebrew formula is installed - When caveats are shown -- Then they tell the user to reinstall this formula after upgrading `container` +- Then they tell the user to run `sudo adevcontainer plugin --install` once after install, and again only after upgrading Apple `container` +- And they do not tell the user to `brew reinstall` or `brew upgrade` this formula in order to restage the plugin + +#### Scenario: brew upgrade does not require restage when the symlink targets the opt path + +- Given `bin/dev` is an absolute symlink to `$(brew --prefix)/opt/adevcontainer/bin/adevcontainer` +- When the user upgrades formula `adevcontainer` +- Then the symlink remains valid without running `plugin --install` again + +#### Scenario: Tarball install uses plugin --install -#### Scenario: Tarball install restages the plugin layout - Given the user installs from the release tarball onto PATH - When install instructions are followed -- Then the plugin layout is restaged the same way as `doctor --repair` +- Then the plugin layout is installed with PATH `adevcontainer plugin --install` (with `sudo` when the destination requires elevation), not with `install-plugin` and not by copying the Mach-O into the plugin layout + +#### Scenario: Source install uses plugin --install -#### Scenario: Source install restages the plugin layout - Given the user builds from source - When install instructions are followed -- Then the plugin layout is restaged the same way as `doctor --repair` +- Then the plugin layout is installed with PATH `adevcontainer plugin --install` (with `sudo` when the destination requires elevation), not with `install-plugin` + +--- + +### Requirement: Plugin uninstall removes layout entries only + +`plugin --uninstall` MUST remove `{install-root}/libexec/container-plugins/dev/bin/dev` when that path is a symlink or a regular file, and MUST remove `{install-root}/libexec/container-plugins/dev/config.toml`. It MUST NOT delete the symlink target of `bin/dev`, MUST NOT delete the Homebrew keg or the opt-path executable, and MUST NOT remove any other path, including the plugin directory itself. Removing `config.toml` MUST unlink that path and MUST NOT delete a symlink target if `config.toml` is a symlink. When `bin/dev` and `config.toml` are already absent, uninstall MUST succeed. Uninstall MUST NOT require `container system start`. + +When the destination is not writable, uninstall MUST re-exec with elevated privileges to complete the removal, or MUST print exact remediation naming PATH `adevcontainer plugin --uninstall` including `sudo`. That remediation MUST NOT name `container dev plugin --uninstall`. + +#### Scenario: plugin --uninstall removes the plugin binary and config.toml only + +- Given `bin/dev` is a symlink to an existing executable and `config.toml` exists, and the plugin directory contains another file +- When the user runs `plugin --uninstall` +- Then `bin/dev` and `config.toml` are gone, the symlink target still exists, and the other file remains + +#### Scenario: plugin --uninstall removes a leftover copy without deleting the keg + +- Given `bin/dev` is a regular file left by an older copy install, and the Homebrew keg and opt-path executable exist elsewhere +- When the user runs `plugin --uninstall` +- Then the leftover `bin/dev` and `config.toml` are removed and the keg and opt-path executable are unchanged + +#### Scenario: plugin --uninstall succeeds when the layout is missing + +- Given Apple `container` is installed and the plugin layout is already absent +- When the user runs `plugin --uninstall` +- Then the command succeeds and does not create the layout + +#### Scenario: plugin --uninstall uses elevated privileges when required + +- Given the plugin destination is not writable by the current user +- When the user runs `plugin --uninstall` +- Then the product re-execs with elevated privileges or prints exact remediation naming PATH `adevcontainer plugin --uninstall` with `sudo`, and MUST NOT name `container dev plugin --uninstall` + +#### Scenario: plugin --uninstall does not require container system start + +- Given Apple `container` is installed and Apple container services are not running +- When the user runs `adevcontainer plugin --uninstall` +- Then uninstall completes and MUST NOT fail because `container system start` has not been run diff --git a/wiki/architecture.md b/wiki/architecture.md index 28e8ea8..d21844c 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -24,7 +24,7 @@ Help/hints follow the invocation (`container dev` vs `adevcontainer`). Managed l Runtime still shells to Apple `container` (prefer `/usr/local/bin/container`); never the plugin binary. -`install-plugin` is removed. `plugin --install` creates that `bin/dev` symlink; Homebrew target is `$(brew --prefix)/opt/adevcontainer/bin/adevcontainer`, so `brew upgrade adevcontainer` updates the plugin without writing `/usr/local`. `plugin --uninstall` removes only `bin/dev` and `config.toml`. Brew is not sudo; formula `post_install` does not write Apple’s install-root. Apple upgrades wipe the plugin dir ([apple/container#1617](https://github.com/apple/container/issues/1617)); PATH `adevcontainer` survives. Run `sudo adevcontainer plugin --install` once, and again only after that wipe — never `container dev plugin --install` when the layout is missing. Doctor is checks-only and hints that PATH command. Lifecycle does not restage. Contract: [`specs/plugin.md`](../specs/plugin.md) and [`specs/changes/plugin-symlink-install/`](../specs/changes/plugin-symlink-install/). Where the still-active [`install-plugin-command`](../specs/changes/install-plugin-command/) delta says copy or `install-plugin`, the symlink change is the contract. Doctor/identity: [`specs/core.md`](../specs/core.md). +`install-plugin` is removed. `plugin --install` creates that `bin/dev` symlink; Homebrew target is `$(brew --prefix)/opt/adevcontainer/bin/adevcontainer`, so `brew upgrade adevcontainer` updates the plugin without writing `/usr/local`. `plugin --uninstall` removes only `bin/dev` and `config.toml`. Brew is not sudo; formula `post_install` does not write Apple’s install-root. Apple upgrades wipe the plugin dir ([apple/container#1617](https://github.com/apple/container/issues/1617)); PATH `adevcontainer` survives. Run `sudo adevcontainer plugin --install` once, and again only after that wipe — never `container dev plugin --install` when the layout is missing. Doctor is checks-only and hints that PATH command. Lifecycle does not restage. Contract: [`specs/plugin.md`](../specs/plugin.md). Archive: [`20260925-plugin-symlink-install`](../specs/changes/archive/20260925-plugin-symlink-install/), [`20260925-install-plugin-command`](../specs/changes/archive/20260925-install-plugin-command/). Doctor/identity: [`specs/core.md`](../specs/core.md). ## Package layout diff --git a/wiki/conventions/release-distribution.md b/wiki/conventions/release-distribution.md index 46d6bf0..1038cf5 100644 --- a/wiki/conventions/release-distribution.md +++ b/wiki/conventions/release-distribution.md @@ -58,7 +58,7 @@ Do not paste workflow YAML into the wiki — edit the files under `.github/workf ## Install UX (priority order) -1. **Homebrew (primary)** — public tap [wcgomes/homebrew-tap](https://github.com/wcgomes/homebrew-tap); sole formula SoT `Formula/adevcontainer.rb`. Install: `brew tap wcgomes/tap && brew install adevcontainer`. Non-prerelease release auto-bumps the tap (`HOMEBREW_TAP_TOKEN` + `scripts/render-homebrew-formula.sh`). No in-repo formula mirror. Formula `post_install` does not write Apple’s install-root (brew is not sudo). Caveats: Apple `container` is a separate runtime install; run `sudo adevcontainer plugin --install` once after install. That makes `{install-root}/libexec/container-plugins/dev/bin/dev` an absolute symlink to `$(brew --prefix)/opt/adevcontainer/bin/adevcontainer`, so `brew upgrade adevcontainer` updates the plugin without writing `/usr/local`. Run it again only after an Apple `container` upgrade wipes the plugin dir ([apple/container#1617](https://github.com/apple/container/issues/1617)). `plugin --uninstall` removes only `bin/dev` and `config.toml`. Contract: [`specs/plugin.md`](../../specs/plugin.md); [`specs/changes/plugin-symlink-install/`](../../specs/changes/plugin-symlink-install/). +1. **Homebrew (primary)** — public tap [wcgomes/homebrew-tap](https://github.com/wcgomes/homebrew-tap); sole formula SoT `Formula/adevcontainer.rb`. Install: `brew tap wcgomes/tap && brew install adevcontainer`. Non-prerelease release auto-bumps the tap (`HOMEBREW_TAP_TOKEN` + `scripts/render-homebrew-formula.sh`). No in-repo formula mirror. Formula `post_install` does not write Apple’s install-root (brew is not sudo). Caveats: Apple `container` is a separate runtime install; run `sudo adevcontainer plugin --install` once after install. That makes `{install-root}/libexec/container-plugins/dev/bin/dev` an absolute symlink to `$(brew --prefix)/opt/adevcontainer/bin/adevcontainer`, so `brew upgrade adevcontainer` updates the plugin without writing `/usr/local`. Run it again only after an Apple `container` upgrade wipes the plugin dir ([apple/container#1617](https://github.com/apple/container/issues/1617)). `plugin --uninstall` removes only `bin/dev` and `config.toml`. Contract: [`specs/plugin.md`](../../specs/plugin.md); archive [`20260925-plugin-symlink-install`](../../specs/changes/archive/20260925-plugin-symlink-install/). 2. **GitHub Release curl/tar** — download tarball + verify sha256; fallback for non-brew users. 3. **Source build** — documented in README (`swift build -c release` on macOS 26+ Apple Silicon). From 5d08c96f5c2917c49b3e27f35982b1bcd6124372 Mon Sep 17 00:00:00 2001 From: Wyller Gomes Date: Fri, 25 Sep 2026 05:33:29 +0000 Subject: [PATCH 3/4] docs: update test count badge Registered MiniTest cases are 1147. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 291ff11..bee424e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Apple Dev Container CLI (adevcontainer) [![CI](https://github.com/wcgomes/apple-devcontainers/actions/workflows/ci.yml/badge.svg)](https://github.com/wcgomes/apple-devcontainers/actions/workflows/ci.yml) -[![tests](https://img.shields.io/badge/tests-1105%2B-brightgreen)](https://github.com/wcgomes/apple-devcontainers) +[![tests](https://img.shields.io/badge/tests-1147%2B-brightgreen)](https://github.com/wcgomes/apple-devcontainers) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) Native Swift CLI that reads `devcontainer.json` and runs development environments on Apple [`container`](https://github.com/apple/container). From 22661b3d623ca406575c00abb60c2bfed1f313e2 Mon Sep 17 00:00:00 2001 From: Wyller Gomes Date: Fri, 25 Sep 2026 05:33:34 +0000 Subject: [PATCH 4/4] chore: install OpenCode from the devcontainer script Drop the opencode OCI feature and install OpenCode v2 from install-tools.sh. Wiki matches that layout. --- .devcontainer/devcontainer.json | 1 - .devcontainer/install-tools.sh | 6 ++++++ wiki/architecture.md | 2 +- wiki/conventions/workspace-devcontainer.md | 12 ++++++++---- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e06770a..56f7a40 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -17,7 +17,6 @@ "ghcr.io/devcontainers/features/node:2.1.0": { "version": "lts" }, - "ghcr.io/wcgomes/devcontainer-features/opencode:0": {}, "ghcr.io/wcgomes/devcontainer-features/agents-workspace:0": { "divisions": "engineering,testing,security" } diff --git a/.devcontainer/install-tools.sh b/.devcontainer/install-tools.sh index c6be1f9..f4e9628 100755 --- a/.devcontainer/install-tools.sh +++ b/.devcontainer/install-tools.sh @@ -12,6 +12,11 @@ require_npm() { exit 1 } +install_opencode() { + echo "Installing OpenCode V2..." + curl -fsSL https://opencode.ai/v2/install | bash +} + install_codegraph() { require_npm @@ -44,4 +49,5 @@ install_codegraph() { codegraph init } +install_opencode install_codegraph diff --git a/wiki/architecture.md b/wiki/architecture.md index d21844c..1c2778d 100644 --- a/wiki/architecture.md +++ b/wiki/architecture.md @@ -228,7 +228,7 @@ Not full Dev Containers up/rebuild or IDE-owned customizations parity; volume-mo ## Reference config -- **Workspace self-devcontainer:** `.devcontainer/devcontainer.json` — `swift:6.3.3-noble` plus OCI Features (`opencode`, `agents-workspace`) for Linux Swift tooling + product fixture; not full macOS product build/test. Detail: [workspace-devcontainer.md](conventions/workspace-devcontainer.md). +- **Workspace self-devcontainer:** `.devcontainer/devcontainer.json` — `swift:6.3.3-noble` plus OCI Features (`common-utils`, `git`, `node`, `agents-workspace`) for Linux Swift tooling + product fixture; not full macOS product build/test. OpenCode is installed by `postCreate` `.devcontainer/install-tools.sh`, not an OCI Feature. Detail: [workspace-devcontainer.md](conventions/workspace-devcontainer.md). - **Team sample (warn-skip surface):** `reference/devcontainer.json` — features (incl. docker-ood), privileged+tun `runArgs`, mounts, `postCreateCommand`, `forwardPorts`, VS Code customizations. Docker-oriented bits warn-skip; Compose/unknown still fail-closed; see [0003](decisions/0003-warn-skip-apple-incompatibles.md) and [gaps](domain/devcontainer-apple-gaps.md). - **Language / multi-feature sample:** `references/multiplatform` — `base:ubuntu` + OCI Features `dotnet:2` + `node:1` (install-time feature `containerEnv` path; no privileged/DinD surface). - **Dockerfile sample:** `references/dockerfile/` — nested `build` (no top-level `image`) plus sibling `Dockerfile`. diff --git a/wiki/conventions/workspace-devcontainer.md b/wiki/conventions/workspace-devcontainer.md index 90a6d82..214e74c 100644 --- a/wiki/conventions/workspace-devcontainer.md +++ b/wiki/conventions/workspace-devcontainer.md @@ -19,12 +19,13 @@ This repo ships `.devcontainer/devcontainer.json` for **Linux Swift tooling** an | Feature ID | Options | |------------|---------| -| `ghcr.io/devcontainers/features/node:1` | `{ version: "lts" }` | -| `ghcr.io/wcgomes/devcontainer-features/opencode:0` | `{}` | +| `ghcr.io/devcontainers/features/common-utils:2` | `{ installZsh: "false", username: "vscode", upgradePackages: "false" }` | +| `ghcr.io/devcontainers/features/git:1` | `{ version: "os-provided", ppa: "false" }` | +| `ghcr.io/devcontainers/features/node:2.1.0` | `{ version: "lts" }` | | `ghcr.io/wcgomes/devcontainer-features/agents-workspace:0` | `{ divisions: "engineering,testing,security" }` | -- **node:1 (lts)** — required for npm/global tools (codegraph). Opencode/agents-workspace Features do **not** provide Node. -- Opencode + agents-workspace unchanged from prior fixture. +- **node:2.1.0 (lts)** — required for npm/global tools (codegraph). The agents-workspace Feature does **not** provide Node. +- OpenCode is not an OCI Feature. These are supported OCI Features (product Features runner). They are not `docker-outside-of-docker` / `docker-in-docker` / `docker-from-docker` (those are warn-skipped). @@ -32,8 +33,11 @@ These are supported OCI Features (product Features runner). They are not `docker `postCreateCommand` runs `.devcontainer/install-tools.sh`, then `swift package resolve`. +`postCreate` `.devcontainer/install-tools.sh` installs OpenCode via `https://opencode.ai/v2/install` and still installs codegraph. + `install-tools.sh`: +- Installs **OpenCode** via `https://opencode.ai/v2/install` (`install_opencode`, before codegraph) - Installs **codegraph** via npm global - Agent wiring + init if needed