feat: migrate Mac mini services into Reyna CLI
This commit is contained in:
@@ -22,6 +22,7 @@
|
||||
C0000000000000000000000D /* RemindersProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000011 /* RemindersProvider.swift */; };
|
||||
C0000000000000000000000E /* RemindersAuthorizationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000012 /* RemindersAuthorizationProvider.swift */; };
|
||||
C00000000000000000000011 /* SystemInfoProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000015 /* SystemInfoProvider.swift */; };
|
||||
C00000000000000000000012 /* PythonLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = B00000000000000000000016 /* PythonLauncher.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -43,6 +44,7 @@
|
||||
B00000000000000000000011 /* RemindersProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersProvider.swift; sourceTree = "<group>"; };
|
||||
B00000000000000000000012 /* RemindersAuthorizationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersAuthorizationProvider.swift; sourceTree = "<group>"; };
|
||||
B00000000000000000000015 /* SystemInfoProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemInfoProvider.swift; sourceTree = "<group>"; };
|
||||
B00000000000000000000016 /* PythonLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PythonLauncher.swift; sourceTree = "<group>"; };
|
||||
B0000000000000000000000D /* Reyna CLI.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Reyna CLI.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -117,6 +119,7 @@
|
||||
B00000000000000000000011 /* RemindersProvider.swift */,
|
||||
B00000000000000000000012 /* RemindersAuthorizationProvider.swift */,
|
||||
B00000000000000000000015 /* SystemInfoProvider.swift */,
|
||||
B00000000000000000000016 /* PythonLauncher.swift */,
|
||||
B00000000000000000000008 /* SocketPathValidation.swift */,
|
||||
B00000000000000000000009 /* SocketServer.swift */,
|
||||
);
|
||||
@@ -209,6 +212,7 @@
|
||||
C0000000000000000000000D /* RemindersProvider.swift in Sources */,
|
||||
C0000000000000000000000E /* RemindersAuthorizationProvider.swift in Sources */,
|
||||
C00000000000000000000011 /* SystemInfoProvider.swift in Sources */,
|
||||
C00000000000000000000012 /* PythonLauncher.swift in Sources */,
|
||||
C00000000000000000000006 /* SocketPathValidation.swift in Sources */,
|
||||
C00000000000000000000007 /* SocketServer.swift in Sources */,
|
||||
C00000000000000000000008 /* CSignalSupport.c in Sources */,
|
||||
|
||||
@@ -5,6 +5,19 @@ import Foundation
|
||||
// Headless design: socket-server or stdin JSON-lines mode only.
|
||||
|
||||
public func runReynaCLIHost(arguments: [String] = CommandLine.arguments) -> Never {
|
||||
let hasSocket = arguments.contains("--socket")
|
||||
let hasPython = arguments.contains("--python")
|
||||
|
||||
if hasSocket && hasPython {
|
||||
fputs("error: --python cannot be combined with --socket\n", stderr)
|
||||
Darwin.exit(2)
|
||||
}
|
||||
|
||||
if let idx = arguments.firstIndex(of: "--python") {
|
||||
let forwardedArguments = Array(arguments.dropFirst(idx + 1))
|
||||
Darwin.exit(runPythonCLI(forwardedArguments: forwardedArguments))
|
||||
}
|
||||
|
||||
if let idx = arguments.firstIndex(of: "--socket") {
|
||||
let nextIdx = idx + 1
|
||||
guard nextIdx < arguments.count else {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import Foundation
|
||||
|
||||
public struct PythonRuntime: Equatable {
|
||||
public let workingDirectory: URL
|
||||
public let python: URL
|
||||
}
|
||||
|
||||
public func parsePythonRuntimeConfig(_ content: String) -> [String: String] {
|
||||
var values: [String: String] = [:]
|
||||
for rawLine in content.components(separatedBy: .newlines) {
|
||||
let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !line.isEmpty, !line.hasPrefix("#") else { continue }
|
||||
let parts = line.split(separator: "=", maxSplits: 1).map(String.init)
|
||||
guard parts.count == 2 else { continue }
|
||||
let key = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value.count >= 2,
|
||||
((value.hasPrefix("\"") && value.hasSuffix("\"")) || (value.hasPrefix("'") && value.hasSuffix("'"))) {
|
||||
value.removeFirst()
|
||||
value.removeLast()
|
||||
}
|
||||
guard key == "REYNA_CLI_DIR" || key == "REYNA_CLI_PYTHON" else { continue }
|
||||
values[key] = value
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
public func pythonRuntimeConfig(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> [String: String] {
|
||||
let paths = [
|
||||
homeDirectory.appendingPathComponent(".reyna-cli.env"),
|
||||
homeDirectory.appendingPathComponent(".config/reyna-cli/env"),
|
||||
]
|
||||
var result: [String: String] = [:]
|
||||
for path in paths {
|
||||
guard let content = try? String(contentsOf: path, encoding: .utf8) else { continue }
|
||||
result.merge(parsePythonRuntimeConfig(content)) { _, newest in newest }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
public func resolvePythonRuntime(
|
||||
homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser,
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
config: [String: String]? = nil,
|
||||
isExecutable: (String) -> Bool = { FileManager.default.isExecutableFile(atPath: $0) }
|
||||
) -> PythonRuntime {
|
||||
let loadedConfig = config ?? pythonRuntimeConfig(homeDirectory: homeDirectory)
|
||||
let defaultWorkDirectory = homeDirectory.appendingPathComponent("Projects/reyna-cli").path
|
||||
let workDirectoryPath = environment["REYNA_CLI_DIR"] ?? loadedConfig["REYNA_CLI_DIR"] ?? defaultWorkDirectory
|
||||
let selectedPython = environment["REYNA_CLI_PYTHON"]
|
||||
?? loadedConfig["REYNA_CLI_PYTHON"]
|
||||
?? URL(fileURLWithPath: workDirectoryPath).appendingPathComponent(".venv/bin/python").path
|
||||
let pythonPath = isExecutable(selectedPython) ? selectedPython : "/usr/bin/python3"
|
||||
return PythonRuntime(
|
||||
workingDirectory: URL(fileURLWithPath: workDirectoryPath),
|
||||
python: URL(fileURLWithPath: pythonPath)
|
||||
)
|
||||
}
|
||||
|
||||
public func pythonCommand(runtime: PythonRuntime, forwardedArguments: [String]) -> [String] {
|
||||
[runtime.python.path, "-m", "reyna_cli.cli"] + forwardedArguments
|
||||
}
|
||||
|
||||
public func pythonLaunchEnvironment(from environment: [String: String] = ProcessInfo.processInfo.environment) -> [String: String] {
|
||||
var sanitized = environment
|
||||
for key in ["PYTHONHOME", "PYTHONPATH", "VIRTUAL_ENV"] {
|
||||
sanitized.removeValue(forKey: key)
|
||||
}
|
||||
sanitized["PYTHONNOUSERSITE"] = "1"
|
||||
return sanitized
|
||||
}
|
||||
|
||||
public func runPythonCLI(forwardedArguments: [String]) -> Int32 {
|
||||
let runtime = resolvePythonRuntime()
|
||||
let command = pythonCommand(runtime: runtime, forwardedArguments: forwardedArguments)
|
||||
let process = Process()
|
||||
process.executableURL = runtime.python
|
||||
process.arguments = Array(command.dropFirst())
|
||||
process.currentDirectoryURL = runtime.workingDirectory
|
||||
process.environment = pythonLaunchEnvironment()
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
return process.terminationStatus
|
||||
} catch {
|
||||
fputs("error: failed to run Reyna CLI Python: \(error)\n", stderr)
|
||||
return 127
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import XCTest
|
||||
@testable import ReynaCLIHostCore
|
||||
|
||||
final class PythonLauncherTests: XCTestCase {
|
||||
func testConfiguredRuntimeUsesEnvironmentBeforeConfigAndDefaults() throws {
|
||||
let home = URL(fileURLWithPath: "/tmp/reyna-home")
|
||||
let config = [
|
||||
"REYNA_CLI_DIR": "/config/repo",
|
||||
"REYNA_CLI_PYTHON": "/config/python",
|
||||
]
|
||||
let environment = [
|
||||
"REYNA_CLI_DIR": "/environment/repo",
|
||||
"REYNA_CLI_PYTHON": "/environment/python",
|
||||
]
|
||||
|
||||
let runtime = resolvePythonRuntime(
|
||||
homeDirectory: home,
|
||||
environment: environment,
|
||||
config: config,
|
||||
isExecutable: { path in path == "/environment/python" }
|
||||
)
|
||||
|
||||
XCTAssertEqual(runtime.workingDirectory.path, "/environment/repo")
|
||||
XCTAssertEqual(runtime.python.path, "/environment/python")
|
||||
}
|
||||
|
||||
func testConfiguredRuntimeFallsBackToSystemPythonWhenSelectedPythonMissing() throws {
|
||||
let runtime = resolvePythonRuntime(
|
||||
homeDirectory: URL(fileURLWithPath: "/tmp/reyna-home"),
|
||||
environment: [:],
|
||||
config: ["REYNA_CLI_DIR": "/config/repo", "REYNA_CLI_PYTHON": "/missing/python"],
|
||||
isExecutable: { _ in false }
|
||||
)
|
||||
|
||||
XCTAssertEqual(runtime.workingDirectory.path, "/config/repo")
|
||||
XCTAssertEqual(runtime.python.path, "/usr/bin/python3")
|
||||
}
|
||||
|
||||
func testPythonCommandRunsFixedCliModuleAndForwardsArguments() {
|
||||
let runtime = PythonRuntime(
|
||||
workingDirectory: URL(fileURLWithPath: "/repo"),
|
||||
python: URL(fileURLWithPath: "/repo/.venv/bin/python")
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
pythonCommand(runtime: runtime, forwardedArguments: ["local-services", "speech", "generate", "hello"]),
|
||||
["/repo/.venv/bin/python", "-m", "reyna_cli.cli", "local-services", "speech", "generate", "hello"]
|
||||
)
|
||||
}
|
||||
|
||||
func testPythonEnvironmentRemovesParentInterpreterOverrides() {
|
||||
let environment = pythonLaunchEnvironment(from: [
|
||||
"PATH": "/bin",
|
||||
"PYTHONHOME": "/wrong",
|
||||
"PYTHONPATH": "/wrong/site-packages",
|
||||
"VIRTUAL_ENV": "/wrong/.venv",
|
||||
"KEEP": "yes",
|
||||
])
|
||||
|
||||
XCTAssertNil(environment["PYTHONHOME"])
|
||||
XCTAssertNil(environment["PYTHONPATH"])
|
||||
XCTAssertNil(environment["VIRTUAL_ENV"])
|
||||
XCTAssertEqual(environment["PYTHONNOUSERSITE"], "1")
|
||||
XCTAssertEqual(environment["KEEP"], "yes")
|
||||
}
|
||||
|
||||
func testConfigParserIgnoresCommentsBlankLinesAndQuotes() {
|
||||
let config = parsePythonRuntimeConfig("# comment\nREYNA_CLI_DIR = '/repo path'\n\nREYNA_CLI_PYTHON=\"/python\"\ninvalid\n")
|
||||
|
||||
XCTAssertEqual(config["REYNA_CLI_DIR"], "/repo path")
|
||||
XCTAssertEqual(config["REYNA_CLI_PYTHON"], "/python")
|
||||
XCTAssertNil(config["invalid"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user