Fix cmux omo bootstrap with yanked deps (#2280)

This commit is contained in:
Austin Wang 2026-03-28 01:08:35 -07:00 committed by GitHub
parent 97fee253b5
commit f0c3ccc314
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 243 additions and 58 deletions

View file

@ -9688,6 +9688,163 @@ struct CMUXCLI {
.appendingPathComponent("omo-config", isDirectory: true)
}
private func omoFileType(at url: URL) -> FileAttributeType? {
let attrs = try? FileManager.default.attributesOfItem(atPath: url.path)
return attrs?[.type] as? FileAttributeType
}
private func omoEnsureShadowPackageManifest(at shadowPackageURL: URL) throws {
let fm = FileManager.default
if omoFileType(at: shadowPackageURL) == .typeSymbolicLink {
try? fm.removeItem(at: shadowPackageURL)
}
// Keep the shadow package isolated from stale/yanked pins in the user's
// opencode package.json. bun will update this manifest with the resolved
// oh-my-opencode version when installation succeeds.
let packageManifest: [String: Any] = [
"dependencies": [
Self.omoPluginName: "latest"
],
"name": "cmux-omo-shadow",
"private": true
]
let output = try JSONSerialization.data(withJSONObject: packageManifest, options: [.prettyPrinted, .sortedKeys])
let existing = try? Data(contentsOf: shadowPackageURL)
if existing != output {
try output.write(to: shadowPackageURL, options: .atomic)
}
}
private func omoEnsureShadowNodeModulesSymlink(
shadowNodeModules: URL,
userNodeModules: URL
) throws {
let fm = FileManager.default
guard fm.fileExists(atPath: userNodeModules.path) else { return }
if let type = omoFileType(at: shadowNodeModules) {
if type == .typeSymbolicLink {
let target = try? fm.destinationOfSymbolicLink(atPath: shadowNodeModules.path)
if target != userNodeModules.path {
try? fm.removeItem(at: shadowNodeModules)
} else {
return
}
} else {
return
}
}
if !fm.fileExists(atPath: shadowNodeModules.path) {
try fm.createSymbolicLink(at: shadowNodeModules, withDestinationURL: userNodeModules)
}
}
private func omoRunPackageInstall(
executablePath: String,
arguments: [String],
currentDirectoryURL: URL
) throws -> Int32 {
let process = Process()
process.currentDirectoryURL = currentDirectoryURL
process.executableURL = URL(fileURLWithPath: executablePath)
process.arguments = arguments
process.standardOutput = FileHandle.standardError
process.standardError = FileHandle.standardError
try process.run()
process.waitUntilExit()
return process.terminationStatus
}
private func omoRequestedPort(from commandArgs: [String]) -> String? {
for (index, arg) in commandArgs.enumerated() {
if arg == "--port" {
let nextIndex = commandArgs.index(after: index)
guard nextIndex < commandArgs.endIndex else { return nil }
let value = commandArgs[nextIndex].trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
if arg.hasPrefix("--port=") {
let value = String(arg.dropFirst("--port=".count)).trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}
return nil
}
private func omoBindableLoopbackPort(_ port: UInt16) -> UInt16? {
let socketDescriptor = socket(AF_INET, SOCK_STREAM, 0)
guard socketDescriptor >= 0 else { return nil }
defer { close(socketDescriptor) }
var reuseAddress: Int32 = 1
_ = setsockopt(
socketDescriptor,
SOL_SOCKET,
SO_REUSEADDR,
&reuseAddress,
socklen_t(MemoryLayout<Int32>.size)
)
var address = sockaddr_in()
address.sin_len = UInt8(MemoryLayout<sockaddr_in>.stride)
address.sin_family = sa_family_t(AF_INET)
address.sin_port = port.bigEndian
address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
let bindResult = withUnsafePointer(to: &address) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
Darwin.bind(socketDescriptor, $0, socklen_t(MemoryLayout<sockaddr_in>.stride))
}
}
guard bindResult == 0 else { return nil }
if port != 0 {
return port
}
var boundAddress = address
var boundAddressLength = socklen_t(MemoryLayout<sockaddr_in>.stride)
let nameResult = withUnsafeMutablePointer(to: &boundAddress) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
getsockname(socketDescriptor, $0, &boundAddressLength)
}
}
guard nameResult == 0 else { return nil }
return UInt16(bigEndian: boundAddress.sin_port)
}
private func omoResolvedPort(
commandArgs: [String],
processEnvironment: [String: String]
) -> String {
if let requestedPort = omoRequestedPort(from: commandArgs) {
return requestedPort
}
if let environmentPort = processEnvironment["OPENCODE_PORT"]?
.trimmingCharacters(in: .whitespacesAndNewlines),
let parsedEnvironmentPort = UInt16(environmentPort),
parsedEnvironmentPort != 0,
omoBindableLoopbackPort(parsedEnvironmentPort) != nil {
return environmentPort
}
if let preferredPort = omoBindableLoopbackPort(4096) {
return String(preferredPort)
}
if let fallbackPort = omoBindableLoopbackPort(0) {
return String(fallbackPort)
}
return "4096"
}
/// Creates a shadow config directory that layers oh-my-opencode on top of the user's
/// existing opencode config without modifying the original. Sets OPENCODE_CONFIG_DIR
/// to point at the shadow directory.
@ -9727,27 +9884,15 @@ struct CMUXCLI {
// Symlink node_modules from the user's config dir so installed packages resolve
let shadowNodeModules = shadowDir.appendingPathComponent("node_modules")
let userNodeModules = userDir.appendingPathComponent("node_modules")
if fm.fileExists(atPath: userNodeModules.path) {
// Remove stale symlink or directory if it exists
if let attrs = try? fm.attributesOfItem(atPath: shadowNodeModules.path),
attrs[.type] as? FileAttributeType == .typeSymbolicLink {
let target = try? fm.destinationOfSymbolicLink(atPath: shadowNodeModules.path)
if target != userNodeModules.path {
try? fm.removeItem(at: shadowNodeModules)
}
}
if !fm.fileExists(atPath: shadowNodeModules.path) {
try fm.createSymbolicLink(at: shadowNodeModules, withDestinationURL: userNodeModules)
}
}
try omoEnsureShadowNodeModulesSymlink(shadowNodeModules: shadowNodeModules, userNodeModules: userNodeModules)
// Symlink package.json and bun.lock so bun/npm can resolve in the shadow dir
for filename in ["package.json", "bun.lock"] {
let userFile = userDir.appendingPathComponent(filename)
let shadowFile = shadowDir.appendingPathComponent(filename)
if fm.fileExists(atPath: userFile.path) && !fm.fileExists(atPath: shadowFile.path) {
try fm.createSymbolicLink(at: shadowFile, withDestinationURL: userFile)
}
// The shadow config owns its own package metadata so yanked/stale pins in the
// user's opencode package.json/bun.lock cannot poison plugin installation.
let shadowPackageURL = shadowDir.appendingPathComponent("package.json")
let shadowBunLockURL = shadowDir.appendingPathComponent("bun.lock")
try omoEnsureShadowPackageManifest(at: shadowPackageURL)
if omoFileType(at: shadowBunLockURL) == .typeSymbolicLink {
try? fm.removeItem(at: shadowBunLockURL)
}
// Copy oh-my-opencode plugin config (jsonc) if the user has one
@ -9762,37 +9907,43 @@ struct CMUXCLI {
// Install the package if not available via the symlinked node_modules
let pluginPackageDir = shadowNodeModules.appendingPathComponent(Self.omoPluginName)
if !fm.fileExists(atPath: pluginPackageDir.path) {
// Need to install into the real user config dir so the symlink picks it up
let installDir = fm.fileExists(atPath: userNodeModules.path) ? userDir : shadowDir
// If installing into shadow dir, remove the symlink first
if installDir == shadowDir {
try? fm.removeItem(at: shadowNodeModules)
}
let process = Process()
process.currentDirectoryURL = installDir
let installDir = shadowDir
if let bunPath = resolveExecutableInPath("bun") {
process.executableURL = URL(fileURLWithPath: bunPath)
process.arguments = ["add", Self.omoPluginName]
FileHandle.standardError.write("Installing oh-my-opencode plugin (this may take a minute on first run)...\n".data(using: .utf8)!)
let installArguments = ["add", Self.omoPluginName]
let firstAttemptStatus = try omoRunPackageInstall(
executablePath: bunPath,
arguments: installArguments,
currentDirectoryURL: installDir
)
if firstAttemptStatus != 0 {
FileHandle.standardError.write("Retrying oh-my-opencode install with a clean shadow package state...\n".data(using: .utf8)!)
try? fm.removeItem(at: shadowBunLockURL)
try? fm.removeItem(at: shadowNodeModules)
try omoEnsureShadowNodeModulesSymlink(shadowNodeModules: shadowNodeModules, userNodeModules: userNodeModules)
let retryStatus = try omoRunPackageInstall(
executablePath: bunPath,
arguments: installArguments,
currentDirectoryURL: installDir
)
if retryStatus != 0 {
throw CLIError(message: "Failed to install oh-my-opencode. Try manually: npm install -g oh-my-opencode")
}
}
} else if let npmPath = resolveExecutableInPath("npm") {
process.executableURL = URL(fileURLWithPath: npmPath)
process.arguments = ["install", Self.omoPluginName]
FileHandle.standardError.write("Installing oh-my-opencode plugin (this may take a minute on first run)...\n".data(using: .utf8)!)
let status = try omoRunPackageInstall(
executablePath: npmPath,
arguments: ["install", Self.omoPluginName],
currentDirectoryURL: installDir
)
if status != 0 {
throw CLIError(message: "Failed to install oh-my-opencode. Try manually: npm install -g oh-my-opencode")
}
} else {
throw CLIError(message: "Neither bun nor npm found in PATH. Install oh-my-opencode manually: bunx oh-my-opencode install")
}
// Show install output directly so the user sees progress (npm can take 30s+)
process.standardOutput = FileHandle.standardError
process.standardError = FileHandle.standardError
FileHandle.standardError.write("Installing oh-my-opencode plugin (this may take a minute on first run)...\n".data(using: .utf8)!)
try process.run()
process.waitUntilExit()
if process.terminationStatus != 0 {
throw CLIError(message: "Failed to install oh-my-opencode. Try manually: npm install -g oh-my-opencode")
}
FileHandle.standardError.write("oh-my-opencode plugin installed\n".data(using: .utf8)!)
// Re-create symlink if we installed into user dir
if installDir == userDir && !fm.fileExists(atPath: shadowNodeModules.path) {
try fm.createSymbolicLink(at: shadowNodeModules, withDestinationURL: userNodeModules)
}
}
// Ensure tmux mode is enabled in oh-my-opencode config.
@ -9857,13 +10008,9 @@ struct CMUXCLI {
executablePath: String,
socketPath: String,
explicitPassword: String?,
focusedContext: TmuxCompatFocusedContext?
focusedContext: TmuxCompatFocusedContext?,
openCodePort: String
) {
// Tell oh-my-opencode the API server port so subagent attach works
var extraEnvVars: [(key: String, value: String)] = []
if processEnvironment["OPENCODE_PORT"] == nil {
extraEnvVars.append((key: "OPENCODE_PORT", value: "4096"))
}
configureTmuxCompatEnvironment(
processEnvironment: processEnvironment,
shimDirectory: shimDirectory,
@ -9874,7 +10021,7 @@ struct CMUXCLI {
tmuxPathPrefix: "cmux-omo",
cmuxBinEnvVar: "CMUX_OMO_CMUX_BIN",
termOverrideEnvVar: "CMUX_OMO_TERM",
extraEnvVars: extraEnvVars
extraEnvVars: [(key: "OPENCODE_PORT", value: openCodePort)]
)
}
@ -9916,23 +10063,29 @@ struct CMUXCLI {
processEnvironment: launcherEnvironment,
explicitPassword: explicitPassword
)
let openCodePort = omoResolvedPort(
commandArgs: commandArgs,
processEnvironment: launcherEnvironment
)
launcherEnvironment["OPENCODE_PORT"] = openCodePort
configureOMOEnvironment(
processEnvironment: launcherEnvironment,
shimDirectory: shimDirectory,
executablePath: executablePath,
socketPath: socketPath,
explicitPassword: explicitPassword,
focusedContext: focusedContext
focusedContext: focusedContext,
openCodePort: openCodePort
)
let launchPath = openCodeExecutablePath ?? "opencode"
// oh-my-openagent needs the OpenCode API server running to attach
// subagent sessions to tmux panes. Inject --port 4096 unless the
// user already specified a port.
// subagent sessions to tmux panes. Prefer the historic default port
// when it is available, otherwise fall back to a free loopback port.
var effectiveArgs = commandArgs
if !commandArgs.contains("--port") {
if omoRequestedPort(from: commandArgs) == nil {
effectiveArgs.append("--port")
effectiveArgs.append("4096")
effectiveArgs.append(openCodePort)
}
var argv = ([launchPath] + effectiveArgs).map { strdup($0) }
defer {

View file

@ -14,6 +14,27 @@ CMUX_DEBUG_LOG=""
CLI_PATH=""
LAST_SOCKET_PATH_DIR="$HOME/Library/Application Support/cmux"
LAST_SOCKET_PATH_FILE="${LAST_SOCKET_PATH_DIR}/last-socket-path"
AUTO_SKIP_ZIG_BUILD_REASON=""
should_skip_ghostty_cli_helper_zig_build() {
if [[ "${CMUX_SKIP_ZIG_BUILD:-}" == "1" ]]; then
AUTO_SKIP_ZIG_BUILD_REASON="CMUX_SKIP_ZIG_BUILD=1"
return 0
fi
local product_version zig_version major_version
product_version="$(sw_vers -productVersion 2>/dev/null || true)"
zig_version="$(zig version 2>/dev/null || true)"
major_version="${product_version%%.*}"
if [[ "$zig_version" == "0.15.2" ]] && [[ "$major_version" =~ ^[0-9]+$ ]] && (( major_version >= 26 )); then
AUTO_SKIP_ZIG_BUILD_REASON="macOS ${product_version} + zig ${zig_version}"
return 0
fi
AUTO_SKIP_ZIG_BUILD_REASON=""
return 1
}
write_dev_cli_shim() {
local target="$1"
@ -258,6 +279,13 @@ if [[ -z "$TAG" ]]; then
exit 1
fi
if should_skip_ghostty_cli_helper_zig_build; then
if [[ "${CMUX_SKIP_ZIG_BUILD:-}" != "1" ]]; then
echo "Auto-enabling CMUX_SKIP_ZIG_BUILD=1 for Ghostty CLI helper (${AUTO_SKIP_ZIG_BUILD_REASON})"
fi
export CMUX_SKIP_ZIG_BUILD=1
fi
if [[ -n "$TAG" ]]; then
TAG_ID="$(sanitize_bundle "$TAG")"
TAG_SLUG="$(sanitize_path "$TAG")"
@ -427,8 +455,12 @@ if [[ -d "$PWD/cmuxd" ]]; then
(cd "$PWD/cmuxd" && zig build -Doptimize=ReleaseFast)
fi
if [[ -d "$PWD/ghostty" ]]; then
if [[ "${CMUX_SKIP_ZIG_BUILD:-}" == "1" ]]; then
echo "Skipping direct ghostty CLI helper zig build (CMUX_SKIP_ZIG_BUILD=1)"
else
(cd "$PWD/ghostty" && zig build cli-helper -Dapp-runtime=none -Demit-macos-app=false -Demit-xcframework=false -Doptimize=ReleaseFast)
fi
fi
if [[ -x "$CMUXD_SRC" ]]; then
BIN_DIR="$APP_PATH/Contents/Resources/bin"
mkdir -p "$BIN_DIR"