From 3cb3910590480e4cfe5e13f53633bfa6e4d2fc4d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 17:15:44 +0000 Subject: [PATCH 1/2] Generalise pm2 port detection to any PORT/*_PORT env var Port detection hardcoded a project-specific GPTZERO_CUSTOM_PORT env var, which has no place in a general-purpose, public tool. Replace it with a generic rule: read the bare PORT or any *_PORT variable (e.g. SERVER_PORT, HTTP_PORT), preferring a specific *_PORT over the generic PORT and breaking ties alphabetically so the result is deterministic regardless of JSON key order. To find these without modelling the whole (secret-bearing) pm2_env, the decoder enumerates env key names but only decodes the values of port-shaped keys, so secret values still never enter memory. Keys that merely contain "port" (SUPPORT_EMAIL, EXPORT_DIR) are ignored, and a non-numeric value falls through to the next candidate. Update the port-extraction tests to cover the generalised behaviour. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018fGGnRAjpLH5EWXNaqYza8 --- Sources/Reeve/Models/PM2Process.swift | 69 ++++++++++++++++++++------- Tests/reeveTests/ReeveTests.swift | 57 ++++++++++++++++++---- 2 files changed, 99 insertions(+), 27 deletions(-) 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/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 = """ From ed218c120add6e0345660869b7e07ab999ca5471 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 17:15:50 +0000 Subject: [PATCH 2/2] Show error banner instead of endless skeleton when pm2 is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the pm2 binary can't be resolved (e.g. pm2 isn't installed), refresh() set the error message but returned before marking the first scan complete. The UI then showed the error banner together with the loading skeleton shimmering forever beneath it — a broken-looking first run for anyone without pm2. Mark the first scan complete on resolver failure so the panel settles into a clean error state. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018fGGnRAjpLH5EWXNaqYza8 --- Sources/Reeve/Services/PM2Service.swift | 3 +++ 1 file changed, 3 insertions(+) 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 }