Merge pull request #298 from manaflow-ai/task-cmux-version-command
Add cmux --version command with regression test
This commit is contained in:
commit
57efdbd254
2 changed files with 290 additions and 1 deletions
204
CLI/cmux.swift
204
CLI/cmux.swift
|
|
@ -435,6 +435,10 @@ struct CMUXCLI {
|
|||
index += 2
|
||||
continue
|
||||
}
|
||||
if arg == "-v" || arg == "--version" {
|
||||
print(versionSummary())
|
||||
return
|
||||
}
|
||||
if arg == "-h" || arg == "--help" {
|
||||
print(usage())
|
||||
return
|
||||
|
|
@ -450,6 +454,11 @@ struct CMUXCLI {
|
|||
let command = args[index]
|
||||
let commandArgs = Array(args[(index + 1)...])
|
||||
|
||||
if command == "version" {
|
||||
print(versionSummary())
|
||||
return
|
||||
}
|
||||
|
||||
// Check for --help/-h on subcommands before connecting to the socket,
|
||||
// so help text is available even when cmux is not running.
|
||||
if commandArgs.contains("--help") || commandArgs.contains("-h") {
|
||||
|
|
@ -4393,12 +4402,204 @@ struct CMUXCLI {
|
|||
return truncate(normalized, maxLength: 180)
|
||||
}
|
||||
|
||||
private func versionSummary() -> String {
|
||||
let info = resolvedVersionInfo()
|
||||
if let version = info["CFBundleShortVersionString"], let build = info["CFBundleVersion"] {
|
||||
return "cmux \(version) (\(build))"
|
||||
}
|
||||
if let version = info["CFBundleShortVersionString"] {
|
||||
return "cmux \(version)"
|
||||
}
|
||||
if let build = info["CFBundleVersion"] {
|
||||
return "cmux build \(build)"
|
||||
}
|
||||
return "cmux version unknown"
|
||||
}
|
||||
|
||||
private func resolvedVersionInfo() -> [String: String] {
|
||||
if let main = versionInfo(from: Bundle.main.infoDictionary) {
|
||||
return main
|
||||
}
|
||||
|
||||
for plistURL in candidateInfoPlistURLs() {
|
||||
guard let data = try? Data(contentsOf: plistURL),
|
||||
let raw = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil),
|
||||
let dictionary = raw as? [String: Any],
|
||||
let parsed = versionInfo(from: dictionary)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
if let fromProject = versionInfoFromProjectFile() {
|
||||
return fromProject
|
||||
}
|
||||
|
||||
return [:]
|
||||
}
|
||||
|
||||
private func versionInfo(from dictionary: [String: Any]?) -> [String: String]? {
|
||||
guard let dictionary else { return nil }
|
||||
|
||||
var info: [String: String] = [:]
|
||||
if let version = dictionary["CFBundleShortVersionString"] as? String {
|
||||
let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty && !trimmed.contains("$(") {
|
||||
info["CFBundleShortVersionString"] = trimmed
|
||||
}
|
||||
}
|
||||
if let build = dictionary["CFBundleVersion"] as? String {
|
||||
let trimmed = build.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty && !trimmed.contains("$(") {
|
||||
info["CFBundleVersion"] = trimmed
|
||||
}
|
||||
}
|
||||
return info.isEmpty ? nil : info
|
||||
}
|
||||
|
||||
private func versionInfoFromProjectFile() -> [String: String]? {
|
||||
guard let executable = currentExecutablePath(), !executable.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let fileManager = FileManager.default
|
||||
var current = URL(fileURLWithPath: executable)
|
||||
.resolvingSymlinksInPath()
|
||||
.standardizedFileURL
|
||||
.deletingLastPathComponent()
|
||||
|
||||
while true {
|
||||
let projectFile = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj")
|
||||
if fileManager.fileExists(atPath: projectFile.path),
|
||||
let contents = try? String(contentsOf: projectFile, encoding: .utf8) {
|
||||
var info: [String: String] = [:]
|
||||
if let version = firstProjectSetting("MARKETING_VERSION", in: contents) {
|
||||
info["CFBundleShortVersionString"] = version
|
||||
}
|
||||
if let build = firstProjectSetting("CURRENT_PROJECT_VERSION", in: contents) {
|
||||
info["CFBundleVersion"] = build
|
||||
}
|
||||
if !info.isEmpty {
|
||||
return info
|
||||
}
|
||||
}
|
||||
|
||||
let parent = current.deletingLastPathComponent()
|
||||
if parent.path == current.path {
|
||||
break
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func firstProjectSetting(_ key: String, in source: String) -> String? {
|
||||
let pattern = NSRegularExpression.escapedPattern(for: key) + "\\s*=\\s*([^;]+);"
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern) else {
|
||||
return nil
|
||||
}
|
||||
let searchRange = NSRange(source.startIndex..<source.endIndex, in: source)
|
||||
guard let match = regex.firstMatch(in: source, options: [], range: searchRange),
|
||||
match.numberOfRanges > 1,
|
||||
let valueRange = Range(match.range(at: 1), in: source)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let value = source[valueRange]
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||
guard !value.isEmpty, !value.contains("$(") else {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private func candidateInfoPlistURLs() -> [URL] {
|
||||
guard let executable = currentExecutablePath(), !executable.isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
let fileManager = FileManager.default
|
||||
let executableURL = URL(fileURLWithPath: executable)
|
||||
.resolvingSymlinksInPath()
|
||||
.standardizedFileURL
|
||||
|
||||
var candidates: [URL] = []
|
||||
var current = executableURL.deletingLastPathComponent()
|
||||
while true {
|
||||
if current.pathExtension == "app" {
|
||||
candidates.append(current.appendingPathComponent("Contents/Info.plist"))
|
||||
}
|
||||
if current.lastPathComponent == "Contents" {
|
||||
candidates.append(current.appendingPathComponent("Info.plist"))
|
||||
}
|
||||
|
||||
// Local dev fallback: resolve version from the repo's app Info.plist
|
||||
// when running a standalone cmux-cli binary from build/Debug.
|
||||
let projectMarker = current.appendingPathComponent("GhosttyTabs.xcodeproj/project.pbxproj")
|
||||
let repoInfo = current.appendingPathComponent("Resources/Info.plist")
|
||||
if fileManager.fileExists(atPath: projectMarker.path),
|
||||
fileManager.fileExists(atPath: repoInfo.path) {
|
||||
candidates.append(repoInfo)
|
||||
break
|
||||
}
|
||||
|
||||
let parent = current.deletingLastPathComponent()
|
||||
if parent.path == current.path {
|
||||
break
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
|
||||
let searchRoots = [
|
||||
executableURL.deletingLastPathComponent(),
|
||||
executableURL.deletingLastPathComponent().deletingLastPathComponent()
|
||||
]
|
||||
for root in searchRoots {
|
||||
guard let entries = try? fileManager.contentsOfDirectory(
|
||||
at: root,
|
||||
includingPropertiesForKeys: [.isDirectoryKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
for entry in entries where entry.pathExtension == "app" {
|
||||
candidates.append(entry.appendingPathComponent("Contents/Info.plist"))
|
||||
}
|
||||
}
|
||||
|
||||
var seen: Set<String> = []
|
||||
return candidates.filter { url in
|
||||
let path = url.path
|
||||
guard !path.isEmpty else { return false }
|
||||
guard seen.insert(path).inserted else { return false }
|
||||
return fileManager.fileExists(atPath: path)
|
||||
}
|
||||
}
|
||||
|
||||
private func currentExecutablePath() -> String? {
|
||||
var size: UInt32 = 0
|
||||
_ = _NSGetExecutablePath(nil, &size)
|
||||
if size > 0 {
|
||||
var buffer = Array<CChar>(repeating: 0, count: Int(size))
|
||||
if _NSGetExecutablePath(&buffer, &size) == 0 {
|
||||
let path = String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !path.isEmpty {
|
||||
return path
|
||||
}
|
||||
}
|
||||
}
|
||||
return Bundle.main.executableURL?.path ?? args.first
|
||||
}
|
||||
|
||||
private func usage() -> String {
|
||||
return """
|
||||
cmux - control cmux via Unix socket
|
||||
|
||||
Usage:
|
||||
cmux [--socket PATH] [--window WINDOW] [--json] [--id-format refs|uuids|both] <command> [options]
|
||||
cmux [--socket PATH] [--window WINDOW] [--json] [--id-format refs|uuids|both] [--version] <command> [options]
|
||||
|
||||
Handle Inputs:
|
||||
For most v2-backed commands you can use UUIDs, short refs (window:1/workspace:2/pane:3/surface:4), or indexes.
|
||||
|
|
@ -4406,6 +4607,7 @@ struct CMUXCLI {
|
|||
Output defaults to refs; pass --id-format uuids or --id-format both to include UUIDs.
|
||||
|
||||
Commands:
|
||||
version
|
||||
ping
|
||||
capabilities
|
||||
identify [--workspace <id|ref|index>] [--surface <id|ref|index>] [--no-caller]
|
||||
|
|
|
|||
87
tests/test_cli_version_flag.py
Normal file
87
tests/test_cli_version_flag.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Regression test: `cmux --version` should print version text without requiring a socket.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
|
||||
def resolve_cmux_cli() -> str:
|
||||
explicit = os.environ.get("CMUX_CLI_BIN") or os.environ.get("CMUX_CLI")
|
||||
if explicit and os.path.exists(explicit) and os.access(explicit, os.X_OK):
|
||||
return explicit
|
||||
|
||||
candidates: list[str] = []
|
||||
candidates.extend(glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/*/Build/Products/Debug/cmux")))
|
||||
candidates.extend(glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux"))
|
||||
candidates = [p for p in candidates if os.path.exists(p) and os.access(p, os.X_OK)]
|
||||
if candidates:
|
||||
candidates.sort(key=os.path.getmtime, reverse=True)
|
||||
return candidates[0]
|
||||
|
||||
in_path = shutil.which("cmux")
|
||||
if in_path:
|
||||
return in_path
|
||||
|
||||
raise RuntimeError("Unable to find cmux CLI binary. Set CMUX_CLI_BIN.")
|
||||
|
||||
|
||||
def run(cli_path: str, *args: str) -> tuple[int, str, str]:
|
||||
proc = subprocess.run(
|
||||
[cli_path, *args],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
cli_path = resolve_cmux_cli()
|
||||
except Exception as exc:
|
||||
print(f"FAIL: {exc}")
|
||||
return 1
|
||||
|
||||
code, out, err = run(cli_path, "--version")
|
||||
if code != 0:
|
||||
print("FAIL: `cmux --version` exited non-zero")
|
||||
print(f"exit={code}")
|
||||
print(f"stdout={out}")
|
||||
print(f"stderr={err}")
|
||||
return 1
|
||||
|
||||
if not out:
|
||||
print("FAIL: `cmux --version` produced empty stdout")
|
||||
return 1
|
||||
|
||||
if not re.search(r"\b\d+\.\d+\.\d+\b", out):
|
||||
print(f"FAIL: version output missing semantic version: {out!r}")
|
||||
return 1
|
||||
|
||||
code2, out2, err2 = run(cli_path, "version")
|
||||
if code2 != 0:
|
||||
print("FAIL: `cmux version` exited non-zero")
|
||||
print(f"exit={code2}")
|
||||
print(f"stdout={out2}")
|
||||
print(f"stderr={err2}")
|
||||
return 1
|
||||
|
||||
if out2 != out:
|
||||
print("FAIL: `cmux --version` and `cmux version` differ")
|
||||
print(f"--version: {out!r}")
|
||||
print(f"version: {out2!r}")
|
||||
return 1
|
||||
|
||||
print(f"PASS: cmux version command works ({out})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue