Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Sources/OpenFreshrApp/AppViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ public final class AppViewModel {
HomebrewFormulaEcosystem(processRunner: processRunner, fileSystem: fileSystem),
NpmEcosystem(processRunner: processRunner, fileSystem: fileSystem),
PnpmEcosystem(processRunner: processRunner, fileSystem: fileSystem),
PipxEcosystem(processRunner: processRunner, fileSystem: fileSystem),
MacOSUpdateEcosystem(processRunner: processRunner, fileSystem: fileSystem),
])

Expand Down
155 changes: 155 additions & 0 deletions Sources/OpenFreshrCore/Ecosystem/PipxEcosystem.swift
Original file line number Diff line number Diff line change
@@ -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..<name.endIndex, in: name)) != nil
}

private static let packageNameRegex = try! NSRegularExpression(
pattern: "\\A[A-Za-z0-9][A-Za-z0-9._-]*\\z"
)
}
115 changes: 115 additions & 0 deletions Tests/OpenFreshrCoreTests/EcosystemTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,121 @@ struct PnpmEcosystemTests {
}
}

/// Verified directly against real `pipx 1.17.3` (see ``PipxEcosystem``'s doc
/// comment): `pipx list --outdated --json` is a genuinely read-only check,
/// unlike the `pipx upgrade`-only world issue #29 originally found — no
/// dry-run existed then. Also unlike npm/pnpm, it exits `0` whether or not
/// anything is outdated; the payload alone carries the answer.
@Suite("PipxEcosystem")
struct PipxEcosystemTests {

private let pipx = "/opt/homebrew/bin/pipx"

private func ecosystem(
pipxInstalled: Bool = true,
handler: @escaping @Sendable (URL, [String]) -> 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 {

Expand Down
Loading