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
69 changes: 52 additions & 17 deletions Sources/Reeve/Models/PM2Process.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,19 @@ public struct PM2Process: Identifiable, Sendable {
}

// Custom decoding from pm2 jlist JSON — only extracts fields we need.
// pm2_env contains the full process environment including secrets;
// we deliberately avoid decoding the entire object.
// pm2_env contains the full process environment, which can include secrets.
// We enumerate its key *names* to find port variables but only decode the
// values of port-shaped keys, so secret values never enter memory.
extension PM2Process: Decodable {
/// Coding key that accepts any string, used to enumerate `pm2_env` keys
/// without modelling the whole (secret-bearing) environment.
private struct DynamicKey: CodingKey {
let stringValue: String
var intValue: Int? { nil }
init?(stringValue: String) { self.stringValue = stringValue }
init?(intValue: Int) { return nil }
}

private enum TopKeys: String, CodingKey {
case pid, name, monit
case pmId = "pm_id"
Expand All @@ -127,9 +137,6 @@ extension PM2Process: Decodable {
case createdAt = "created_at"
case pmOutLogPath = "pm_out_log_path"
case pmErrLogPath = "pm_err_log_path"
// Common port env vars
case envPORT = "PORT"
case envGPTZeroPort = "GPTZERO_CUSTOM_PORT"
}

public init(from decoder: Decoder) throws {
Expand All @@ -154,20 +161,48 @@ extension PM2Process: Decodable {
outLogPath = try env.decodeIfPresent(String.self, forKey: .pmOutLogPath) ?? ""
errLogPath = try env.decodeIfPresent(String.self, forKey: .pmErrLogPath) ?? ""

// Extract port: first try args (--port N, -p N), then common env vars
// Extract port: prefer an explicit `--port`/`-p` arg, then fall back to
// any `PORT` or `*_PORT` environment variable.
let args = try env.decodeIfPresent([String].self, forKey: .args) ?? []
let argsJoined = args.joined(separator: " ")
let portPattern = try? NSRegularExpression(pattern: #"(?:--port|-p)\s+(\d+)"#)
if let match = portPattern?.firstMatch(in: argsJoined, range: NSRange(argsJoined.startIndex..., in: argsJoined)),
let range = Range(match.range(at: 1), in: argsJoined),
let parsed = Int(argsJoined[range]) {
port = parsed
} else if let envPort = try env.decodeIfPresent(StringOrInt.self, forKey: .envGPTZeroPort) {
port = envPort.intValue
} else if let envPort = try env.decodeIfPresent(StringOrInt.self, forKey: .envPORT) {
port = envPort.intValue
if let argPort = PM2Process.port(fromArgs: args) {
port = argPort
} else {
port = nil
let envContainer = try top.nestedContainer(keyedBy: DynamicKey.self, forKey: .pm2Env)
port = PM2Process.port(fromEnv: envContainer)
}
}

/// Parse a port from process args, e.g. `--port 3000` or `-p 8080`.
private static func port(fromArgs args: [String]) -> Int? {
let joined = args.joined(separator: " ")
guard let pattern = try? NSRegularExpression(pattern: #"(?:--port|-p)\s+(\d+)"#),
let match = pattern.firstMatch(in: joined, range: NSRange(joined.startIndex..., in: joined)),
let range = Range(match.range(at: 1), in: joined) else {
return nil
}
return Int(joined[range])
}

/// Find a port among the `pm2_env` keys: the bare `PORT`, or a specific
/// suffix like `SERVER_PORT`. A specific `*_PORT` wins over the generic
/// `PORT`, with ties broken alphabetically so the result is deterministic
/// regardless of JSON key order.
private static func port(fromEnv container: KeyedDecodingContainer<DynamicKey>) -> Int? {
let portKeys = container.allKeys.filter { key in
let upper = key.stringValue.uppercased()
return upper == "PORT" || upper.hasSuffix("_PORT")
}
let ordered = portKeys.sorted { lhs, rhs in
let lhsSpecific = lhs.stringValue.uppercased() != "PORT"
let rhsSpecific = rhs.stringValue.uppercased() != "PORT"
if lhsSpecific != rhsSpecific { return lhsSpecific }
return lhs.stringValue < rhs.stringValue
}
for key in ordered {
if let parsed = try? container.decode(StringOrInt.self, forKey: key), let value = parsed.intValue {
return value
}
}
return nil
}
}
3 changes: 3 additions & 0 deletions Sources/Reeve/Services/PM2Service.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ public class PM2Service: ObservableObject {
error = nil
} catch {
self.error = error.localizedDescription
// Mark the first scan complete so the UI shows the error banner
// instead of the loading skeleton forever (e.g. pm2 not installed).
hasCompletedFirstScan = true
return
}

Expand Down
57 changes: 47 additions & 10 deletions Tests/reeveTests/ReeveTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,25 +173,25 @@ struct PortExtractionTests {
#expect(try decode(json).port == 8080)
}

@Test("Port from GPTZERO_CUSTOM_PORT (int)")
func portFromGPTZeroInt() throws {
@Test("Port from a *_PORT env var (int)")
func portFromSuffixInt() throws {
let json = """
{
"pid": 1, "name": "srv", "pm_id": 0,
"monit": { "memory": 0, "cpu": 0.0 },
"pm2_env": { "status": "online", "GPTZERO_CUSTOM_PORT": 55001 }
"pm2_env": { "status": "online", "SERVER_PORT": 55001 }
}
"""
#expect(try decode(json).port == 55001)
}

@Test("Port from GPTZERO_CUSTOM_PORT (string)")
func portFromGPTZeroString() throws {
@Test("Port from a *_PORT env var (string)")
func portFromSuffixString() throws {
let json = """
{
"pid": 1, "name": "srv", "pm_id": 0,
"monit": { "memory": 0, "cpu": 0.0 },
"pm2_env": { "status": "online", "GPTZERO_CUSTOM_PORT": "55002" }
"pm2_env": { "status": "online", "HTTP_PORT": "55002" }
}
"""
#expect(try decode(json).port == 55002)
Expand Down Expand Up @@ -227,24 +227,61 @@ struct PortExtractionTests {
{
"pid": 1, "name": "srv", "pm_id": 0,
"monit": { "memory": 0, "cpu": 0.0 },
"pm2_env": { "status": "online", "args": ["--port", "9000"], "PORT": 4000, "GPTZERO_CUSTOM_PORT": 5000 }
"pm2_env": { "status": "online", "args": ["--port", "9000"], "PORT": 4000, "SERVER_PORT": 5000 }
}
"""
#expect(try decode(json).port == 9000)
}

@Test("GPTZERO_CUSTOM_PORT takes precedence over PORT")
func gptzeroPortPrecedence() throws {
@Test("A specific *_PORT takes precedence over the generic PORT")
func suffixPortPrecedence() throws {
let json = """
{
"pid": 1, "name": "srv", "pm_id": 0,
"monit": { "memory": 0, "cpu": 0.0 },
"pm2_env": { "status": "online", "PORT": 4000, "GPTZERO_CUSTOM_PORT": 5000 }
"pm2_env": { "status": "online", "PORT": 4000, "SERVER_PORT": 5000 }
}
"""
#expect(try decode(json).port == 5000)
}

@Test("Multiple *_PORT vars resolve deterministically (alphabetical)")
func multipleSuffixPortsDeterministic() throws {
let json = """
{
"pid": 1, "name": "srv", "pm_id": 0,
"monit": { "memory": 0, "cpu": 0.0 },
"pm2_env": { "status": "online", "SERVER_PORT": 5000, "APP_PORT": 6000 }
}
"""
// APP_PORT sorts before SERVER_PORT, so it wins regardless of JSON order.
#expect(try decode(json).port == 6000)
}

@Test("Keys merely containing 'port' are ignored")
func ignoresNonPortKeys() throws {
let json = """
{
"pid": 1, "name": "srv", "pm_id": 0,
"monit": { "memory": 0, "cpu": 0.0 },
"pm2_env": { "status": "online", "SUPPORT_EMAIL": "a@b.com", "EXPORT_DIR": "/tmp" }
}
"""
#expect(try decode(json).port == nil)
}

@Test("Non-numeric port value falls back to the next candidate")
func nonNumericPortFallback() throws {
let json = """
{
"pid": 1, "name": "srv", "pm_id": 0,
"monit": { "memory": 0, "cpu": 0.0 },
"pm2_env": { "status": "online", "APP_PORT": "auto", "SERVER_PORT": 7000 }
}
"""
#expect(try decode(json).port == 7000)
}

@Test("No port when none specified")
func noPort() throws {
let json = """
Expand Down
Loading