1
0
mirror of https://github.com/nicoverbruggen/phpmon.git synced 2025-08-07 12:00:09 +02:00

Load extension info for all PHP versions

In order to make this possible, I've added a new `sync()` method to the
Shellable protocol, which now should allow us to run shell commands
synchronously. Back to basics, as this was how *all* commands were
run in legacy versions of PHP Monitor.

The advantage here is that there is now a choice. Previously, you'd
have to use the `system()` helper that I added.

Usage of that helper is now discouraged, as is the synchronous
shell method, but it may be useful for some methods where waiting
for the outcome of the output is required.

(Important: the `system()` helper is still required when determining
what the preferred terminal is during the initialization of the
`Paths` class.)
This commit is contained in:
2023-11-26 21:26:48 +01:00
parent 23cf575026
commit e8306289ce
15 changed files with 155 additions and 71 deletions

View File

@ -1213,6 +1213,8 @@
033D459C2B0D506B00070080 /* PHP Versions */ = { 033D459C2B0D506B00070080 /* PHP Versions */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
C43BCD4329FBEF40001547BC /* ModifyPhpVersionCommand.swift */,
C4B79ECA29CA475900A483EE /* RemovePhpVersionCommand.swift */,
); );
path = "PHP Versions"; path = "PHP Versions";
sourceTree = "<group>"; sourceTree = "<group>";
@ -1667,6 +1669,15 @@
path = phpmon; path = phpmon;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
C4513F8D2B13CD08001AD760 /* PHP Extensions */ = {
isa = PBXGroup;
children = (
033D45972B0D4EC600070080 /* InstallPhpExtensionCommand.swift */,
033D459D2B0D513900070080 /* RemovePhpExtensionCommand.swift */,
);
path = "PHP Extensions";
sourceTree = "<group>";
};
C456A0D02AA6175D0080144F /* Config */ = { C456A0D02AA6175D0080144F /* Config */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -1961,12 +1972,9 @@
C4B79EBA29CA38D100A483EE /* Commands */ = { C4B79EBA29CA38D100A483EE /* Commands */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
C4513F8D2B13CD08001AD760 /* PHP Extensions */,
033D459C2B0D506B00070080 /* PHP Versions */, 033D459C2B0D506B00070080 /* PHP Versions */,
C4B79EBB29CA38DB00A483EE /* BrewCommand.swift */, C4B79EBB29CA38DB00A483EE /* BrewCommand.swift */,
C43BCD4329FBEF40001547BC /* ModifyPhpVersionCommand.swift */,
C4B79ECA29CA475900A483EE /* RemovePhpVersionCommand.swift */,
033D45972B0D4EC600070080 /* InstallPhpExtensionCommand.swift */,
033D459D2B0D513900070080 /* RemovePhpExtensionCommand.swift */,
); );
path = Commands; path = Commands;
sourceTree = "<group>"; sourceTree = "<group>";

View File

