From a5772b26e3e4a8c8291f0bbf89931905fbcd5aa0 Mon Sep 17 00:00:00 2001 From: trsdn Date: Tue, 22 Sep 2026 21:03:42 +0200 Subject: [PATCH] feat(ecosystem): add pipx as a package manager OpenFreshr tracks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reopens issue #29's pipx question. Its original finding was correct at the time: older pipx had no safe, read-only "what's outdated" command — `pipx upgrade` ran immediately, no dry-run, and the only honest check meant a per-package network call to the index. Verified directly against real `pipx 1.17.3`, not assumed: that gap is closed. `pipx list --outdated --json` is genuinely read-only (confirmed: running it repeatedly changes nothing) and returns a clean, structured report per outdated package. - PipxEcosystem: `pipx list --outdated --json` for the check, `pipx upgrade --output json -- ` for the update. Simpler than npm/pnpm's contract in one way — verified pipx exits 0 whether or not anything is outdated, so there's no exit-code heuristic to get wrong, the payload alone carries the answer. - EcosystemKind.pipx already existed as an unused placeholder; this wires it into AppViewModel's EcosystemCoordinator for the first time. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01K1v8n32LeqiPZd5vazB8q1 --- Sources/OpenFreshrApp/AppViewModel.swift | 1 + .../Ecosystem/PipxEcosystem.swift | 155 ++++++++++++++++++ .../OpenFreshrCoreTests/EcosystemTests.swift | 115 +++++++++++++ 3 files changed, 271 insertions(+) create mode 100644 Sources/OpenFreshrCore/Ecosystem/PipxEcosystem.swift diff --git a/Sources/OpenFreshrApp/AppViewModel.swift b/Sources/OpenFreshrApp/AppViewModel.swift index 26171bb..9dc51b4 100644 --- a/Sources/OpenFreshrApp/AppViewModel.swift +++ b/Sources/OpenFreshrApp/AppViewModel.swift @@ -280,6 +280,7 @@ public final class AppViewModel { self.ecosystemCoordinator = EcosystemCoordinator(ecosystems: [ HomebrewFormulaEcosystem(processRunner: processRunner, fileSystem: fileSystem), NpmEcosystem(processRunner: processRunner, fileSystem: fileSystem), + PipxEcosystem(processRunner: processRunner, fileSystem: fileSystem), MacOSUpdateEcosystem(processRunner: processRunner, fileSystem: fileSystem), ]) diff --git a/Sources/OpenFreshrCore/Ecosystem/PipxEcosystem.swift b/Sources/OpenFreshrCore/Ecosystem/PipxEcosystem.swift new file mode 100644 index 0000000..4112c35 --- /dev/null +++ b/Sources/OpenFreshrCore/Ecosystem/PipxEcosystem.swift @@ -0,0 +1,155 @@ +import Foundation + +/// Globally installed **pipx** packages. +/// +/// `pipx` resolves through the same candidate-path pattern as `brew`/`npm`: an +/// absolute path, never `PATH`. +/// +/// Verified directly (not assumed) against real `pipx 1.17.3`. This ecosystem +/// was deliberately left unbuilt for a long time (see the project history on +/// issue #29): older `pipx` offered no safe, read-only "what's outdated" +/// command — `pipx upgrade` ran the upgrade immediately, with no dry-run, and +/// the only honest check would have meant a network call to the package index +/// per venv. Current `pipx` has closed that gap: `pipx list --outdated --json` +/// is genuinely read-only (confirmed: running it repeatedly never changes a +/// venv) and returns a clean, structured report — no version-detection or +/// exit-code heuristics needed at all, unlike npm/pnpm's "exits 1 the moment +/// something is outdated" contract. +public struct PipxEcosystem: EcosystemUpdating { + + public let kind: EcosystemKind = .pipx + + private let processRunner: any ProcessRunning + private let fileSystem: any FileSystemReading + private let candidatePipxPaths: [String] + + public init( + processRunner: any ProcessRunning, + fileSystem: any FileSystemReading, + candidatePipxPaths: [String] = ["/opt/homebrew/bin/pipx", "/usr/local/bin/pipx"] + ) { + self.processRunner = processRunner + self.fileSystem = fileSystem + self.candidatePipxPaths = candidatePipxPaths + } + + private func pipxURL() -> URL? { + for path in candidatePipxPaths where fileSystem.fileExists(atPath: path) { + return URL(fileURLWithPath: path) + } + return nil + } + + public func isAvailable() -> Bool { pipxURL() != nil } + + public func check() -> EcosystemCheck { + guard let pipx = pipxURL() else { return .unavailable } + let result: ProcessResult + do { + // Queries the package index for every pipx-managed venv, same + // network shape as npm/pnpm's `outdated`; bounded for the same + // reason (see ``NpmEcosystem/check()``). + result = try processRunner.run( + executableURL: pipx, arguments: ["list", "--outdated", "--json"], environment: nil, + timeout: 20) + } catch { + return .unknown(.launchFailed(message: error.localizedDescription)) + } + // Verified directly: unlike npm/pnpm, `pipx list --outdated` exits `0` + // whether or not anything is outdated — the payload alone carries the + // answer, so there is no exit-code heuristic to get wrong here. + guard result.exitCode == 0 else { + return .unknown(.processFailed(exitCode: result.exitCode, standardError: result.standardError)) + } + guard let packages = Self.parseOutdated(result.standardOutput) else { + return .unknown(.unparsableOutput) + } + return packages.isEmpty ? .upToDate : .outdated(packages) + } + + public func resolveUpdateCommand(for package: OutdatedPackage) -> ResolvedCommand? { + guard package.ecosystem == .pipx, Self.isValidPackageName(package.name), let pipx = pipxURL() else { + return nil + } + return ResolvedCommand( + executablePath: pipx.path, + arguments: ["upgrade", "--output", "json", "--", package.name] + ) + } + + public func update(_ package: OutdatedPackage) -> BackendActionResult { + guard package.ecosystem == .pipx, Self.isValidPackageName(package.name) else { + return .failed(reason: .invalidIdentifier(package.name)) + } + guard let command = resolveUpdateCommand(for: package) else { + return .failed(reason: .toolUnavailable(tool: "pipx")) + } + do { + let result = try processRunner.run( + executableURL: URL(fileURLWithPath: command.executablePath), arguments: command.arguments) + if result.didSucceed { return .succeeded(standardOutput: result.standardOutput) } + return .failed( + reason: .processFailed(exitCode: result.exitCode, standardError: result.standardError)) + } catch { + return .failed(reason: .launchFailed(message: error.localizedDescription)) + } + } + + // MARK: - Parsing and validation + + private struct Entry: Decodable { + var package: String + var version: String + var latestVersion: String + + enum CodingKeys: String, CodingKey { + case package + case version + case latestVersion = "latest_version" + } + } + + private struct Payload: Decodable { + struct Data: Decodable { + var packages: [Entry] + } + var data: Data + } + + /// Parses `pipx list --outdated --json`: `{"data": {"packages": [{package, + /// version, latest_version, …}], …}}`, verified directly against real + /// `pipx`. Returns `nil` when the output is not the expected shape, so + /// garbage is never read as "up to date". + static func parseOutdated(_ output: String) -> [OutdatedPackage]? { + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + guard let data = trimmed.data(using: .utf8), + let decoded = try? JSONDecoder().decode(Payload.self, from: data) + else { return nil } + return decoded.data.packages + .filter { $0.version != $0.latestVersion } + .map { entry in + OutdatedPackage( + ecosystem: .pipx, + name: entry.package, + installed: entry.version, + available: entry.latestVersion, + isMajor: VersionComparator.isMajorChange(from: entry.version, to: entry.latestVersion) + ) + } + .sorted { $0.name < $1.name } + } + + /// A PyPI-style distribution name: letters, digits, `.`, `_`, `-`, not + /// starting with `-` or empty — PyPI itself is looser (mixed case, + /// normalised on the server side) than npm's lowercase-only rule, but the + /// safety property is the same: nothing here can be parsed as a flag. + /// Anchored with `\z`, not `$`, so a trailing newline cannot slip through. + static func isValidPackageName(_ name: String) -> Bool { + packageNameRegex.firstMatch(in: name, range: NSRange(name.startIndex.. ProcessResult + ) -> (PipxEcosystem, RecordingProcessRunner) { + var fileSystem = FakeFileSystem() + if pipxInstalled { fileSystem.addExistingPath(pipx) } + let runner = RecordingProcessRunner(handler: handler) + return (PipxEcosystem(processRunner: runner, fileSystem: fileSystem), runner) + } + + private let sample = """ + { + "data": { + "packages": [ + {"package": "cowsay", "version": "5.0", "latest_version": "6.1", "environment": "cowsay", "injected": false, "pinned": false} + ], + "packages_checked": 1, + "skipped": [] + }, + "errors": [], + "exit_code": 0, + "pipx_result_version": "1", + "status": "success" + } + """ + + @Test("It parses the real pipx --outdated --json shape") + func parses() { + let (ecosystem, runner) = ecosystem { _, _ in + ProcessResult(exitCode: 0, standardOutput: self.sample, standardError: "") + } + let packages = ecosystem.check().packages + #expect(packages.map(\.name) == ["cowsay"]) + #expect(packages[0].installed == "5.0") + #expect(packages[0].available == "6.1") + #expect(runner.invocations.first?.arguments == ["list", "--outdated", "--json"]) + } + + @Test("An empty packages array, exit 0, is up to date") + func upToDate() { + let json = #"{"data": {"packages": [], "packages_checked": 3, "skipped": []}, "exit_code": 0}"# + let (ecosystem, _) = ecosystem { _, _ in ProcessResult(exitCode: 0, standardOutput: json, standardError: "") } + #expect(ecosystem.check() == .upToDate) + } + + @Test("A non-zero exit is always a real failure — pipx never uses exit code to signal \"outdated\"") + func nonZeroExitIsFailure() { + let (ecosystem, _) = ecosystem { _, _ in + ProcessResult(exitCode: 1, standardOutput: "", standardError: "pipx error") + } + #expect(ecosystem.check() == .unknown(.processFailed(exitCode: 1, standardError: "pipx error"))) + } + + @Test("Output that is not the expected JSON is unknown, never up to date") + func garbage() { + let (ecosystem, _) = ecosystem { _, _ in + ProcessResult(exitCode: 0, standardOutput: "not json", standardError: "") + } + #expect(ecosystem.check() == .unknown(.unparsableOutput)) + } + + @Test("Without pipx the ecosystem is unavailable and nothing runs") + func noPipx() { + let (ecosystem, runner) = ecosystem(pipxInstalled: false) { _, _ in + ProcessResult(exitCode: 0, standardOutput: "", standardError: "") + } + #expect(!ecosystem.isAvailable()) + #expect(ecosystem.check() == .unavailable) + #expect(runner.invocations.isEmpty) + } + + @Test("The update command upgrades exactly the one named package, with a -- separator") + func command() { + let (ecosystem, _) = ecosystem { _, _ in ProcessResult(exitCode: 0, standardOutput: "", standardError: "") } + let package = OutdatedPackage( + ecosystem: .pipx, name: "cowsay", installed: "5.0", available: "6.1", isMajor: true) + #expect( + ecosystem.resolveUpdateCommand(for: package)?.arguments + == ["upgrade", "--output", "json", "--", "cowsay"]) + } + + @Test("Names that could be parsed as flags are refused before any process runs") + func rejectsHostileNames() { + let (ecosystem, runner) = ecosystem { _, _ in ProcessResult(exitCode: 0, standardOutput: "", standardError: "") + } + for name in ["-x", "--force", "a; rm -rf /", "", "cowsay\n"] { + let hostile = OutdatedPackage(ecosystem: .pipx, name: name, installed: "1", available: "2", isMajor: false) + #expect(ecosystem.resolveUpdateCommand(for: hostile) == nil, "\(name)") + #expect(!ecosystem.update(hostile).didReportSuccess, "\(name)") + } + #expect(runner.invocations.isEmpty) + } + + @Test("A package with the wrong ecosystem tag is refused, never routed to pipx by name alone") + func refusesWrongEcosystemTag() { + let (ecosystem, runner) = ecosystem { _, _ in ProcessResult(exitCode: 0, standardOutput: "", standardError: "") + } + let npmTagged = OutdatedPackage( + ecosystem: .npm, name: "cowsay", installed: "5.0", available: "6.1", isMajor: false) + #expect(ecosystem.resolveUpdateCommand(for: npmTagged) == nil) + #expect(!ecosystem.update(npmTagged).didReportSuccess) + #expect(runner.invocations.isEmpty) + } +} + @Suite("MacOSUpdateEcosystem") struct MacOSUpdateEcosystemTests {