diff --git a/Sources/Reeve/Models/PM2Process.swift b/Sources/Reeve/Models/PM2Process.swift index c102f34..13ff9d6 100644 --- a/Sources/Reeve/Models/PM2Process.swift +++ b/Sources/Reeve/Models/PM2Process.swift @@ -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" @@ -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 { @@ -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) -> 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 } } diff --git a/Sources/Reeve/Services/PM2Service.swift b/Sources/Reeve/Services/PM2Service.swift index 4e4e5af..282548c 100644 --- a/Sources/Reeve/Services/PM2Service.swift +++ b/Sources/Reeve/Services/PM2Service.swift @@ -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 } diff --git a/Tests/reeveTests/ReeveTests.swift b/Tests/reeveTests/ReeveTests.swift index 6ac6078..2e9cf49 100644 --- a/Tests/reeveTests/ReeveTests.swift +++ b/Tests/reeveTests/ReeveTests.swift @@ -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) @@ -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 = """