@ -112,7 +112,7 @@ All stable and supported PHP versions are also supported by PHP Monitor. However
Backports that are installable via PHP Monitor's **PHP Version Manager** functionality are subject to availability via [this tap](https://github.com/shivammathur/homebrew-php). Backports that are installable via PHP Monitor's **PHP Version Manager** functionality are subject to availability via [this tap](https://github.com/shivammathur/homebrew-php).
PHP extensions that are installable via PHP Monitor's **PHP Version Manager** functionality are subject to availability via [this tap](https://github.com/shivammathur/homebrew-extensions). PHP extensions that are installable via PHP Monitor's **PHP Extension Manager** functionality are subject to availability via [this tap](https://github.com/shivammathur/homebrew-extensions).
For maximum compatibility with older PHP versions, you may wish to keep using Valet 2 or 3. For more information, please see [SECURITY.md](./SECURITY.md) to find out which versions of PHP are supported with different versions of Valet. For maximum compatibility with older PHP versions, you may wish to keep using Valet 2 or 3. For more information, please see [SECURITY.md](./SECURITY.md) to find out which versions of PHP are supported with different versions of Valet.
</details> </details>

View File

@ -64,7 +64,7 @@ class RealFileSystem: FileSystemProtocol {
// MARK: FS Attributes // MARK: FS Attributes
func makeExecutable(_ path: String) throws { func makeExecutable(_ path: String) throws {
_ = system("chmod +x \(path.replacingTildeWithHomeDirectory)") _ = ActiveShell.shared.sync("chmod +x \(path.replacingTildeWithHomeDirectory)")
} }
// MARK: - Checks // MARK: - Checks

View File

@ -10,7 +10,6 @@ import Foundation
/** /**
Run a simple blocking Shell command on the user's own system. Run a simple blocking Shell command on the user's own system.
Avoid using this method in favor of the fakeable Shell class unless needed for express system operations.
*/ */
public func system(_ command: String) -> String { public func system(_ command: String) -> String {
let task = Process() let task = Process()

View File

@ -62,13 +62,6 @@ class ActivePhpInstallation {
return return
} }
// Load extension information
let mainConfigurationFileUrl = URL(fileURLWithPath: "\(Paths.etcPath)/php/\(version.short)/php.ini")
if let file = PhpConfigurationFile.from(filePath: mainConfigurationFileUrl.path) {
iniFiles.append(file)
}
// Get configuration values // Get configuration values
limits = Limits( limits = Limits(
memory_limit: getByteCount(key: "memory_limit"), memory_limit: getByteCount(key: "memory_limit"),
@ -76,15 +69,10 @@ class ActivePhpInstallation {
post_max_size: getByteCount(key: "post_max_size") post_max_size: getByteCount(key: "post_max_size")
) )
// Return a list of .ini files parsed after php.ini let paths = ActiveShell.shared
let paths = Command.execute( .sync("\(Paths.php) --ini | grep -E -o '(/[^ ]+\\.ini)'").out
path: Paths.php, .split(separator: "\n")
arguments: ["-r", "echo php_ini_scanned_files();"], .map { String($0) }
trimNewlines: false
)
.replacingOccurrences(of: "\n", with: "")
.split(separator: ",")
.map { String($0) }
// See if any extensions are present in said .ini files // See if any extensions are present in said .ini files
paths.forEach { (iniFilePath) in paths.forEach { (iniFilePath) in

View File

@ -172,7 +172,7 @@ class PhpEnvironments {
let phpAliasInstall = PhpInstallation(phpAlias) let phpAliasInstall = PhpInstallation(phpAlias)
// Before inserting, ensure that the actual output matches the alias // Before inserting, ensure that the actual output matches the alias
// if that isn't the case, our formula remains out-of-date // if that isn't the case, our formula remains out-of-date
if !phpAliasInstall.missingBinary { if !phpAliasInstall.isMissingBinary {
supportedVersions.insert(phpAlias) supportedVersions.insert(phpAlias)
} }
} }

View File

@ -12,21 +12,36 @@ class PhpInstallation {
var versionNumber: VersionNumber var versionNumber: VersionNumber
var missingBinary: Bool = false var iniFiles: [PhpConfigurationFile] = []
var isMissingBinary: Bool = false
var isHealthy: Bool = true var isHealthy: Bool = true
var extensions: [PhpExtension] {
return self.iniFiles.flatMap({ $0.extensions })
}
/** /**
In order to determine details about a PHP installation, In order to determine details about a PHP installation,
well simply run `php-config --version` in the relevant directory. well simply run `php-config --version` in the relevant directory.
*/ */
init(_ version: String) { init(_ version: String) {
let phpConfigExecutablePath = "\(Paths.optPath)/php@\(version)/bin/php-config" let phpConfigExecutablePath = "\(Paths.optPath)/php@\(version)/bin/php-config",
phpExecutablePath = "\(Paths.optPath)/php@\(version)/bin/php"
let phpExecutablePath = "\(Paths.optPath)/php@\(version)/bin/php" versionNumber = VersionNumber.make(from: version)!
self.versionNumber = VersionNumber.make(from: version)! determineVersion(phpConfigExecutablePath, phpExecutablePath)
determineHealth(phpExecutablePath)
determineIniFiles(phpExecutablePath)
// Find all enabled extensions
let enabled = self.extensions.filter({ $0.enabled }).map({ $0.name })
Log.info("PHP \(versionNumber.short) has the following extensions enabled: \(enabled)")
}
private func determineVersion(_ phpConfigExecutablePath: String, _ phpExecutablePath: String) {
if FileSystem.fileExists(phpConfigExecutablePath) { if FileSystem.fileExists(phpConfigExecutablePath) {
let longVersionString = Command.execute( let longVersionString = Command.execute(
path: phpConfigExecutablePath, path: phpConfigExecutablePath,
@ -36,13 +51,15 @@ class PhpInstallation {
// The parser should always work, or the string has to be very unusual. // The parser should always work, or the string has to be very unusual.
// If so, the app SHOULD crash, so that the users report what's up. // If so, the app SHOULD crash, so that the users report what's up.
self.versionNumber = try! VersionNumber.parse(longVersionString) versionNumber = try! VersionNumber.parse(longVersionString)
} else { } else {
// Keep track that the `php-config` binary is missing; this often means there's a mismatch between // Keep track that the `php-config` binary is missing; this often means there's a mismatch between
// the `php` version alias and the actual installed version (e.g. you haven't upgraded `php`) // the `php` version alias and the actual installed version (e.g. you haven't upgraded `php`)
missingBinary = true isMissingBinary = true
} }
}
private func determineHealth(_ phpExecutablePath: String) {
if FileSystem.fileExists(phpExecutablePath) { if FileSystem.fileExists(phpExecutablePath) {
let testCommand = Command.execute( let testCommand = Command.execute(
path: phpExecutablePath, path: phpExecutablePath,
@ -59,4 +76,18 @@ class PhpInstallation {
} }
} }
} }
private func determineIniFiles(_ phpExecutablePath: String) {
let paths = ActiveShell.shared
.sync("\(phpExecutablePath) --ini | grep -E -o '(/[^ ]+\\.ini)'").out
.split(separator: "\n")
.map { String($0) }
// See if any extensions are present in said .ini files
paths.forEach { (iniFilePath) in
if let file = PhpConfigurationFile.from(filePath: iniFilePath) {
iniFiles.append(file)
}
}
}
} }

View File

@ -86,14 +86,37 @@ class RealShell: ShellProtocol {
// MARK: - Shellable Protocol // MARK: - Shellable Protocol
func sync(_ command: String) -> ShellOutput {
let task = getShellProcess(for: command)
let outputPipe = Pipe()
let errorPipe = Pipe()
if ProcessInfo.processInfo.environment["SLOW_SHELL_MODE"] != nil {
sleep(3)
}
task.standardOutput = outputPipe
task.standardError = errorPipe
task.launch()
task.waitUntilExit()
let stdOut = String(data: outputPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)!
let stdErr = String(data: errorPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)!
if Log.shared.verbosity == .cli {
log(task: task, stdOut: stdOut, stdErr: stdErr)
}
return .out(stdOut, stdErr)
}
func pipe(_ command: String) async -> ShellOutput { func pipe(_ command: String) async -> ShellOutput {
let task = getShellProcess(for: command) let task = getShellProcess(for: command)
let outputPipe = Pipe() let outputPipe = Pipe()
let errorPipe = Pipe() let errorPipe = Pipe()
// Seriously slow down how long it takes for the shell to return output
// (in order to debug or identify async issues)
if ProcessInfo.processInfo.environment["SLOW_SHELL_MODE"] != nil { if ProcessInfo.processInfo.environment["SLOW_SHELL_MODE"] != nil {
Log.info("[SLOW SHELL] \(command)") Log.info("[SLOW SHELL] \(command)")
await delay(seconds: 3.0) await delay(seconds: 3.0)
@ -104,20 +127,20 @@ class RealShell: ShellProtocol {
task.launch() task.launch()
task.waitUntilExit() task.waitUntilExit()
let stdOut = String( let stdOut = String(data: outputPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)!
data: outputPipe.fileHandleForReading.readDataToEndOfFile(), let stdErr = String(data: errorPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)!
encoding: .utf8
)!
let stdErr = String(
data: errorPipe.fileHandleForReading.readDataToEndOfFile(),
encoding: .utf8
)!
if Log.shared.verbosity == .cli { if Log.shared.verbosity == .cli {
var args = task.arguments ?? [] log(task: task, stdOut: stdOut, stdErr: stdErr)
let last = "\"" + (args.popLast() ?? "") + "\"" }
var log = """
return .out(stdOut, stdErr)
}
private func log(task: Process, stdOut: String, stdErr: String) {
var args = task.arguments ?? []
let last = "\"" + (args.popLast() ?? "") + "\""
var log = """
<~~~~~~~~~~~~~~~~~~~~~~~ <~~~~~~~~~~~~~~~~~~~~~~~
$ \(([self.launchPath] + args + [last]).joined(separator: " ")) $ \(([self.launchPath] + args + [last]).joined(separator: " "))
@ -126,22 +149,19 @@ class RealShell: ShellProtocol {
\(stdOut) \(stdOut)
""" """
if !stdErr.isEmpty { if !stdErr.isEmpty {
log.append(""" log.append("""
[ERR]: [ERR]:
\(stdErr) \(stdErr)
""") """)
} }
log.append(""" log.append("""
~~~~~~~~~~~~~~~~~~~~~~~~> ~~~~~~~~~~~~~~~~~~~~~~~~>
""") """)
Log.info(log) Log.info(log)
}
return .out(stdOut, stdErr)
} }
func quiet(_ command: String) async { func quiet(_ command: String) async {

View File

@ -14,6 +14,16 @@ protocol ShellProtocol {
*/ */
var PATH: String { get } var PATH: String { get }
/**
Run a command synchronously. Use with caution.
Common usage:
```
let output = Shell.sync("php -v")
```
*/
func sync(_ command: String) -> ShellOutput
/** /**
Run a command asynchronously. Run a command asynchronously.
Returns the most relevant output (prefers error output if it exists). Returns the most relevant output (prefers error output if it exists).

View File

@ -19,6 +19,17 @@ public class TestableShell: ShellProtocol {
var expectations: [String: BatchFakeShellOutput] = [:] var expectations: [String: BatchFakeShellOutput] = [:]
func sync(_ command: String) -> ShellOutput {
// This assertion will only fire during test builds
assert(expectations.keys.contains(command), "No response declared for command: \(command)")
guard let expectation = expectations[command] else {
return .err("No Expected Output")
}
return expectation.syncOutput()
}
func quiet(_ command: String) async { func quiet(_ command: String) async {
_ = try! await self.attach(command, didReceiveOutput: { _, _ in }, withTimeout: 60) _ = try! await self.attach(command, didReceiveOutput: { _, _ in }, withTimeout: 60)
} }
@ -112,6 +123,29 @@ struct BatchFakeShellOutput: Codable {
return output return output
} }
/**
Outputs the fake shell output as expected, but does this synchronously.
*/
public func syncOutput(
ignoreDelay: Bool = false
) -> ShellOutput {
let output = ShellOutput.empty()
for item in items {
if !ignoreDelay {
sleep(UInt32(item.delay))
}
if item.stream == .stdErr {
output.err += item.output
} else if item.stream == .stdOut {
output.out += item.output
}
}
return output
}
/** /**
For testing purposes (and speed) we may omit the delay, regardless of its timespan. For testing purposes (and speed) we may omit the delay, regardless of its timespan.
*/ */

View File

@ -142,7 +142,7 @@ class Startup {
return await Shell.pipe("\(Paths.binPath)/php -v").err return await Shell.pipe("\(Paths.binPath)/php -v").err
.contains("Library not loaded") .contains("Library not loaded")
}, },
name: "`no dyld issue detected", name: "no `dyld` issue (`Library not loaded`) detected",
titleText: "startup.errors.dyld_library.title".localized, titleText: "startup.errors.dyld_library.title".localized,
subtitleText: "startup.errors.dyld_library.subtitle".localized( subtitleText: "startup.errors.dyld_library.subtitle".localized(
Paths.optPath Paths.optPath

View File

@ -21,8 +21,15 @@ class ModifyPhpVersionCommand: BrewCommand {
/** /**
You can pass in which PHP versions need to be upgraded and which ones need to be installed. You can pass in which PHP versions need to be upgraded and which ones need to be installed.
The process will be executed in two steps: first upgrades, then installations. The process will be executed in two steps: first upgrades, then installations.
Upgrades come first because... well, otherwise installations may very well break. Upgrades come first because... well, otherwise installations may very well break.
Each version that is installed will need to be checked afterwards (if it is OK). Each version that is installed will need to be checked afterwards. Installing a
newer formula may break other PHP installations, which in turn need to be fixed.
- Important: If any PHP formula is a major upgrade that causes a PHP "version" to be
uninstalled, this is remedied by running `upgradeMainPhpFormula()`. This process
will ensure that the upgrade is applied, but the also that old version is
re-installed and linked again.
*/ */
public init( public init(
title: String, title: String,
@ -44,6 +51,8 @@ class ModifyPhpVersionCommand: BrewCommand {
description: "PHP Monitor is preparing Homebrew..." description: "PHP Monitor is preparing Homebrew..."
)) ))
// Determine if a formula will become unavailable
// This is the case when `php` will be bumped to a new version
let unavailable = upgrading.first(where: { formula in let unavailable = upgrading.first(where: { formula in
formula.unavailableAfterUpgrade formula.unavailableAfterUpgrade
}) })
@ -71,10 +80,7 @@ class ModifyPhpVersionCommand: BrewCommand {
await self.completedOperations(onProgress) await self.completedOperations(onProgress)
} }
private func upgradeMainPhpFormula( private func upgradeMainPhpFormula(_ unavailable: BrewPhpFormula, _ onProgress: @escaping (BrewCommandProgress) -> Void) async throws {
_ unavailable: BrewPhpFormula,
_ onProgress: @escaping (BrewCommandProgress) -> Void
) async throws {
// Determine which version was previously available (that will become unavailable) // Determine which version was previously available (that will become unavailable)
guard let short = try? VersionNumber guard let short = try? VersionNumber
.parse(unavailable.installedVersion!).short else { .parse(unavailable.installedVersion!).short else {
@ -92,18 +98,6 @@ class ModifyPhpVersionCommand: BrewCommand {
try await run(command, onProgress) try await run(command, onProgress)
} }
private func checkPhpTap(_ onProgress: @escaping (BrewCommandProgress) -> Void) async throws {
if !BrewDiagnostics.installedTaps.contains("shivammathur/php") {
let command = "brew tap shivammathur/php"
try await run(command, onProgress)
}
if !BrewDiagnostics.installedTaps.contains("shivammathur/extensions") {
let command = "brew tap shivammathur/extensions"
try await run(command, onProgress)
}
}
private func upgradePackages(_ onProgress: @escaping (BrewCommandProgress) -> Void) async throws { private func upgradePackages(_ onProgress: @escaping (BrewCommandProgress) -> Void) async throws {
// If no upgrades are needed, early exit // If no upgrades are needed, early exit
if self.upgrading.isEmpty { if self.upgrading.isEmpty {