From 756e6a426f8d31cb07d639e3c0880307b69179d2 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Tue, 29 Sep 2026 23:18:00 +0200 Subject: [PATCH 01/28] Hash through swift-crypto on both platforms - CryptoKit is Apple-only; swift-crypto's Crypto re-exports it on Darwin and builds BoringSSL only off-Darwin, so one import serves both - Pinned known-answer digests guard the seam that .mcs-project state depends on Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu --- Package.swift | 2 ++ Sources/mcs/Core/FileHasher.swift | 7 +++---- Sources/mcs/Core/SettingsHasher.swift | 2 +- Sources/mcs/Doctor/SectionValidator.swift | 2 +- Tests/MCSTests/FileHasherTests.swift | 10 ++++++++++ Tests/MCSTests/SettingsHasherTests.swift | 10 +++++++++- 6 files changed, 26 insertions(+), 7 deletions(-) diff --git a/Package.swift b/Package.swift index 5b21fedf..bfaf641f 100644 --- a/Package.swift +++ b/Package.swift @@ -10,6 +10,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), .package(url: "https://github.com/jpsim/Yams.git", from: "5.0.0"), + .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"), ], targets: [ .executableTarget( @@ -17,6 +18,7 @@ let package = Package( dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Yams", package: "Yams"), + .product(name: "Crypto", package: "swift-crypto"), ], path: "Sources/mcs", swiftSettings: [ diff --git a/Sources/mcs/Core/FileHasher.swift b/Sources/mcs/Core/FileHasher.swift index f6671f0e..dc8e9278 100644 --- a/Sources/mcs/Core/FileHasher.swift +++ b/Sources/mcs/Core/FileHasher.swift @@ -1,9 +1,8 @@ -import CryptoKit +import Crypto import Foundation -/// Pure CryptoKit utilities for SHA-256 file hashing. -/// Extracted from the deleted `Manifest` type — used by `PackTrustManager` -/// for trust verification and by `ComponentExecutor` for directory copies. +/// SHA-256 file hashing utilities, used by `PackTrustManager` for trust verification and by +/// `ComponentExecutor` for directory copies. enum FileHasher { /// Compute SHA-256 hash of a file. static func sha256(of url: URL) throws -> String { diff --git a/Sources/mcs/Core/SettingsHasher.swift b/Sources/mcs/Core/SettingsHasher.swift index 28d5fd3f..9c290f7a 100644 --- a/Sources/mcs/Core/SettingsHasher.swift +++ b/Sources/mcs/Core/SettingsHasher.swift @@ -1,4 +1,4 @@ -import CryptoKit +import Crypto import Foundation /// Deterministic SHA-256 hashing of pack-contributed settings values. diff --git a/Sources/mcs/Doctor/SectionValidator.swift b/Sources/mcs/Doctor/SectionValidator.swift index dabcfccd..6d652d79 100644 --- a/Sources/mcs/Doctor/SectionValidator.swift +++ b/Sources/mcs/Doctor/SectionValidator.swift @@ -1,4 +1,4 @@ -import CryptoKit +import Crypto import Foundation /// Validates managed sections in composed template files. diff --git a/Tests/MCSTests/FileHasherTests.swift b/Tests/MCSTests/FileHasherTests.swift index f04b8cf7..ac23b833 100644 --- a/Tests/MCSTests/FileHasherTests.swift +++ b/Tests/MCSTests/FileHasherTests.swift @@ -25,6 +25,16 @@ struct FileHasherTests { #expect(hash == expected) } + @Test("sha256(data:) matches the NIST known answer") + func knownAnswerForData() { + // Pins the digest across CryptoKit (Darwin) and swift-crypto (Linux): a hash recorded in + // .mcs-project state on one platform has to validate on the other. + #expect( + FileHasher.sha256(data: Data("abc".utf8)) + == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ) + } + @Test("directoryFileHashes returns sorted entries for all files") func directoryHashes() throws { let tmpDir = try makeTmpDir() diff --git a/Tests/MCSTests/SettingsHasherTests.swift b/Tests/MCSTests/SettingsHasherTests.swift index 18ffe664..9bfe1e6e 100644 --- a/Tests/MCSTests/SettingsHasherTests.swift +++ b/Tests/MCSTests/SettingsHasherTests.swift @@ -1,4 +1,4 @@ -import CryptoKit +import Crypto import Foundation @testable import mcs import Testing @@ -11,6 +11,14 @@ struct SettingsHasherTests { #expect(result == nil) } + @Test("the canonical form hashes to a pinned digest") + func pinnedDigest() { + // Pinned so the digest cannot drift between CryptoKit and swift-crypto — see + // FileHasherTests.knownAnswerForData. + let hash = SettingsHasher.hash(keyPaths: ["alwaysThinkingEnabled"], in: ["alwaysThinkingEnabled": true]) + #expect(hash == "2bdbc4888ab5234a3b483dfb56870d73b941e36a5fe86d6d5bcd8497b9df73a9") + } + @Test("dotted key path extracts nested value") func dottedKeyPath() { let json: [String: Any] = ["env": ["FOO": "bar"]] From 5e589fa8c8ea6128508030586e49ffc6472520b6 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Tue, 29 Sep 2026 23:18:00 +0200 Subject: [PATCH 02/28] Replace OSAllocatedUnfairLock with a portable Locked value - OSAllocatedUnfairLock is Darwin-only and Synchronization.Mutex is macOS 15+, above this package's floor, so an NSLock-backed box serves both call sites - Raw-mode pickers treat a hangup as No and leave a warning in scrollback; readByte takes the descriptor so the EOF exit is covered by a pipe test Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu --- Sources/mcs/Core/CLIOutput.swift | 51 ++++++++++++++++----- Sources/mcs/Core/Locked.swift | 23 ++++++++++ Sources/mcs/ExternalPack/ScriptRunner.swift | 3 +- Tests/MCSTests/CLIOutputTests.swift | 42 +++++++++++++++++ Tests/MCSTests/LockedTests.swift | 30 ++++++++++++ 5 files changed, 135 insertions(+), 14 deletions(-) create mode 100644 Sources/mcs/Core/Locked.swift create mode 100644 Tests/MCSTests/CLIOutputTests.swift create mode 100644 Tests/MCSTests/LockedTests.swift diff --git a/Sources/mcs/Core/CLIOutput.swift b/Sources/mcs/Core/CLIOutput.swift index c53c6ebc..c7e31e4e 100644 --- a/Sources/mcs/Core/CLIOutput.swift +++ b/Sources/mcs/Core/CLIOutput.swift @@ -1,5 +1,9 @@ import Foundation -import os +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif /// Shared, mutable tally of warnings emitted through a `CLIOutput`. /// @@ -11,7 +15,7 @@ import os /// `Sendable` so `CLIOutput` stays `Sendable` (it's captured in isolated /// closures, e.g. via `ScriptRunner`); the lock supplies that guarantee. final class WarningCounter: Sendable { - private let lock = OSAllocatedUnfairLock(initialState: 0) + private let lock = Locked(0) var count: Int { lock.withLock { $0 } @@ -91,7 +95,7 @@ struct CLIOutput { private var terminalColumns: Int { var ws = winsize() - if ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0, ws.ws_col > 0 { + if ioctl(STDOUT_FILENO, UInt(TIOCGWINSZ), &ws) == 0, ws.ws_col > 0 { return Int(ws.ws_col) } return 80 @@ -267,7 +271,13 @@ struct CLIOutput { renderYesNo(prompt: prompt, selected: selected) while true { - let byte = readByte() + // Unlike the text fallback, this path only runs when stdin *was* a terminal, so + // end of input here is a hangup, not an unattended run — and the default may be + // the destructive answer. No is safe at every call site. + guard let byte = readByte() else { + reportInputClosed() + return false + } switch byte { case 0x0A, 0x0D, 0x20: // Enter or Space — confirm @@ -408,7 +418,10 @@ struct CLIOutput { renderSingleSelectList(title: title, items: items, cursor: cursor) while true { - let byte = readByte() + guard let byte = readByte() else { + reportInputClosed() + return cursor + } switch byte { case 0x0A, 0x0D, 0x20: // Enter or Space — confirm selection @@ -541,7 +554,10 @@ struct CLIOutput { renderInteractiveList(groups: groups, cursor: cursor) while true { - let byte = readByte() + guard let byte = readByte() else { + reportInputClosed() + return collectSelected(from: groups) + } switch byte { case 0x0A, 0x0D: // Enter @@ -685,9 +701,23 @@ struct CLIOutput { write(output) } - private func readByte() -> UInt8 { + /// Leaves a line in scrollback saying why the prompt ended without a key — the pickers return + /// a value either way, and without this the transcript shows only the question. + private func reportInputClosed() { + write("\n") + warn("Input closed before the prompt was answered.") + } + + /// One byte from `fd`, or `nil` at end of input. + /// + /// Optional because the pickers below loop until they recognise a key: on EOF or a read error + /// no key will ever arrive, and a zero byte matches no case, so returning one would spin at + /// 100% CPU until the process is killed. Reachable whenever stdin closes under a raw-mode + /// prompt — a hung-up terminal in a `nohup`/`setsid` wrapper that ignores SIGHUP, for instance. + /// The descriptor is a parameter so the EOF path can be driven from a pipe under test. + func readByte(from fd: Int32 = STDIN_FILENO) -> UInt8? { var byte: UInt8 = 0 - _ = Darwin.read(STDIN_FILENO, &byte, 1) + guard read(fd, &byte, 1) > 0 else { return nil } return byte } @@ -703,10 +733,7 @@ struct CLIOutput { private func withRawTerminal(_ body: () -> T) -> T { var original = termios() tcgetattr(STDIN_FILENO, &original) - var raw = original - raw.c_lflag &= ~UInt(ICANON | ECHO) - raw.c_cc.16 = 1 // VMIN = 1 - raw.c_cc.17 = 0 // VTIME = 0 + var raw = TerminalAttributes.rawMode(from: original) tcsetattr(STDIN_FILENO, TCSANOW, &raw) write("\u{1B}[?25l") defer { diff --git a/Sources/mcs/Core/Locked.swift b/Sources/mcs/Core/Locked.swift new file mode 100644 index 00000000..e1843cb1 --- /dev/null +++ b/Sources/mcs/Core/Locked.swift @@ -0,0 +1,23 @@ +import Foundation + +/// A mutable value guarded by a lock, with the `withLock { $0 }` shape of `OSAllocatedUnfairLock`, +/// which is Darwin-only. `Synchronization.Mutex` would be the modern answer but is macOS 15+, above +/// this package's macOS 13 deployment target. +/// +/// `@unchecked Sendable` is the point of the type rather than a way to quiet the checker: the +/// invariant — every access to `value` happens under `lock` — is real and cannot be expressed to +/// the compiler. +final class Locked: @unchecked Sendable { + private let lock = NSLock() + private var value: Value + + init(_ value: Value) { + self.value = value + } + + func withLock(_ body: (inout Value) throws -> Result) rethrows -> Result { + lock.lock() + defer { lock.unlock() } + return try body(&value) + } +} diff --git a/Sources/mcs/ExternalPack/ScriptRunner.swift b/Sources/mcs/ExternalPack/ScriptRunner.swift index 7f72ed46..2bbf3e5f 100644 --- a/Sources/mcs/ExternalPack/ScriptRunner.swift +++ b/Sources/mcs/ExternalPack/ScriptRunner.swift @@ -1,5 +1,4 @@ import Foundation -import os /// Higher-level wrapper around `ShellRunner` for executing scripts from external packs. /// Adds pack-specific concerns: path containment validation, standard environment @@ -187,7 +186,7 @@ struct ScriptRunner { // Best-effort killer — see `exceededTimeout` for why the verdict does not rely on it. let start = ProcessInfo.processInfo.systemUptime - let killerFired = OSAllocatedUnfairLock(initialState: false) + let killerFired = Locked(false) let workItem = DispatchWorkItem { [process] in if process.isRunning { killerFired.withLock { $0 = true } diff --git a/Tests/MCSTests/CLIOutputTests.swift b/Tests/MCSTests/CLIOutputTests.swift new file mode 100644 index 00000000..ef7c24df --- /dev/null +++ b/Tests/MCSTests/CLIOutputTests.swift @@ -0,0 +1,42 @@ +import Foundation +@testable import mcs +import Testing + +struct CLIOutputTests { + private func makePipe() -> (read: Int32, write: Int32) { + var fds: [Int32] = [0, 0] + #expect(pipe(&fds) == 0) + return (fds[0], fds[1]) + } + + @Test("readByte returns the byte that was written") + func readByteReturnsWrittenByte() { + let (readEnd, writeEnd) = makePipe() + defer { close(readEnd) } + + var byte: UInt8 = 0x6A + #expect(write(writeEnd, &byte, 1) == 1) + close(writeEnd) + + #expect(CLIOutput(colorsEnabled: false).readByte(from: readEnd) == 0x6A) + } + + @Test("readByte returns nil once the writer has closed, so a picker can exit instead of spinning") + func readByteReturnsNilAtEOF() { + let (readEnd, writeEnd) = makePipe() + defer { close(readEnd) } + close(writeEnd) + + #expect(CLIOutput(colorsEnabled: false).readByte(from: readEnd) == nil) + } + + @Test("readByte returns nil for a descriptor that cannot be read") + func readByteReturnsNilOnReadError() { + let (readEnd, writeEnd) = makePipe() + close(readEnd) + defer { close(writeEnd) } + + // Reading the write end fails with EBADF; the caller must see that as "no key coming". + #expect(CLIOutput(colorsEnabled: false).readByte(from: writeEnd) == nil) + } +} diff --git a/Tests/MCSTests/LockedTests.swift b/Tests/MCSTests/LockedTests.swift new file mode 100644 index 00000000..5acbf0e7 --- /dev/null +++ b/Tests/MCSTests/LockedTests.swift @@ -0,0 +1,30 @@ +import Foundation +@testable import mcs +import Testing + +struct LockedTests { + @Test("withLock returns the body's value and the mutation is visible to the next access") + func returnsBodyValueAndPersistsMutation() { + let locked = Locked(1) + + let doubled = locked.withLock { value -> Int in + value *= 2 + return value + } + + #expect(doubled == 2) + #expect(locked.withLock { $0 } == 2) + } + + @Test("A box captured by a closure is the same box the caller reads") + func mutationThroughACaptureIsVisibleToTheOwner() { + // The shape ScriptRunner relies on: a flag set inside a DispatchWorkItem and read back on + // the calling thread after the work item has finished. + let flag = Locked(false) + let setter = { flag.withLock { $0 = true } } + + setter() + + #expect(flag.withLock { $0 }) + } +} From 3e39dbc535062c95cef5bed197e62c02f23b02c5 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Tue, 29 Sep 2026 23:18:13 +0200 Subject: [PATCH 03/28] Build the terminal and process layer on Glibc as well as Darwin - termios c_cc is indexed through the platform's VMIN/VTIME rather than literals - The PTY bridge's poll decisions move to a pure PTYBridge so the /dev/null-stdin spin and drain-before-drop are table-tested; O_CLOEXEC stops a child holding the process lock after mcs exits - A bridge that fails before exec now reports why instead of "see output above" Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu --- Sources/mcs/Core/FileLock.swift | 10 ++- Sources/mcs/Core/GlobMatcher.swift | 6 +- Sources/mcs/Core/PTYBridge.swift | 46 ++++++++++++ Sources/mcs/Core/ShellRunner.swift | 73 ++++++++++++++++--- Sources/mcs/Core/TerminalAttributes.swift | 38 ++++++++++ Sources/mcs/Sync/GlobalSyncStrategy.swift | 2 +- Sources/mcs/Sync/ProjectSyncStrategy.swift | 2 +- Tests/MCSTests/FileLockTests.swift | 39 ++++++++++ .../MCSTests/LifecycleIntegrationTests.swift | 54 ++++++++++++-- Tests/MCSTests/PTYBridgeTests.swift | 47 ++++++++++++ Tests/MCSTests/ShellRunnerTests.swift | 12 +++ Tests/MCSTests/TerminalAttributesTests.swift | 32 ++++++++ 12 files changed, 338 insertions(+), 23 deletions(-) create mode 100644 Sources/mcs/Core/PTYBridge.swift create mode 100644 Sources/mcs/Core/TerminalAttributes.swift create mode 100644 Tests/MCSTests/PTYBridgeTests.swift create mode 100644 Tests/MCSTests/TerminalAttributesTests.swift diff --git a/Sources/mcs/Core/FileLock.swift b/Sources/mcs/Core/FileLock.swift index 43a1886f..731fb88b 100644 --- a/Sources/mcs/Core/FileLock.swift +++ b/Sources/mcs/Core/FileLock.swift @@ -1,5 +1,10 @@ import ArgumentParser import Foundation +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif /// Errors thrown when the process lock cannot be acquired. enum FileLockError: Error, LocalizedError { @@ -73,7 +78,10 @@ func withFileLock(at path: URL, body: () throws -> T) throws -> T { try fm.createDirectory(at: dir, withIntermediateDirectories: true) } - let fd = open(path.path, O_CREAT | O_RDWR, 0o644) + // O_CLOEXEC: a flock belongs to the open file description, which fork+exec shares, so without + // it a child outliving the command — a daemon started by a shellInteractive component, say — + // inherits the descriptor and keeps the lock held after mcs has exited. + let fd = open(path.path, O_CREAT | O_RDWR | O_CLOEXEC, 0o644) guard fd >= 0 else { throw FileLockError.openFailed(path: path.path, errno: errno) } diff --git a/Sources/mcs/Core/GlobMatcher.swift b/Sources/mcs/Core/GlobMatcher.swift index 3ea706fe..cbdc8501 100644 --- a/Sources/mcs/Core/GlobMatcher.swift +++ b/Sources/mcs/Core/GlobMatcher.swift @@ -1,5 +1,9 @@ -import Darwin import Foundation +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif /// Thin wrapper over POSIX `fnmatch(3)` for path-vs-pattern matching. /// diff --git a/Sources/mcs/Core/PTYBridge.swift b/Sources/mcs/Core/PTYBridge.swift new file mode 100644 index 00000000..79c48b7f --- /dev/null +++ b/Sources/mcs/Core/PTYBridge.swift @@ -0,0 +1,46 @@ +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +/// The per-descriptor decisions of `ShellRunner.runInteractive`'s poll loop, kept free of I/O so +/// every `revents` combination can be tabled in a test. +/// +/// Two facts drive the shapes below. `poll(2)` reports `POLLHUP`, `POLLERR` and `POLLNVAL` +/// whether or not they were requested, so a descriptor that keeps reporting one of them without +/// `POLLIN` makes `poll` return immediately, forever, unless the loop stops watching it — macOS +/// returns `POLLNVAL` for a `/dev/null` stdin, which is what a hook or `mcs sync --all StdinAction { + if revents & Int16(POLLIN) != 0 { return .forward } + if revents & Int16(POLLHUP | POLLERR | POLLNVAL) != 0 { return .drop } + return .idle + } + + static func ptyAction(revents: Int16) -> PTYAction { + if revents & Int16(POLLERR | POLLNVAL) != 0 { return .close } + if revents & Int16(POLLIN | POLLHUP) != 0 { return .read } + return .idle + } +} diff --git a/Sources/mcs/Core/ShellRunner.swift b/Sources/mcs/Core/ShellRunner.swift index dacb9f51..7c1e94e4 100644 --- a/Sources/mcs/Core/ShellRunner.swift +++ b/Sources/mcs/Core/ShellRunner.swift @@ -1,4 +1,9 @@ import Foundation +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif /// Result of running a shell command. struct ShellResult { @@ -72,6 +77,14 @@ extension ShellRunning { /// Runs shell commands and captures output. struct ShellRunner: ShellRunning { + /// The warning for a failed interactive command. The child's own output already went to the + /// terminal, so the result's `stderr` is empty unless the bridge itself failed before `exec` — + /// `forkpty` refused, a C string could not be allocated — and then it is the only record of + /// why, so it is what gets printed. + static func interactiveFailureMessage(name: String, stderr: String) -> String { + stderr.isEmpty ? "\(name) failed (see output above)" : "\(name) failed: \(stderr)" + } + let environment: Environment /// Check if a command exists on PATH. @@ -200,13 +213,44 @@ struct ShellRunner: ShellRunning { } + [nil] defer { envp.compactMap(\.self).forEach { free($0) } } - let argv: [UnsafeMutablePointer?] = ([executable] + arguments).map { strdup($0) } + [nil] - defer { argv.compactMap(\.self).forEach { free($0) } } + // After fork() only async-signal-safe calls are legal, so the child has no way to report + // an allocation failure, and a force-unwrap there would trap through the Swift runtime. + // Every C string the child needs is therefore allocated and checked here, in the parent — + // a nil in the middle of argv or envp would otherwise truncate the vector at execve, which + // stops at the first NULL. (Glibc marks the `execve`/`chdir` parameters `__nonnull`, so + // Swift imports them non-optional there and rejects the `Optional` Darwin's unannotated + // signatures accept.) + guard let executablePath = strdup(executable) else { + return ShellResult( + exitCode: 1, stdout: "", stderr: "Could not allocate the command path for \(executable)" + ) + } + let argv: [UnsafeMutablePointer?] = [executablePath] + arguments.map { strdup($0) } + [nil] + defer { argv.compactMap(\.self).forEach { free($0) } } // frees executablePath too — it is argv[0] + + // dropLast() skips the NULL terminator each vector deliberately ends with. + guard !argv.dropLast().contains(where: { $0 == nil }), + !envp.dropLast().contains(where: { $0 == nil }) + else { + return ShellResult( + exitCode: 1, stdout: "", stderr: "Could not allocate the command line for \(executable)" + ) + } // Pre-convert workingDirectory to a C string before fork so the child // doesn't need to invoke Swift's String-to-CString bridge (not fork-safe). - let cwdCStr = workingDirectory.map { strdup($0) } - defer { cwdCStr.map { free($0) } } + let workingDirectoryPath: UnsafeMutablePointer? + if let workingDirectory { + guard let copy = strdup(workingDirectory) else { + return ShellResult( + exitCode: 1, stdout: "", stderr: "Could not allocate the working directory path" + ) + } + workingDirectoryPath = copy + } else { + workingDirectoryPath = nil + } + defer { workingDirectoryPath.map { free($0) } } // Save the terminal's current attributes so we can restore them after. var originalTermios = termios() @@ -226,7 +270,7 @@ struct ShellRunner: ShellRunning { // After fork, only async-signal-safe functions are safe. Avoid Swift // runtime calls (String interpolation, ARC) — they can deadlock on // locks held by other threads in the parent at fork time. - if let cwd = cwdCStr { + if let cwd = workingDirectoryPath { if chdir(cwd) != 0 { let err = strerror(errno) _ = write(STDERR_FILENO, "chdir failed: ", 14) @@ -235,8 +279,7 @@ struct ShellRunner: ShellRunning { _exit(126) } } - // Use argv[0] (already a C string from strdup) instead of the Swift String. - execve(argv[0], argv, envp) + execve(executablePath, argv, envp) let err = strerror(errno) _ = write(STDERR_FILENO, "execve failed: ", 15) if let err { _ = write(STDERR_FILENO, err, strlen(err)) } @@ -280,19 +323,27 @@ struct ShellRunner: ShellRunning { break } - // Terminal → PTY (user typing, including password input) - if fds[0].revents & Int16(POLLIN) != 0 { + let ptyAction = PTYBridge.ptyAction(revents: fds[1].revents) + if ptyAction == .close { break bridgeLoop } + + // Terminal → PTY (user typing, including password input). poll(2) skips negative + // fds, so a dropped stdin leaves the loop draining the PTY alone. + switch PTYBridge.stdinAction(revents: fds[0].revents) { + case .forward: let n = read(STDIN_FILENO, &buf, buf.count) if n <= 0 { - // stdin EOF — stop monitoring, let PTY drain remaining output fds[0].fd = -1 } else { writeAll(fd: ptyFD, buf: buf, count: n) } + case .drop: + fds[0].fd = -1 + case .idle: + break } // PTY → Terminal (command output, prompts, progress bars) - if fds[1].revents & Int16(POLLIN | POLLHUP) != 0 { + if ptyAction == .read { let n = read(ptyFD, &buf, buf.count) if n <= 0 { break bridgeLoop } // Child closed the PTY writeAll(fd: STDOUT_FILENO, buf: buf, count: n) diff --git a/Sources/mcs/Core/TerminalAttributes.swift b/Sources/mcs/Core/TerminalAttributes.swift new file mode 100644 index 00000000..24df4882 --- /dev/null +++ b/Sources/mcs/Core/TerminalAttributes.swift @@ -0,0 +1,38 @@ +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +/// termios helpers shared by the raw-mode picker. +/// +/// `c_cc` imports as a fixed-size tuple whose length and element order are platform-defined +/// (`NCCS` is 20 on Darwin and 32 on Glibc; `VMIN` is index 16 on Darwin and 6 on Glibc), so the +/// index has to come from the platform's own constant rather than a literal. `tcflag_t` likewise +/// differs — `UInt` on Darwin, `UInt32` on Glibc — so every flag mask is converted through +/// `tcflag_t(...)`. +enum TerminalAttributes { + /// Reads one control character. Exists for `TerminalAttributesTests`: asserting raw mode + /// without a TTY is the only way to cover this on both platforms. + static func controlCharacter(_ attributes: termios, _ index: Int32) -> cc_t { + withUnsafeBytes(of: attributes.c_cc) { raw in + raw.bindMemory(to: cc_t.self)[Int(index)] + } + } + + static func setControlCharacter(_ attributes: inout termios, _ index: Int32, to value: cc_t) { + withUnsafeMutableBytes(of: &attributes.c_cc) { raw in + raw.bindMemory(to: cc_t.self)[Int(index)] = value + } + } + + /// Non-canonical, no-echo attributes: one byte satisfies a read and nothing is echoed, so arrow + /// keys reach the picker as they are typed. + static func rawMode(from attributes: termios) -> termios { + var raw = attributes + raw.c_lflag &= ~tcflag_t(ICANON | ECHO) + setControlCharacter(&raw, VMIN, to: 1) + setControlCharacter(&raw, VTIME, to: 0) + return raw + } +} diff --git a/Sources/mcs/Sync/GlobalSyncStrategy.swift b/Sources/mcs/Sync/GlobalSyncStrategy.swift index 131cac24..a6c81d80 100644 --- a/Sources/mcs/Sync/GlobalSyncStrategy.swift +++ b/Sources/mcs/Sync/GlobalSyncStrategy.swift @@ -131,7 +131,7 @@ struct GlobalSyncStrategy: SyncStrategy { if result.succeeded { output.success(" \(component.displayName) installed") } else if interactive { - output.warn(" \(component.displayName) failed (see output above)") + output.warn(" " + ShellRunner.interactiveFailureMessage(name: component.displayName, stderr: result.stderr)) } else { output.warn(" \(component.displayName) requires manual installation:") output.plain(" \(command)") diff --git a/Sources/mcs/Sync/ProjectSyncStrategy.swift b/Sources/mcs/Sync/ProjectSyncStrategy.swift index 180401de..7e3cebf9 100644 --- a/Sources/mcs/Sync/ProjectSyncStrategy.swift +++ b/Sources/mcs/Sync/ProjectSyncStrategy.swift @@ -124,7 +124,7 @@ struct ProjectSyncStrategy: SyncStrategy { let result = shell.shell(command, interactive: interactive) if !result.succeeded { if interactive { - output.warn(" \(component.displayName) failed (see output above)") + output.warn(" " + ShellRunner.interactiveFailureMessage(name: component.displayName, stderr: result.stderr)) } else { output.warn(" \(component.displayName) failed: \(String(result.stderr.prefix(200)))") } diff --git a/Tests/MCSTests/FileLockTests.swift b/Tests/MCSTests/FileLockTests.swift index 4a4b40fa..37ea40da 100644 --- a/Tests/MCSTests/FileLockTests.swift +++ b/Tests/MCSTests/FileLockTests.swift @@ -1,4 +1,9 @@ import Foundation +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif @testable import mcs import Testing @@ -120,6 +125,40 @@ struct FileLockTests { } } + @Test("withFileLock does not leak the lock into exec'd children") + func lockIsNotInheritedByChildren() throws { + let tmpDir = try makeTmpDir() + defer { try? FileManager.default.removeItem(at: tmpDir) } + + let lockFile = tmpDir.appendingPathComponent("lock") + + // posix_spawn hands every descriptor without FD_CLOEXEC to the child, exactly like the + // forkpty + execve path in ShellRunner. Foundation.Process closes inherited descriptors + // itself, so a Process-based child would pass with or without O_CLOEXEC on the lock. + var child: pid_t = 0 + try withFileLock(at: lockFile) { + let argv: [UnsafeMutablePointer?] = [strdup("/bin/sleep"), strdup("30"), nil] + defer { argv.forEach { free($0) } } + let envp: [UnsafeMutablePointer?] = [nil] + #expect(posix_spawn(&child, "/bin/sleep", nil, nil, argv, envp) == 0) + } + defer { + if child > 0 { + kill(child, SIGKILL) + waitpid(child, nil, 0) + } + } + #expect(child > 0) + + // The body has returned and the parent's descriptor is closed, so only a copy inherited by + // the still-running child could hold the lock now. + let fd = open(lockFile.path, O_RDWR) + #expect(fd >= 0) + defer { close(fd) } + + #expect(flock(fd, LOCK_EX | LOCK_NB) == 0, "lock is still held by the exec'd child") + } + @Test("FileLockError.acquireFailed has descriptive message") func errorMessage() { let error = FileLockError.acquireFailed(path: "/tmp/lock") diff --git a/Tests/MCSTests/LifecycleIntegrationTests.swift b/Tests/MCSTests/LifecycleIntegrationTests.swift index eb3743a0..a11b5e55 100644 --- a/Tests/MCSTests/LifecycleIntegrationTests.swift +++ b/Tests/MCSTests/LifecycleIntegrationTests.swift @@ -23,12 +23,13 @@ private struct LifecycleTestBed { func makeConfigurator( registry: TechPackRegistry = TechPackRegistry(), - warningCounter: WarningCounter? = nil + warningCounter: WarningCounter? = nil, + shell: (any ShellRunning)? = nil ) -> Configurator { Configurator( environment: env, output: CLIOutput(colorsEnabled: false, warningCounter: warningCounter, interactiveStdin: false), - shell: ShellRunner(environment: env), + shell: shell ?? ShellRunner(environment: env), registry: registry, strategy: ProjectSyncStrategy(projectPath: project, environment: env), claudeCLI: mockCLI @@ -49,12 +50,13 @@ private struct LifecycleTestBed { func makeGlobalSyncConfigurator( registry: TechPackRegistry = TechPackRegistry(), - warningCounter: WarningCounter? = nil + warningCounter: WarningCounter? = nil, + shell: (any ShellRunning)? = nil ) -> Configurator { Configurator( environment: env, output: CLIOutput(colorsEnabled: false, warningCounter: warningCounter, interactiveStdin: false), - shell: ShellRunner(environment: env), + shell: shell ?? ShellRunner(environment: env), registry: registry, strategy: GlobalSyncStrategy(environment: env), claudeCLI: mockCLI @@ -1277,7 +1279,7 @@ struct ShellCommandLifecycleTests { #expect(FileManager.default.fileExists(atPath: markerPath)) } - @Test("shellCommand with interactive flag is accepted and state is recorded") + @Test("shellCommand with interactive flag really runs under a PTY") func shellCommandInteractiveAccepted() throws { let bed = try LifecycleTestBed() defer { bed.cleanup() } @@ -1299,12 +1301,14 @@ struct ShellCommandLifecycleTests { ) let registry = TechPackRegistry(packs: [pack]) - // Configure — interactive commands use forkpty() in real ShellRunner, - // but the test verifies the component is accepted and state is recorded. + // Interactive commands go through forkpty() in the real ShellRunner, so the marker file + // proves the whole path ran — fork, execve and the terminal/PTY bridge loop — not just + // that the component was recorded. let configurator = bed.makeGlobalSyncConfigurator(registry: registry) try configurator.configure(packs: [pack], confirmRemovals: false) - // Verify state records the pack + #expect(FileManager.default.fileExists(atPath: markerPath)) + let state = try ProjectState(stateFile: bed.env.globalStateFile) #expect(state.configuredPacks.contains("interactive-pack")) } @@ -3303,6 +3307,40 @@ struct BrewPackageDoctorTests { #expect(summary.warnings == 0) #expect(summary.issues == 0) } + + /// The Linux happy path in one assertion: a `brew:` component whose command is on PATH is + /// satisfied without Homebrew existing at all, which is what makes `brew:` usable on a machine + /// that has no brew. Global scope, because that is where `configure` installs brew packages + /// inline. The package name is deliberately one no machine has, so the mocked PATH hit — not + /// the real machine — is what decides the outcome. + @Test("A brew package satisfied on PATH spawns no brew subprocess") + func pathSatisfiedBrewPackageSpawnsNoSubprocess() throws { + let bed = try LifecycleTestBed() + defer { bed.cleanup() } + + let package = "mcs-nonexistent-formula-for-tests" + let shell = MockShellRunner(environment: bed.env) + shell.commandExistsResult = true + + let pack = MockTechPack( + identifier: "brew-pack", + displayName: "Brew Pack", + components: [bed.brewComponent(pack: "brew-pack", id: "tool", package: package)] + ) + let registry = TechPackRegistry(packs: [pack]) + + try bed.makeGlobalSyncConfigurator(registry: registry, shell: shell) + .configure(packs: [pack], confirmRemovals: false) + + #expect(shell.commandExistsCalls.contains(package)) + #expect( + !shell.runCalls.contains { $0.executable == bed.env.brewPath }, + "a PATH hit must settle the question without asking brew" + ) + + let state = try ProjectState(stateFile: bed.env.globalStateFile) + #expect(state.configuredPacks.contains("brew-pack")) + } } // MARK: - Bootstrap: additive-vs-prune convergence diff --git a/Tests/MCSTests/PTYBridgeTests.swift b/Tests/MCSTests/PTYBridgeTests.swift new file mode 100644 index 00000000..ca67dd27 --- /dev/null +++ b/Tests/MCSTests/PTYBridgeTests.swift @@ -0,0 +1,47 @@ +import Foundation +@testable import mcs +import Testing + +struct PTYBridgeTests { + @Test( + "The terminal side forwards while readable and drops only once nothing more can arrive", + arguments: [ + (Int16(POLLIN), PTYBridge.StdinAction.forward), + (Int16(POLLIN | POLLHUP), .forward), + (Int16(POLLHUP), .drop), + (Int16(POLLERR), .drop), + (Int16(POLLNVAL), .drop), + (Int16(0), .idle), + ] + ) + func stdinAction(revents: Int16, expected: PTYBridge.StdinAction) { + #expect(PTYBridge.stdinAction(revents: revents) == expected) + } + + @Test( + "The child side reads through a hangup and closes only on a bad descriptor", + arguments: [ + (Int16(POLLIN), PTYBridge.PTYAction.read), + (Int16(POLLIN | POLLHUP), .read), + (Int16(POLLHUP), .read), + (Int16(POLLERR), .close), + (Int16(POLLNVAL), .close), + (Int16(POLLIN | POLLERR), .close), + (Int16(0), .idle), + ] + ) + func ptyAction(revents: Int16, expected: PTYBridge.PTYAction) { + #expect(PTYBridge.ptyAction(revents: revents) == expected) + } + + @Test("A /dev/null stdin, which macOS reports as POLLNVAL, is dropped rather than polled forever") + func devNullStdinIsDropped() { + let fd = open("/dev/null", O_RDONLY) + #expect(fd >= 0) + defer { close(fd) } + + var fds = [pollfd(fd: fd, events: Int16(POLLIN), revents: 0)] + #expect(poll(&fds, 1, 0) == 1) + #expect(PTYBridge.stdinAction(revents: fds[0].revents) != .idle) + } +} diff --git a/Tests/MCSTests/ShellRunnerTests.swift b/Tests/MCSTests/ShellRunnerTests.swift index 15e2195d..afa7eddf 100644 --- a/Tests/MCSTests/ShellRunnerTests.swift +++ b/Tests/MCSTests/ShellRunnerTests.swift @@ -7,6 +7,18 @@ struct ShellRunnerTests { ShellRunner(environment: Environment()) } + @Test("An interactive failure points at the terminal unless the bridge itself reported why") + func interactiveFailureMessageShapes() { + #expect( + ShellRunner.interactiveFailureMessage(name: "Ollama", stderr: "") + == "Ollama failed (see output above)" + ) + #expect( + ShellRunner.interactiveFailureMessage(name: "Ollama", stderr: "forkpty failed: No such file or directory") + == "Ollama failed: forkpty failed: No such file or directory" + ) + } + @Test("run captures stdout") func capturesStdout() { let result = shell.run("/bin/echo", arguments: ["hello"]) diff --git a/Tests/MCSTests/TerminalAttributesTests.swift b/Tests/MCSTests/TerminalAttributesTests.swift new file mode 100644 index 00000000..db93011b --- /dev/null +++ b/Tests/MCSTests/TerminalAttributesTests.swift @@ -0,0 +1,32 @@ +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif +@testable import mcs +import Testing + +struct TerminalAttributesTests { + @Test("rawMode clears ICANON and ECHO, keeps ISIG, and sets VMIN/VTIME") + func rawModeFlagsAndControlCharacters() { + var original = termios() + original.c_lflag = tcflag_t(ICANON | ECHO | ISIG) + + let raw = TerminalAttributes.rawMode(from: original) + + #expect(raw.c_lflag & tcflag_t(ICANON) == 0) + #expect(raw.c_lflag & tcflag_t(ECHO) == 0) + #expect(raw.c_lflag & tcflag_t(ISIG) != 0, "Ctrl-C must still generate SIGINT") + #expect(TerminalAttributes.controlCharacter(raw, VMIN) == 1) + #expect(TerminalAttributes.controlCharacter(raw, VTIME) == 0) + } + + @Test("setControlCharacter writes only the requested index") + func setControlCharacterIsIndexed() { + var attributes = termios() + TerminalAttributes.setControlCharacter(&attributes, VMIN, to: 7) + + #expect(TerminalAttributes.controlCharacter(attributes, VMIN) == 7) + #expect(TerminalAttributes.controlCharacter(attributes, VTIME) == 0) + } +} From 8dedbe25e9508900da31df0b535a0c1b5d1affd2 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Tue, 29 Sep 2026 23:18:13 +0200 Subject: [PATCH 04/28] Resolve Homebrew prefixes and installation guidance per platform - Linuxbrew's prefixes, and advice that names the system package manager where brew cannot act, so no hint points back at a command with no exit - ensureClaudeCLI never offers a cask Linuxbrew does not have, and takes injectable brew/confirm seams so both macOS branches are asserted Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu --- Sources/mcs/Core/ClaudePrerequisite.swift | 39 ++++++++-- Sources/mcs/Core/Homebrew.swift | 25 ++++++- Sources/mcs/Doctor/CoreDoctorChecks.swift | 6 +- .../ExternalPack/ExternalDoctorCheck.swift | 36 +++++---- Sources/mcs/Sync/ComponentExecutor.swift | 2 +- Tests/MCSTests/ClaudePrerequisiteTests.swift | 73 +++++++++++++++++++ .../DoctorRunnerIntegrationTests.swift | 42 +++++++++++ Tests/MCSTests/ExternalDoctorCheckTests.swift | 29 +++++++- Tests/MCSTests/HomebrewTests.swift | 31 ++++++++ 9 files changed, 252 insertions(+), 31 deletions(-) create mode 100644 Tests/MCSTests/ClaudePrerequisiteTests.swift diff --git a/Sources/mcs/Core/ClaudePrerequisite.swift b/Sources/mcs/Core/ClaudePrerequisite.swift index ae803dc3..5f73509a 100644 --- a/Sources/mcs/Core/ClaudePrerequisite.swift +++ b/Sources/mcs/Core/ClaudePrerequisite.swift @@ -1,14 +1,20 @@ import Foundation -/// Verify that the Claude Code CLI is available, offering to install via Homebrew if missing. +/// Verify that the Claude Code CLI is available, offering an install where mcs can perform one. /// /// Returns `true` if Claude CLI is available (either already installed or successfully installed). /// Returns `false` if the user declines installation or installation fails. +/// +/// `brewInstalled` and `confirmInstall` default to the real filesystem check and the real prompt; +/// tests pass both so every macOS branch runs without a Homebrew on the host or a terminal on +/// stdin. @discardableResult func ensureClaudeCLI( shell: any ShellRunning, environment: Environment, - output: CLIOutput + output: CLIOutput, + brewInstalled: Bool? = nil, + confirmInstall: (() -> Bool)? = nil ) -> Bool { if shell.commandExists(Constants.CLI.claudeCommand) { return true @@ -17,14 +23,16 @@ func ensureClaudeCLI( output.error("Claude Code CLI not found.") output.plain(" mcs requires the Claude Code CLI to function.") + #if canImport(Darwin) let brew = Homebrew(shell: shell, environment: environment) - guard brew.isInstalled else { - output.plain(" Install it manually: https://docs.anthropic.com/en/docs/claude-code") + guard brewInstalled ?? brew.isInstalled else { + printManualClaudeInstallInstructions(output) return false } - guard output.askYesNo("Install Claude Code via Homebrew?", default: true) else { - output.plain(" Install it manually: https://docs.anthropic.com/en/docs/claude-code") + let confirm = confirmInstall ?? { output.askYesNo("Install Claude Code via Homebrew?", default: true) } + guard confirm() else { + printManualClaudeInstallInstructions(output) return false } @@ -39,6 +47,23 @@ func ensureClaudeCLI( if !result.stderr.isEmpty { output.dimmed(String(result.stderr.prefix(200))) } - output.plain(" Install it manually: https://docs.anthropic.com/en/docs/claude-code") + printManualClaudeInstallInstructions(output) + return false + #else + // `claude-code` is a Homebrew *cask*, and Linuxbrew has no casks, so offering the brew install + // would fail on exactly the machines that have brew. Print what does work instead. + printManualClaudeInstallInstructions(output) return false + #endif +} + +/// How to install Claude Code by hand. mcs never runs these itself — piping an installer into a +/// shell, or writing into npm's global prefix, is a trust decision that belongs to the user. +private func printManualClaudeInstallInstructions(_ output: CLIOutput) { + output.plain(" Install it manually: https://docs.anthropic.com/en/docs/claude-code") + #if !canImport(Darwin) + // On macOS the caller owns the Homebrew route; here the manual ones are all there is. + output.plain(" curl -fsSL https://claude.ai/install.sh | bash") + output.plain(" npm install -g @anthropic-ai/claude-code") + #endif } diff --git a/Sources/mcs/Core/Homebrew.swift b/Sources/mcs/Core/Homebrew.swift index c471ff23..9154b41d 100644 --- a/Sources/mcs/Core/Homebrew.swift +++ b/Sources/mcs/Core/Homebrew.swift @@ -2,8 +2,15 @@ import Foundation /// Manages Homebrew package installation and service management. struct Homebrew { - /// Both Homebrew prefix paths — arm64 and x86_64. - static let allPrefixes = ["/opt/homebrew", "/usr/local"] + /// Every prefix Homebrew installs itself at on this platform: both macOS architectures, or + /// Linuxbrew's multi-user and single-user locations. + static var allPrefixes: [String] { + #if canImport(Darwin) + ["/opt/homebrew", "/usr/local"] + #else + ["/home/linuxbrew/.linuxbrew", Environment.defaultHomeDirectory() + "/.linuxbrew"] + #endif + } let shell: any ShellRunning let environment: Environment @@ -56,6 +63,20 @@ struct Homebrew { shell.run(environment.brewPath, arguments: ["uninstall", name]) } + /// What to tell the user about `package` when Homebrew is not installed. + /// + /// With no `brew` there is nothing `mcs sync` can do, so the advice must not point back at it + /// — that loop has no exit. On Linux, Homebrew is the unusual case: the package almost + /// certainly comes from the distribution's own package manager, so that is what is named. + static func manualInstallAdvice(for package: String) -> String { + #if canImport(Darwin) + "Homebrew not found — install it from https://brew.sh, then re-run 'mcs sync' to get \(package)" + #else + "Homebrew not found — install \(package) with your system package manager" + + " (apt, dnf, pacman, …) or install Homebrew from https://brew.sh" + #endif + } + /// Detects the Homebrew formula that provides a command by reading the immediate /// symlink target in the Homebrew bin directory. Returns nil if the command isn't /// brew-installed. diff --git a/Sources/mcs/Doctor/CoreDoctorChecks.swift b/Sources/mcs/Doctor/CoreDoctorChecks.swift index 737829cd..5f724543 100644 --- a/Sources/mcs/Doctor/CoreDoctorChecks.swift +++ b/Sources/mcs/Doctor/CoreDoctorChecks.swift @@ -40,7 +40,11 @@ struct BrewPackageCheck: DoctorCheck { } func fix() -> FixResult { - .notFixable("Run 'mcs sync' to install dependencies") + let shell = ShellRunner(environment: environment) + guard Homebrew(shell: shell, environment: environment).isInstalled else { + return .notFixable(Homebrew.manualInstallAdvice(for: package)) + } + return .notFixable("Run 'mcs sync' to install dependencies") } } diff --git a/Sources/mcs/ExternalPack/ExternalDoctorCheck.swift b/Sources/mcs/ExternalPack/ExternalDoctorCheck.swift index 0da5b286..09a92319 100644 --- a/Sources/mcs/ExternalPack/ExternalDoctorCheck.swift +++ b/Sources/mcs/ExternalPack/ExternalDoctorCheck.swift @@ -81,7 +81,12 @@ struct ExternalCommandExistsCheck: DoctorCheck { func fix() -> FixResult { guard let fixCommand else { - return .notFixable("Run 'mcs sync' to install dependencies") + // The pack declares no fix and nothing here knows which component — if any — provides + // this command: it may be a brew: package, a shell: installer, or nothing at all. So + // the hint names both routes rather than guessing from what Homebrew can do. + return .notFixable( + "Run 'mcs sync' if a pack component installs '\(command)'; otherwise install it and make sure it is on PATH" + ) } let result = scriptRunner.runCommand(fixCommand) if result.succeeded { @@ -100,6 +105,7 @@ struct ExternalFileExistsCheck: ScopedPathCheck { let path: String let scope: ExternalDoctorCheckScope let projectRoot: URL? + var environment: Environment = .init() func check() -> CheckResult { let resolved: String @@ -127,6 +133,7 @@ struct ExternalDirectoryExistsCheck: ScopedPathCheck { let path: String let scope: ExternalDoctorCheckScope let projectRoot: URL? + var environment: Environment = .init() func check() -> CheckResult { let resolved: String @@ -156,6 +163,7 @@ struct ExternalFileContainsCheck: ScopedPathCheck { let pattern: String let scope: ExternalDoctorCheckScope let projectRoot: URL? + var environment: Environment = .init() func check() -> CheckResult { let resolved: String @@ -187,6 +195,7 @@ struct ExternalFileNotContainsCheck: ScopedPathCheck { let pattern: String let scope: ExternalDoctorCheckScope let projectRoot: URL? + var environment: Environment = .init() func check() -> CheckResult { let resolved: String @@ -512,7 +521,8 @@ enum ExternalDoctorCheckFactory { section: section, path: path, scope: scope, - projectRoot: projectRoot + projectRoot: projectRoot, + environment: environment ) case .directoryExists: @@ -527,7 +537,8 @@ enum ExternalDoctorCheckFactory { section: section, path: path, scope: scope, - projectRoot: projectRoot + projectRoot: projectRoot, + environment: environment ) case .fileContains: @@ -545,7 +556,8 @@ enum ExternalDoctorCheckFactory { path: path, pattern: pattern, scope: scope, - projectRoot: projectRoot + projectRoot: projectRoot, + environment: environment ) case .fileNotContains: @@ -563,7 +575,8 @@ enum ExternalDoctorCheckFactory { path: path, pattern: pattern, scope: scope, - projectRoot: projectRoot + projectRoot: projectRoot, + environment: environment ) case .shellScript: @@ -635,6 +648,7 @@ protocol ScopedPathCheck: DoctorCheck { var path: String { get } var scope: ExternalDoctorCheckScope { get } var projectRoot: URL? { get } + var environment: Environment { get } } enum PathResolveResult { @@ -647,7 +661,7 @@ extension ScopedPathCheck { func resolvePath() -> PathResolveResult { switch scope { case .global: - return .resolved(expandTilde(path)) + return .resolved(environment.expandingTilde(path)) case .project: guard let root = projectRoot else { return .noProjectRoot } guard let safe = PathContainment.safePath(relativePath: path, within: root) else { @@ -741,13 +755,3 @@ extension SettingsReadingCheck { return SettingsProbe(match: nil, readErrors: readErrors, anyFileExisted: anyFileExisted) } } - -// MARK: - Helpers - -/// Expand `~` at the start of a path to the user's home directory. -func expandTilde(_ path: String) -> String { - if path.hasPrefix("~/") { - return NSString(string: path).expandingTildeInPath - } - return path -} diff --git a/Sources/mcs/Sync/ComponentExecutor.swift b/Sources/mcs/Sync/ComponentExecutor.swift index f85cd71d..b6eb44c3 100644 --- a/Sources/mcs/Sync/ComponentExecutor.swift +++ b/Sources/mcs/Sync/ComponentExecutor.swift @@ -17,7 +17,7 @@ struct ComponentExecutor { let brew = Homebrew(shell: shell, environment: environment) if brew.provides(package) { return true } guard brew.isInstalled else { - output.warn("Homebrew not found, cannot install \(package)") + output.warn(Homebrew.manualInstallAdvice(for: package)) return false } let result = brew.install(package) diff --git a/Tests/MCSTests/ClaudePrerequisiteTests.swift b/Tests/MCSTests/ClaudePrerequisiteTests.swift new file mode 100644 index 00000000..8d227d0e --- /dev/null +++ b/Tests/MCSTests/ClaudePrerequisiteTests.swift @@ -0,0 +1,73 @@ +import Foundation +@testable import mcs +import Testing + +struct ClaudePrerequisiteTests { + private func silentOutput() -> CLIOutput { + CLIOutput(colorsEnabled: false) + } + + @Test("A claude already on PATH is accepted without spawning anything") + func acceptsInstalledCLI() { + let shell = MockShellRunner() + shell.commandExistsResult = true + + #expect(ensureClaudeCLI(shell: shell, environment: shell.environment, output: silentOutput())) + #expect(shell.commandExistsCalls.contains(Constants.CLI.claudeCommand)) + #expect(shell.runCalls.isEmpty) + } + + @Test("A missing claude without brew is reported and nothing is installed") + func missingCLIWithoutHomebrew() { + let shell = MockShellRunner() + shell.commandExistsResult = false + + // The same on both platforms: on Linux `claude-code` is a cask Linuxbrew does not have. + #expect(!ensureClaudeCLI( + shell: shell, environment: shell.environment, output: silentOutput(), + brewInstalled: false, confirmInstall: { Issue.record("no prompt without brew"); return true } + )) + #expect(shell.runCalls.isEmpty, "mcs must not attempt an install it cannot perform") + } + + #if canImport(Darwin) + @Test("Declining the Homebrew offer installs nothing") + func declinedHomebrewInstall() { + let shell = MockShellRunner() + shell.commandExistsResult = false + + #expect(!ensureClaudeCLI( + shell: shell, environment: shell.environment, output: silentOutput(), + brewInstalled: true, confirmInstall: { false } + )) + #expect(shell.runCalls.isEmpty) + } + + @Test("Accepting the Homebrew offer runs brew install, then re-probes for claude") + func acceptedHomebrewInstall() { + let shell = MockShellRunner() + shell.commandExistsResult = false + + // The install "succeeds" but claude still is not on PATH, so the outcome is a failure — + // the assertion that matters is which command was run. + #expect(!ensureClaudeCLI( + shell: shell, environment: shell.environment, output: silentOutput(), + brewInstalled: true, confirmInstall: { true } + )) + #expect(shell.runCalls.map(\.arguments) == [["install", "claude-code"]]) + #expect(shell.runCalls.first?.executable == shell.environment.brewPath) + } + #else + @Test("Linux never offers the Homebrew cask, even with brew present and a willing user") + func linuxNeverOffersHomebrew() { + let shell = MockShellRunner() + shell.commandExistsResult = false + + #expect(!ensureClaudeCLI( + shell: shell, environment: shell.environment, output: silentOutput(), + brewInstalled: true, confirmInstall: { Issue.record("no prompt on Linux"); return true } + )) + #expect(shell.runCalls.isEmpty) + } + #endif +} diff --git a/Tests/MCSTests/DoctorRunnerIntegrationTests.swift b/Tests/MCSTests/DoctorRunnerIntegrationTests.swift index 23a84e16..337b94d3 100644 --- a/Tests/MCSTests/DoctorRunnerIntegrationTests.swift +++ b/Tests/MCSTests/DoctorRunnerIntegrationTests.swift @@ -527,3 +527,45 @@ struct DoctorSummaryWarningCountTests { #expect(withGhost.isHealthy) } } + +// MARK: - Brew Guidance + +struct BrewPackageCheckGuidanceTests { + @Test("A brew package whose formula name is also a command on PATH passes") + func formulaNameMatchingCommandPasses() { + // `git` stands in for that whole class: the PATH probe is provenance-blind, so any + // installer satisfies the declaration. This is the only shape that works on a Linux box + // with no Homebrew. + let check = BrewPackageCheck(name: "git", section: "Dependencies", package: "git") + guard case .pass = check.check() else { + Issue.record("Expected .pass for a formula name that is also on PATH, got \(check.check())") + return + } + } + + @Test("The fix for a missing package never advertises a command that cannot work") + func fixDoesNotPointAtSyncWhenBrewIsAbsent() { + let package = "mcs-nonexistent-formula-for-tests" + let check = BrewPackageCheck(name: package, section: "Dependencies", package: package) + guard case .fail = check.check() else { + Issue.record("Expected .fail for a formula nothing provides, got \(check.check())") + return + } + + guard case let .notFixable(message) = check.fix() else { + Issue.record("Installing a package is sync's job, so the fix must be .notFixable") + return + } + + // Asserted against whichever machine runs this: with brew present `mcs sync` really can + // install the package; without it the hint must name the package and where to get brew or + // the package (the Darwin text still ends in `mcs sync`, after installing brew). + if FileManager.default.fileExists(atPath: Environment().brewPath) { + #expect(message.contains("mcs sync")) + } else { + #expect(message == Homebrew.manualInstallAdvice(for: package)) + #expect(message.contains(package)) + #expect(message.contains("brew.sh") || message.contains("system package manager")) + } + } +} diff --git a/Tests/MCSTests/ExternalDoctorCheckTests.swift b/Tests/MCSTests/ExternalDoctorCheckTests.swift index 0dc5badd..e0dafebb 100644 --- a/Tests/MCSTests/ExternalDoctorCheckTests.swift +++ b/Tests/MCSTests/ExternalDoctorCheckTests.swift @@ -74,7 +74,7 @@ struct ExternalDoctorCheckTests { } } - @Test("Command exists fix returns notFixable when no fix command") + @Test("Command exists fix returns notFixable naming the command when no fix command") func commandExistsNoFix() { let check = ExternalCommandExistsCheck( name: "test", @@ -85,11 +85,11 @@ struct ExternalDoctorCheckTests { scriptRunner: makeScriptRunner() ) let result = check.fix() - if case .notFixable = result { - // expected - } else { + guard case let .notFixable(message) = result else { Issue.record("Expected .notFixable, got \(result)") + return } + #expect(message.contains("'nonexistent'")) } // MARK: - ExternalFileExistsCheck @@ -117,6 +117,27 @@ struct ExternalDoctorCheckTests { } } + @Test("A global-scoped tilde path resolves under the injected home") + func fileExistsResolvesTildeAgainstInjectedHome() throws { + let home = try makeTmpDir() + defer { try? FileManager.default.removeItem(at: home) } + try "content".write(to: home.appendingPathComponent(".marker"), atomically: true, encoding: .utf8) + + let check = ExternalFileExistsCheck( + name: "marker", + section: "Files", + path: "~/.marker", + scope: .global, + projectRoot: nil, + environment: Environment(home: home) + ) + if case .pass = check.check() { + // expected + } else { + Issue.record("Expected .pass against the injected home, got \(check.check())") + } + } + @Test("File exists check fails for missing file") func fileExistsFail() { let check = ExternalFileExistsCheck( diff --git a/Tests/MCSTests/HomebrewTests.swift b/Tests/MCSTests/HomebrewTests.swift index 9c48f6c3..bbcddc00 100644 --- a/Tests/MCSTests/HomebrewTests.swift +++ b/Tests/MCSTests/HomebrewTests.swift @@ -15,6 +15,37 @@ struct HomebrewTests { #expect(Homebrew.bareName(of: package) == expected) } + // MARK: - allPrefixes + + @Test("allPrefixes lists this platform's Homebrew locations") + func allPrefixesPerPlatform() { + #if canImport(Darwin) + #expect(Homebrew.allPrefixes == ["/opt/homebrew", "/usr/local"]) + #else + // Asserted by shape, not by re-evaluating the source's own expression: `allPrefixes` is + // static and reads the process home, so a literal home cannot be injected here — that + // contract is pinned in EnvironmentTests instead. + let prefixes = Homebrew.allPrefixes + #expect(prefixes.count == 2) + #expect(prefixes[0] == "/home/linuxbrew/.linuxbrew") + #expect(prefixes[1].hasSuffix("/.linuxbrew")) + #expect(prefixes[1] != prefixes[0], "the single-user prefix is the user's home, not the shared one") + #endif + } + + // MARK: - Guidance when Homebrew is absent + + @Test("Install advice names the package and the platform's way to get it") + func manualInstallAdviceIsActionable() { + let advice = Homebrew.manualInstallAdvice(for: "ripgrep") + #expect(advice.contains("ripgrep")) + #if canImport(Darwin) + #expect(advice.contains("brew.sh")) + #else + #expect(advice.contains("system package manager")) + #endif + } + // MARK: - provides @Test("provides takes the PATH fast path without spawning brew") From 3cf95bfcffcf794a797a532582b24736c0f442cc Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Tue, 29 Sep 2026 23:18:24 +0200 Subject: [PATCH 05/28] Resolve the home directory from $HOME on both platforms - NSHomeDirectory() reads the passwd entry and ignores $HOME on Darwin too, so HOME=... mcs ... was silently ignored everywhere; user-typed ~ now expands against the same home as ~/.mcs - brewPrefix resolves symlinks then strips a trailing Homebrew component, so a shim and the installer's own layout both yield the prefix formulae link into Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu --- Sources/mcs/Commands/PackCommand.swift | 2 +- Sources/mcs/Core/Environment.swift | 67 ++++++++-- Sources/mcs/Core/GitignoreManager.swift | 11 +- .../mcs/ExternalPack/PackSourceResolver.swift | 5 +- Tests/MCSTests/EnvironmentTests.swift | 122 ++++++++++++++++++ Tests/MCSTests/PackSourceResolverTests.swift | 20 ++- Tests/MCSTests/ProjectDetectorTests.swift | 12 +- Tests/MCSTests/SyncCommandTests.swift | 4 +- 8 files changed, 218 insertions(+), 25 deletions(-) diff --git a/Sources/mcs/Commands/PackCommand.swift b/Sources/mcs/Commands/PackCommand.swift index 6227cd2b..4069f3f1 100644 --- a/Sources/mcs/Commands/PackCommand.swift +++ b/Sources/mcs/Commands/PackCommand.swift @@ -64,7 +64,7 @@ struct AddPack: LockedCommand { func perform() throws { let ctx = PackCommandContext() - let resolver = PackSourceResolver() + let resolver = PackSourceResolver(environment: ctx.env) let packSource: PackSource do { packSource = try resolver.resolve(source) diff --git a/Sources/mcs/Core/Environment.swift b/Sources/mcs/Core/Environment.swift index f1b12c37..40bee39d 100644 --- a/Sources/mcs/Core/Environment.swift +++ b/Sources/mcs/Core/Environment.swift @@ -30,7 +30,7 @@ struct Environment { private static let resolvedBrewPath: String? = resolveCommand("brew") init(home: URL? = nil) { - let home = home ?? URL(fileURLWithPath: NSHomeDirectory()) + let home = home ?? URL(fileURLWithPath: Self.defaultHomeDirectory()) homeDirectory = home let claudeDir = home.appendingPathComponent(Constants.FileNames.claudeDirectory) @@ -52,21 +52,70 @@ struct Environment { if let resolvedBrew = Self.resolvedBrewPath { brewPath = resolvedBrew - brewPrefix = URL(fileURLWithPath: resolvedBrew) - .resolvingSymlinksInPath() - .deletingLastPathComponent().deletingLastPathComponent().path + brewPrefix = Self.brewPrefix(forBrewPath: resolvedBrew) } else { - #if arch(arm64) - brewPrefix = "/opt/homebrew" - #else - brewPrefix = "/usr/local" - #endif + brewPrefix = Self.defaultBrewPrefix brewPath = "\(brewPrefix)/bin/brew" } gitPath = Self.resolvedGitPath } + /// The Homebrew prefix implied by the path `brew` was found at. + /// + /// Symlinks are resolved first so a shim elsewhere on PATH (`~/.local/bin/brew` pointing at the + /// real install) still yields the prefix formulae link into. The installer itself links + /// `$PREFIX/bin/brew -> ../Homebrew/bin/brew` on Linux and Intel macOS, which resolves to the + /// repository checkout rather than the prefix — hence the trailing `Homebrew` is stripped. + static func brewPrefix(forBrewPath path: String) -> String { + var prefix = URL(fileURLWithPath: path) + .resolvingSymlinksInPath() + .deletingLastPathComponent().deletingLastPathComponent() + if prefix.lastPathComponent == "Homebrew" { + prefix = prefix.deletingLastPathComponent() + } + return prefix.path + } + + /// The user's home directory, `$HOME` first. + /// + /// Foundation's `NSHomeDirectory()` resolves the passwd entry on Darwin and corelibs alike and + /// consults `$HOME` only when there is none, so `HOME=… mcs …` used to be ignored on every + /// platform. Preferring a non-empty `$HOME` is what makes containers, `sudo -H`-style launchers + /// and test sandboxes work. Takes the environment as a parameter so it can be tested as a pure + /// function. + static func defaultHomeDirectory( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> String { + guard let home = environment["HOME"], !home.isEmpty else { + return NSHomeDirectory() + } + return home + } + + /// Expands a leading `~` against `homeDirectory`, not the passwd entry Foundation's + /// `expandingTildeInPath` reads — otherwise a `~/…` pack path or doctor check would resolve + /// under a different home than `~/.mcs` whenever `$HOME` is overridden. + func expandingTilde(_ path: String) -> String { + if path == "~" { + return homeDirectory.path + } + guard path.hasPrefix("~/") else { return path } + return homeDirectory.appendingPathComponent(String(path.dropFirst(2))).path + } + + /// Where Homebrew installs itself when no `brew` is on PATH to ask. + static var defaultBrewPrefix: String { + #if canImport(Darwin) && arch(arm64) + "/opt/homebrew" + #elseif canImport(Darwin) + "/usr/local" + #else + // Linuxbrew's documented multi-user prefix; unlike macOS it does not vary by architecture. + "/home/linuxbrew/.linuxbrew" + #endif + } + /// Directory where external tech pack checkouts live (`~/.mcs/packs/`). var packsDirectory: URL { mcsDirectory.appendingPathComponent(Constants.ExternalPacks.packsDirectory) diff --git a/Sources/mcs/Core/GitignoreManager.swift b/Sources/mcs/Core/GitignoreManager.swift index 4f194182..5645a778 100644 --- a/Sources/mcs/Core/GitignoreManager.swift +++ b/Sources/mcs/Core/GitignoreManager.swift @@ -17,10 +17,11 @@ struct GitignoreManager { /// /// Every path here is anchored to `shell.environment.homeDirectory`, never to the process's /// own home. In production the two are the same (`Environment()` derives from - /// `NSHomeDirectory()`), but they differ under an injected `Environment(home:)` — and this - /// type both reads *and writes* the file, so resolving the process home let a sandboxed - /// caller mutate the real user's global gitignore. `HOME` is passed to `git config` for the - /// same reason: `--global` resolves `$HOME/.gitconfig`, which would otherwise escape too. + /// `Environment.defaultHomeDirectory()`), but they differ under an injected + /// `Environment(home:)` — and this type both reads *and writes* the file, so resolving the + /// process home let a sandboxed caller mutate the real user's global gitignore. `HOME` is + /// passed to `git config` for the same reason: `--global` resolves `$HOME/.gitconfig`, which + /// would otherwise escape too. func resolveGlobalGitignorePath() -> URL { let home = shell.environment.homeDirectory let result = shell.run( @@ -47,7 +48,7 @@ struct GitignoreManager { try fm.createDirectory(at: dir, withIntermediateDirectories: true) } if !fm.fileExists(atPath: path.path) { - fm.createFile(atPath: path.path, contents: nil) + try Data().write(to: path) } } diff --git a/Sources/mcs/ExternalPack/PackSourceResolver.swift b/Sources/mcs/ExternalPack/PackSourceResolver.swift index 82d4cd43..4ee0a4f6 100644 --- a/Sources/mcs/ExternalPack/PackSourceResolver.swift +++ b/Sources/mcs/ExternalPack/PackSourceResolver.swift @@ -35,6 +35,8 @@ struct PackSourceResolver { /// character to exclude path-like inputs such as `../foo` or `./bar`. static let shorthandPattern = #"^[a-zA-Z0-9][a-zA-Z0-9_.-]*/[a-zA-Z0-9][a-zA-Z0-9_.-]*$"# + var environment: Environment = .init() + func resolve(_ input: String) throws -> PackSource { guard !input.hasPrefix("-") else { throw PackSourceError.invalidInput("must not start with '-'") @@ -59,11 +61,10 @@ struct PackSourceResolver { input } - // expandingTildeInPath handles ~/... and is a no-op for other paths. // URL(fileURLWithPath:) resolves relative paths (../, ./) against CWD. // Extract .path first to get the absolute string, then re-wrap — // calling .standardized directly on a relative URL mangles ".." components. - let expanded = NSString(string: pathString).expandingTildeInPath + let expanded = environment.expandingTilde(pathString) let resolved = URL(fileURLWithPath: URL(fileURLWithPath: expanded).path) var isDir: ObjCBool = false diff --git a/Tests/MCSTests/EnvironmentTests.swift b/Tests/MCSTests/EnvironmentTests.swift index 1a5ddce9..3877324e 100644 --- a/Tests/MCSTests/EnvironmentTests.swift +++ b/Tests/MCSTests/EnvironmentTests.swift @@ -104,4 +104,126 @@ struct EnvironmentTests { #expect(!env.isInsideClaudeHome(env.homeDirectory)) } + + // MARK: - Home directory + + @Test("The default home directory prefers $HOME") + func defaultHomeDirectoryPrefersHOME() { + // Tested as a pure function rather than by mutating the process environment: swift-testing + // runs in parallel, and setenv would leak into every other test in flight. + #expect(Environment.defaultHomeDirectory(environment: ["HOME": "/sandbox/home"]) == "/sandbox/home") + } + + @Test("An unset or empty $HOME falls back to the passwd entry") + func defaultHomeDirectoryFallsBackToPasswd() { + #expect(Environment.defaultHomeDirectory(environment: [:]) == NSHomeDirectory()) + #expect(Environment.defaultHomeDirectory(environment: ["HOME": ""]) == NSHomeDirectory()) + } + + @Test("Environment derives every path from the resolved home") + func environmentUsesResolvedHome() { + let home = Environment.defaultHomeDirectory(environment: ["HOME": "/sandbox/home"]) + let env = Environment(home: URL(fileURLWithPath: home)) + + #expect(env.homeDirectory.path == "/sandbox/home") + #expect(env.claudeDirectory.path == "/sandbox/home/.claude") + #expect(env.mcsDirectory.path == "/sandbox/home/.mcs") + } + + @Test("The default initializer wires the resolved home through") + func defaultInitUsesDefaultHomeDirectory() { + #expect(Environment().homeDirectory.path == Environment.defaultHomeDirectory()) + } + + @Test("A leading tilde expands against the environment's home, not the passwd entry") + func expandingTildeUsesEnvironmentHome() { + let env = Environment(home: URL(fileURLWithPath: "/sandbox/home")) + + #expect(env.expandingTilde("~") == "/sandbox/home") + #expect(env.expandingTilde("~/packs/ios") == "/sandbox/home/packs/ios") + #expect(env.expandingTilde("/abs/path") == "/abs/path") + #expect(env.expandingTilde("relative/~/x") == "relative/~/x") + #expect(env.expandingTilde("~other/x") == "~other/x") + } + + // MARK: - Homebrew prefix + + @Test("brewPrefix strips the Homebrew repository component the installer's symlink resolves to") + func brewPrefixStripsRepositoryCheckout() throws { + let home = try makeTmpHome() + defer { try? FileManager.default.removeItem(at: home) } + + // The shape Linuxbrew and Intel macOS install: $PREFIX/bin/brew -> ../Homebrew/bin/brew. + let prefix = home.appendingPathComponent("prefix") + let repositoryBin = prefix.appendingPathComponent("Homebrew/bin") + let prefixBin = prefix.appendingPathComponent("bin") + try FileManager.default.createDirectory(at: repositoryBin, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: prefixBin, withIntermediateDirectories: true) + try Data().write(to: repositoryBin.appendingPathComponent("brew")) + try FileManager.default.createSymbolicLink( + atPath: prefixBin.appendingPathComponent("brew").path, + withDestinationPath: "../Homebrew/bin/brew" + ) + + // Resolving alone yields /Homebrew, whose bin holds only brew. + #expect(Environment.brewPrefix(forBrewPath: prefixBin.appendingPathComponent("brew").path) == prefix.path) + } + + @Test("brewPrefix follows a shim symlink back to the real prefix") + func brewPrefixFollowsShim() throws { + let home = try makeTmpHome() + defer { try? FileManager.default.removeItem(at: home) } + + // A user-made ~/.local/bin/brew -> /bin/brew with /bin off PATH: the + // prefix formulae link into is the target's, not the shim's. + let prefix = home.appendingPathComponent("opt/homebrew") + let prefixBin = prefix.appendingPathComponent("bin") + let shimBin = home.appendingPathComponent(".local/bin") + try FileManager.default.createDirectory(at: prefixBin, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: shimBin, withIntermediateDirectories: true) + try Data().write(to: prefixBin.appendingPathComponent("brew")) + try FileManager.default.createSymbolicLink( + atPath: shimBin.appendingPathComponent("brew").path, + withDestinationPath: prefixBin.appendingPathComponent("brew").path + ) + + let expected = prefix.resolvingSymlinksInPath().path + #expect(Environment.brewPrefix(forBrewPath: shimBin.appendingPathComponent("brew").path) == expected) + } + + @Test("brewPrefix handles a real file at $PREFIX/bin/brew (arm64 macOS shape)") + func brewPrefixForRealFile() { + #expect(Environment.brewPrefix(forBrewPath: "/opt/homebrew/bin/brew") == "/opt/homebrew") + } + + @Test("The fallback prefix is the platform's own default") + func defaultBrewPrefixPerPlatform() { + #if canImport(Darwin) && arch(arm64) + #expect(Environment.defaultBrewPrefix == "/opt/homebrew") + #elseif canImport(Darwin) + #expect(Environment.defaultBrewPrefix == "/usr/local") + #else + #expect(Environment.defaultBrewPrefix == "/home/linuxbrew/.linuxbrew") + #endif + } + + @Test("pathWithBrew prepends the prefix bin directory exactly once") + func pathWithBrewPrependsOnce() throws { + let home = try makeTmpHome() + defer { try? FileManager.default.removeItem(at: home) } + let env = Environment(home: home) + + let brewBin = "\(env.brewPrefix)/bin" + let currentPath = ProcessInfo.processInfo.environment["PATH"] ?? "/usr/bin:/bin" + let path = env.pathWithBrew + + // Asserted against the ambient PATH rather than a fixed shape: a developer whose profile + // is sourced twice genuinely has the brew bin in there more than once, and that is not a + // failure of this code. + if currentPath.contains(brewBin) { + #expect(path == currentPath, "an already-present brew bin must not be prepended again") + } else { + #expect(path.hasPrefix("\(brewBin):")) + } + } } diff --git a/Tests/MCSTests/PackSourceResolverTests.swift b/Tests/MCSTests/PackSourceResolverTests.swift index 6acb7c4e..897d8a9b 100644 --- a/Tests/MCSTests/PackSourceResolverTests.swift +++ b/Tests/MCSTests/PackSourceResolverTests.swift @@ -109,6 +109,22 @@ struct PackSourceResolverTests { #expect(url.standardizedFileURL.path == tmpDir.standardizedFileURL.path) } + @Test("A tilde path resolves under the injected home, not the passwd entry") + func tildeResolvesUnderInjectedHome() throws { + let home = try makeTmpDir() + defer { try? FileManager.default.removeItem(at: home) } + let pack = home.appendingPathComponent("packs/ios") + try FileManager.default.createDirectory(at: pack, withIntermediateDirectories: true) + + let resolver = PackSourceResolver(environment: Environment(home: home)) + let result = try resolver.resolve("~/packs/ios") + guard case let .localPath(url) = result else { + Issue.record("Expected .localPath, got \(result)") + return + } + #expect(url.standardizedFileURL.path == pack.standardizedFileURL.path) + } + @Test("file:// prefix is stripped and treated as local path") func fileScheme() throws { let tmpDir = try makeTmpDir() @@ -169,8 +185,8 @@ struct PackSourceResolverTests { // Change CWD to tmpDir so "org/pack" resolves to the directory let originalDir = FileManager.default.currentDirectoryPath - FileManager.default.changeCurrentDirectoryPath(tmpDir.path) - defer { FileManager.default.changeCurrentDirectoryPath(originalDir) } + #expect(FileManager.default.changeCurrentDirectoryPath(tmpDir.path)) + defer { #expect(FileManager.default.changeCurrentDirectoryPath(originalDir)) } let result = try PackSourceResolver().resolve("org/pack") guard case let .localPath(url) = result else { diff --git a/Tests/MCSTests/ProjectDetectorTests.swift b/Tests/MCSTests/ProjectDetectorTests.swift index b34827f0..a966db81 100644 --- a/Tests/MCSTests/ProjectDetectorTests.swift +++ b/Tests/MCSTests/ProjectDetectorTests.swift @@ -24,7 +24,11 @@ struct ProjectDetectorTests { try FileManager.default.createDirectory(at: sourcesDir, withIntermediateDirectories: true) let root = ProjectDetector.findProjectRoot(from: sourcesDir) - #expect(root?.standardizedFileURL == tmpDir.standardizedFileURL) + // Compared as paths, not URLs: corelibs Foundation flags a directory URL produced by + // deletingLastPathComponent() with a trailing slash that standardizedFileURL does not + // strip, so two URLs for the same directory are unequal on Linux. The product compares + // paths for the same reason (PathContainment, Environment.isInsideClaudeHome). + #expect(root?.standardizedFileURL.path == tmpDir.standardizedFileURL.path) } @Test("Finds project root via CLAUDE.local.md") @@ -41,7 +45,7 @@ struct ProjectDetectorTests { try FileManager.default.createDirectory(at: subDir, withIntermediateDirectories: true) let root = ProjectDetector.findProjectRoot(from: subDir) - #expect(root?.standardizedFileURL == tmpDir.standardizedFileURL) + #expect(root?.standardizedFileURL.path == tmpDir.standardizedFileURL.path) } @Test("Finds project root via .claude/.mcs-project") @@ -60,7 +64,7 @@ struct ProjectDetectorTests { try FileManager.default.createDirectory(at: subDir, withIntermediateDirectories: true) let root = ProjectDetector.findProjectRoot(from: subDir) - #expect(root?.standardizedFileURL == tmpDir.standardizedFileURL) + #expect(root?.standardizedFileURL.path == tmpDir.standardizedFileURL.path) } @Test("Prefers .git over CLAUDE.local.md at same level") @@ -78,7 +82,7 @@ struct ProjectDetectorTests { ) let root = ProjectDetector.findProjectRoot(from: tmpDir) - #expect(root?.standardizedFileURL == tmpDir.standardizedFileURL) + #expect(root?.standardizedFileURL.path == tmpDir.standardizedFileURL.path) } } diff --git a/Tests/MCSTests/SyncCommandTests.swift b/Tests/MCSTests/SyncCommandTests.swift index 5c8ae471..1be3b973 100644 --- a/Tests/MCSTests/SyncCommandTests.swift +++ b/Tests/MCSTests/SyncCommandTests.swift @@ -356,7 +356,7 @@ struct SyncCommandGuardTests { let env = Environment(home: home) let originalCwd = FileManager.default.currentDirectoryPath - defer { FileManager.default.changeCurrentDirectoryPath(originalCwd) } + defer { #expect(FileManager.default.changeCurrentDirectoryPath(originalCwd)) } let cmd = try SyncCommand.parse([env.claudeDirectory.path, "--global", "--pack", "foo"]) let effective = try cmd.guardClaudeHomeCwd(env: env, output: silentOutput()) @@ -364,7 +364,7 @@ struct SyncCommandGuardTests { let resultCwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) .resolvingSymlinksInPath() - #expect(resultCwd == env.homeDirectory.resolvingSymlinksInPath()) + #expect(resultCwd.path == env.homeDirectory.resolvingSymlinksInPath().path) } @Test("Guard triggers for nested paths like ~/.claude/skills") From 0bdc461bee62810f7de4c93e201f19275944aef7 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Tue, 29 Sep 2026 23:18:24 +0200 Subject: [PATCH 06/28] Offer an upgrade command that matches how mcs was installed - brew upgrade only exists where mcs came from the tap; Linux gets a staged, version-verified tarball swap at the path /proc/self/exe resolves - With nothing runnable the hook names the releases page and asks no question, instead of emitting an empty "On yes, run:" Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu --- Sources/mcs/Core/Constants.swift | 8 +++ Sources/mcs/Core/UpdateChecker.swift | 69 ++++++++++++++++++++++--- Tests/MCSTests/UpdateCheckerTests.swift | 34 ++++++++++-- 3 files changed, 102 insertions(+), 9 deletions(-) diff --git a/Sources/mcs/Core/Constants.swift b/Sources/mcs/Core/Constants.swift index a4c8db9a..4f28923c 100644 --- a/Sources/mcs/Core/Constants.swift +++ b/Sources/mcs/Core/Constants.swift @@ -160,6 +160,14 @@ enum Constants { /// The Homebrew formula name for mcs. static let brewFormula = "mcs-cli/tap/mcs" + + /// Where the release tarballs are published. + static let releasesURL = "https://github.com/mcs-cli/mcs/releases/latest" + + /// The download URL of one asset attached to a tagged release. + static func assetURL(tag: String, asset: String) -> String { + "https://github.com/mcs-cli/mcs/releases/download/\(tag)/\(asset)" + } } // MARK: - Plugins diff --git a/Sources/mcs/Core/UpdateChecker.swift b/Sources/mcs/Core/UpdateChecker.swift index d8a700f7..e0072fe2 100644 --- a/Sources/mcs/Core/UpdateChecker.swift +++ b/Sources/mcs/Core/UpdateChecker.swift @@ -25,7 +25,52 @@ struct UpdateChecker { static let hookTimeout: Int = 30 static let hookStatusMessage = "Checking for updates..." - static let cliUpgradeCommands = ["brew update", "brew upgrade \(Constants.MCSRepo.brewFormula)"] + /// Commands that upgrade the mcs binary in place, or `[]` when this install offers no upgrade + /// path mcs can hand over — the caller then points at the releases page instead. + /// + /// Claude runs these after asking the user, so the Linux form has to be safe in a shell it does + /// not own: the tarball member goes to a sibling temp file rather than over the live binary, + /// its `--version` runs before anything is replaced, and the final `mv` is a same-directory + /// rename, which is atomic and legal while the old binary is still mapped. + static func cliUpgradeCommands(toVersion version: String) -> [String] { + #if canImport(Darwin) + ["brew update", "brew upgrade \(Constants.MCSRepo.brewFormula)"] + #else + guard let path = installedBinaryPath else { return [] } + let directory = (path as NSString).deletingLastPathComponent + let staged = "\(directory)/.mcs-upgrade" + let url = Constants.MCSRepo.assetURL(tag: version, asset: "mcs-\(version)-linux-\(releaseArch).tar.gz") + let script = "curl -fsSL \(url) | tar -xzO mcs > \(staged)" + + " && chmod +x \(staged) && \(staged) --version && mv -f \(staged) \(path)" + // Writing as the user keeps the binary's ownership; sudo only where the directory forbids it. + return FileManager.default.isWritableFile(atPath: directory) ? [script] : ["sudo sh -c '\(script)'"] + #endif + } + + /// The architecture slug used in the Linux release asset names. + static var releaseArch: String { + #if arch(arm64) + "aarch64" + #else + "x86_64" + #endif + } + + /// Where this process's binary lives, read from the kernel rather than `argv[0]`, which a + /// caller controls. Darwin has no equivalent that is worth trusting here, and does not need + /// one: Homebrew owns the upgrade there. + static var installedBinaryPath: String? { + #if canImport(Darwin) + nil + #else + do { + return try FileManager.default.destinationOfSymbolicLink(atPath: "/proc/self/exe") + } catch { + return nil + } + #endif + } + static let manualPackUpdateCommand = "mcs update --all-projects" static let hookPackUpdateCommand = "mcs update -a --trust-all" @@ -600,10 +645,11 @@ struct UpdateChecker { } else { // User-invoked: colored output for terminal readability if let cli = result.cliUpdate { - output.warn( - "mcs \(cli.latestVersion) available (current: \(cli.currentVersion)). " - + "Run '\(cliUpgradeCommands.joined(separator: " && "))' to update." - ) + let commands = cliUpgradeCommands(toVersion: cli.latestVersion) + let how = commands.isEmpty + ? "Download the latest release from \(Constants.MCSRepo.releasesURL)." + : "Run '\(commands.joined(separator: " && "))' to update." + output.warn("mcs \(cli.latestVersion) available (current: \(cli.currentVersion)). " + how) } if !result.packUpdates.isEmpty { let noun = result.packUpdates.count == 1 ? "pack update" : "pack updates" @@ -636,7 +682,14 @@ struct UpdateChecker { lines.append( "- New mcs version \(cli.latestVersion) is available (installed: \(cli.currentVersion))." ) - commands += cliUpgradeCommands + let upgrade = cliUpgradeCommands(toVersion: cli.latestVersion) + if upgrade.isEmpty { + lines.append( + " There is no upgrade command for this install; it has to be downloaded from" + + " \(Constants.MCSRepo.releasesURL)." + ) + } + commands += upgrade } let hasPackUpdates = !result.packUpdates.isEmpty if hasPackUpdates { @@ -647,6 +700,10 @@ struct UpdateChecker { } lines.append("- To stop these notifications, run: mcs config set update-check false") lines.append("") + guard !commands.isEmpty else { + lines.append("There is nothing to run, so do not ask a question; continue with the user's request.") + return lines.joined(separator: "\n") + } lines.append( "Then make the AskUserQuestion tool your first tool call: one yes/no question asking whether to" + " apply all of these updates now. If AskUserQuestion is unavailable, ask in plain text and" diff --git a/Tests/MCSTests/UpdateCheckerTests.swift b/Tests/MCSTests/UpdateCheckerTests.swift index 766ec59e..bcb083f9 100644 --- a/Tests/MCSTests/UpdateCheckerTests.swift +++ b/Tests/MCSTests/UpdateCheckerTests.swift @@ -1218,7 +1218,8 @@ struct UpdateCheckerContextStringTests { let context = UpdateChecker.buildContextString( UpdateChecker.CheckResult(packUpdates: [], cliUpdate: cliUpdate) ) - #expect(context.contains("On yes, run: \(UpdateChecker.cliUpgradeCommands.joined(separator: " && "))\n")) + let upgrade = UpdateChecker.cliUpgradeCommands(toVersion: "2.0.0") + #expect(context.contains("On yes, run: \(upgrade.joined(separator: " && "))\n")) #expect(context.contains("AskUserQuestion")) #expect(context.contains("mcs config set update-check false")) #expect(!context.contains("--trust-all")) @@ -1235,19 +1236,46 @@ struct UpdateCheckerContextStringTests { #expect(context.contains("On yes, run: \(UpdateChecker.hookPackUpdateCommand)\n")) #expect(context.contains("without review")) #expect(context.contains(UpdateChecker.manualPackUpdateCommand)) - #expect(!context.contains(UpdateChecker.cliUpgradeCommands[1])) + for command in UpdateChecker.cliUpgradeCommands(toVersion: "2.0.0") { + #expect(!context.contains(command)) + } let doctor = try #require(context.range(of: "'mcs doctor'")) let cleanup = try #require(context.range(of: "mcs cleanup -af")) #expect(doctor.lowerBound < cleanup.lowerBound) #expect(context.contains("mcs doctor --fix --yes")) } + @Test("The upgrade command matches how this platform ships mcs") + func upgradeCommandShapePerPlatform() throws { + let commands = UpdateChecker.cliUpgradeCommands(toVersion: "2.0.0") + #if canImport(Darwin) + #expect(commands == ["brew update", "brew upgrade \(Constants.MCSRepo.brewFormula)"]) + #else + let path = try #require(UpdateChecker.installedBinaryPath) + let command = try #require(commands.first) + #expect(commands.count == 1) + #expect(command.contains("mcs-2.0.0-linux-\(UpdateChecker.releaseArch).tar.gz")) + // Staged, verified, then renamed — nothing is ever written over the live binary. + #expect(command.contains("--version &&")) + #expect(command.hasSuffix(path)) + #endif + } + + @Test("Nothing to run means no question is asked") + func noCommandsSkipTheAsk() { + let context = UpdateChecker.buildContextString( + UpdateChecker.CheckResult(packUpdates: [], cliUpdate: nil) + ) + #expect(!context.contains("On yes, run:")) + #expect(!context.contains("AskUserQuestion")) + } + @Test("CLI upgrade chains before the pack update and the request resumes last") func cliBeforePacks() { let context = UpdateChecker.buildContextString( UpdateChecker.CheckResult(packUpdates: [packUpdate], cliUpdate: cliUpdate) ) - let chain = (UpdateChecker.cliUpgradeCommands + [UpdateChecker.hookPackUpdateCommand]) + let chain = (UpdateChecker.cliUpgradeCommands(toVersion: "2.0.0") + [UpdateChecker.hookPackUpdateCommand]) .joined(separator: " && ") #expect(context.contains("On yes, run: \(chain)\n")) #expect(context.hasSuffix("continue with the user's original request.")) From 33f3dbfb2f56165e15ef89e83217ef49c165c173 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Tue, 29 Sep 2026 23:20:52 +0200 Subject: [PATCH 07/28] Build, test and release on Linux for both architectures - PR checks and the release build run on ubuntu-22.04 and ubuntu-22.04-arm, so x86_64 and aarch64 ship from one release with one SHA256SUMS - One composite action installs the toolchain for every Linux job - A manual Linux Smoke workflow exercises whole command flows against a real pack; it is what the compatibility matrix rests on Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu --- .github/actions/setup-swift-linux/action.yml | 34 +++++ .github/workflows/linux-smoke.yml | 140 ++++++++++++++++++ .github/workflows/pr-checks.yml | 51 +++++++ .github/workflows/release.yml | 142 +++++++++++++++++-- 4 files changed, 356 insertions(+), 11 deletions(-) create mode 100644 .github/actions/setup-swift-linux/action.yml create mode 100644 .github/workflows/linux-smoke.yml diff --git a/.github/actions/setup-swift-linux/action.yml b/.github/actions/setup-swift-linux/action.yml new file mode 100644 index 00000000..31cc85df --- /dev/null +++ b/.github/actions/setup-swift-linux/action.yml @@ -0,0 +1,34 @@ +name: Set up Swift on Linux +description: Install a pinned Swift toolchain on an Ubuntu runner via swiftly and put it on PATH. + +inputs: + swift-version: + description: The toolchain version swiftly installs and selects (for example 6.2.0). + required: true + +runs: + using: composite + steps: + - name: Install the toolchain's system dependencies + # swiftly refuses to install a toolchain until Swift's documented Ubuntu packages are + # present; libcurl4-openssl-dev is the one ubuntu-latest lacks. + shell: bash + run: sudo apt-get update && sudo apt-get install -y libcurl4-openssl-dev + + - name: Install Swift via swiftly + # Unpacked outside the checkout so the tree the caller builds and tests in stays clean. + shell: bash + env: + SWIFT_VERSION: ${{ inputs.swift-version }} + run: | + cd "$RUNNER_TEMP" + curl -O "https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz" + tar zxf "swiftly-$(uname -m).tar.gz" + ./swiftly init --quiet-shell-followup --assume-yes --skip-install + # env.sh lives under $XDG_DATA_HOME when that is set; fail loudly rather than silently + # continuing without swiftly on PATH if the layout ever moves. + ENV_SH="${XDG_DATA_HOME:-$HOME/.local/share}/swiftly/env.sh" + test -f "$ENV_SH" || { echo "::error::swiftly env.sh not found at $ENV_SH"; exit 1; } + . "$ENV_SH" + swiftly install --use "$SWIFT_VERSION" --assume-yes + echo "$SWIFTLY_BIN_DIR" >> "$GITHUB_PATH" diff --git a/.github/workflows/linux-smoke.yml b/.github/workflows/linux-smoke.yml new file mode 100644 index 00000000..46244988 --- /dev/null +++ b/.github/workflows/linux-smoke.yml @@ -0,0 +1,140 @@ +name: Linux Smoke + +# Manual only: this is the Linux acceptance check behind docs/linux-support.md's compatibility +# matrix. It exercises whole command flows against a real pack, which the unit suite cannot. +on: + workflow_dispatch: + inputs: + runner: + description: Runner to smoke-test on + required: true + default: ubuntu-22.04 + type: choice + options: + - ubuntu-22.04 + - ubuntu-22.04-arm + +permissions: + contents: read + +env: + # Keep in sync with pr-checks.yml. + SWIFT_VERSION: '6.2.0' + +jobs: + smoke: + name: Smoke (${{ inputs.runner }}) + runs-on: ${{ inputs.runner }} + steps: + - uses: actions/checkout@v7 + + - uses: ./.github/actions/setup-swift-linux + with: + swift-version: ${{ env.SWIFT_VERSION }} + + - name: Build + run: | + swift build -c release + echo "MCS=$(swift build -c release --show-bin-path)/mcs" >> "$GITHUB_ENV" + + # A stand-in for the Claude Code CLI: every command below calls ensureClaudeCLI, which + # refuses to run without it, and nothing here asserts on what Claude itself does. + - name: Stub the claude CLI + run: | + mkdir -p "$HOME/.local/bin" + printf '#!/bin/sh\nexit 0\n' > "$HOME/.local/bin/claude" + chmod +x "$HOME/.local/bin/claude" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + # A local pack: exercises the same manifest, trust and artifact paths as a git pack without + # depending on a network fetch inside the smoke test. + - name: Create a fixture pack + run: | + mkdir -p "$RUNNER_TEMP/fixture-pack/skills/greet" "$RUNNER_TEMP/project" + cat > "$RUNNER_TEMP/fixture-pack/techpack.yaml" <<'PACK' + schemaVersion: 1 + identifier: smoke + displayName: Smoke Pack + description: Fixture pack for the Linux smoke test + prompts: + - key: GREETING + type: input + prompt: Greeting to use + default: hello + components: + - id: greet-skill + displayName: Greet skill + description: Skill whose body is substituted from a prompt value + skill: + source: skills/greet + destination: greet + - id: git-present + displayName: git + description: A formula whose name is also its command, so the PATH probe satisfies it + brew: git + PACK + cat > "$RUNNER_TEMP/fixture-pack/skills/greet/SKILL.md" <<'SKILL' + --- + name: greet + description: Says __GREETING__ + --- + Says __GREETING__. + SKILL + cat > "$RUNNER_TEMP/project/mcs.yaml" <> "$GITHUB_ENV" + + - name: Verify binary + env: + TAG_NAME: ${{ github.ref_name }} + run: | + BUILT_VERSION=$("${BIN_DIR}/mcs" --version) + if [ "$BUILT_VERSION" != "$TAG_NAME" ]; then + echo "::error::Built binary reports version '${BUILT_VERSION}' but tag is '${TAG_NAME}'" + exit 1 + fi + # The asset name encodes the arch, so a mismatch would ship a binary nobody can run. + BUILT_ARCH=$(uname -m) + if [ "$BUILT_ARCH" != "${{ matrix.arch }}" ]; then + echo "::error::Runner is ${BUILT_ARCH} but the asset is named ${{ matrix.arch }}" + exit 1 + fi + echo "Binary verified: ${BUILT_VERSION} (${BUILT_ARCH})" + + - name: Prepare artifact + env: + TAG_NAME: ${{ github.ref_name }} + run: tar -czf "mcs-${TAG_NAME}-linux-${{ matrix.arch }}.tar.gz" -C "${BIN_DIR}" mcs + + - uses: actions/upload-artifact@v7 + with: + name: mcs-linux-${{ matrix.arch }} + path: mcs-${{ github.ref_name }}-linux-${{ matrix.arch }}.tar.gz + if-no-files-found: error + + release: + name: Release + needs: [build-macos, build-linux, release-notes] + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v8 + with: + merge-multiple: true + + - name: Checksum artifacts env: TAG_NAME: ${{ github.ref_name }} run: | - TARBALL="mcs-${TAG_NAME}-macos-universal.tar.gz" - tar -czf "${TARBALL}" -C "${BIN_DIR}" mcs - shasum -a 256 "${TARBALL}" > SHA256SUMS - echo "TARBALL=${TARBALL}" >> "$GITHUB_ENV" + # sha256sum, not shasum: identical " " output and present on every runner. + sha256sum *.tar.gz > SHA256SUMS + cat SHA256SUMS + echo "TARBALL=mcs-${TAG_NAME}-macos-universal.tar.gz" >> "$GITHUB_ENV" - name: Create GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG_NAME: ${{ github.ref_name }} - TARBALL: ${{ env.TARBALL }} NOTES: ${{ needs.release-notes.outputs.notes }} run: | gh release create "${TAG_NAME}" \ + --repo "${{ github.repository }}" \ --verify-tag \ --notes "${NOTES}" \ - "${TARBALL}" SHA256SUMS + *.tar.gz SHA256SUMS - name: Update Homebrew tap env: @@ -155,10 +270,15 @@ jobs: TARBALL: ${{ env.TARBALL }} if: env.HOMEBREW_TAP_TOKEN != '' run: | - SHA=$(shasum -a 256 "${TARBALL}" | cut -d' ' -f1) + # The formula keeps pointing at the macOS tarball; Linux ships as plain release assets. + SHA=$(sha256sum "${TARBALL}" | cut -d' ' -f1) git clone "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/mcs-cli/homebrew-tap.git" tap cd tap + # This job has no checkout and Ubuntu runners have dotless hostnames, so git cannot + # auto-detect an author and would refuse the commit. Same identity as auto-release.yml. + git config user.email "autorelease@mcs-cli.dev" + git config user.name "MCS Auto Release" cat > Formula/mcs.rb << FORMULA class Mcs < Formula desc "Configure Claude Code with MCP servers, plugins, skills, and hooks" From af65af2f3e8d7969c38980be4cd30200e1fbc799 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Tue, 29 Sep 2026 23:24:38 +0200 Subject: [PATCH 08/28] Document Linux support and the platform differences - docs/linux-support.md records the supported configurations, the compatibility matrix and an ADR per platform decision; telemetry and the lockfile are gone from main, so neither needs a Linux entry - CLAUDE.md, README and the docs name both architectures and the six files allowed to hold platform-dependent values Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu --- CLAUDE.md | 23 +- README.md | 22 +- Sources/mcs/TechPack/HookInterpreter.swift | 5 +- docs/README.md | 4 +- docs/architecture.md | 18 +- docs/creating-tech-packs.md | 4 +- docs/linux-support.md | 478 +++++++++++++++++++++ docs/techpack-schema.md | 10 +- docs/troubleshooting.md | 33 +- 9 files changed, 579 insertions(+), 18 deletions(-) create mode 100644 docs/linux-support.md diff --git a/CLAUDE.md b/CLAUDE.md index 04a08dc1..30801b72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,8 @@ Swift CLI tool (`mcs`) that configures Claude Code with MCP servers, plugins, sk # Development swift build # Build the CLI swift test # Run tests -swift build -c release --arch arm64 --arch x86_64 # Universal binary +swift build -c release --arch arm64 --arch x86_64 # macOS universal binary +swift build -c release --static-swift-stdlib # Linux release binary # CLI usage (after install) mcs sync [path] # Sync project: multi-select packs, compose artifacts (default command) @@ -67,7 +68,7 @@ mcs config set # Set a configuration value (true/false) ## Architecture ### Swift Package Structure -- **Package.swift** — swift-tools-version: 6.0, macOS 13+, deps: swift-argument-parser, Yams +- **Package.swift** — swift-tools-version: 6.0, macOS 13+ and Linux (glibc), deps: swift-argument-parser, Yams, swift-crypto - **Sources/mcs/** — main executable target - **Tests/MCSTests/** — test target @@ -79,14 +80,17 @@ mcs config set # Set a configuration value (true/false) - `Environment.swift` — paths, arch detection, brew path, claude-home cwd detection (`isInsideClaudeHome(_:)`) - `CLIOutput.swift` — ANSI colors, logging, prompts, multi-select, doctor summary - `ShellRunner.swift` — Process execution wrapper +- `PTYBridge.swift` — the per-descriptor `poll(2)` decisions of the interactive PTY bridge, pure so every `revents` combination is table-tested - `Settings.swift` — Codable model for `settings.json` and `settings.local.json`, deep-merge - `Backup.swift` — timestamped backups for mixed-ownership files (CLAUDE.local.md), backup discovery and deletion - `GitignoreManager.swift` — global gitignore management, core entry list - `ClaudeIntegration.swift` — `claude mcp add/remove` (with scope support), `claude plugin install/uninstall/list` (scope + working directory, like MCP) and `marketplace add/list` -- `ClaudePrerequisite.swift` — Claude Code CLI availability check with optional Homebrew auto-install +- `ClaudePrerequisite.swift` — Claude Code CLI availability check; macOS offers a Homebrew install, Linux prints the native-installer and npm commands - `Homebrew.swift` — brew detection, package install/uninstall, and `provides(_:)` — the one availability predicate shared by `ComponentExecutor` and `BrewPackageCheck` (PATH under `bareName(of:)`, falling back to `brew list`) -- `FileHasher.swift` — SHA-256 file and directory hashing via CryptoKit (used by `PackTrustManager` and `ComponentExecutor`) +- `FileHasher.swift` — SHA-256 file and directory hashing via swift-crypto's `Crypto`, which re-exports CryptoKit on Darwin (used by `PackTrustManager` and `ComponentExecutor`) - `FileLock.swift` — POSIX `flock()` process lock and `LockedCommand` protocol for mutually exclusive CLI commands +- `Locked.swift` — `NSLock`-backed value box with the `withLock { $0 }` shape of the Darwin-only `OSAllocatedUnfairLock` (`Synchronization.Mutex` is macOS 15+, above the macOS 13 floor) +- `TerminalAttributes.swift` — termios helpers for the raw-mode picker; `c_cc` is indexed through the platform's own `VMIN`/`VTIME` and flag masks are converted through `tcflag_t` - `PathContainment.swift` — centralized path-boundary checks and relative-path utilities (symlink-safe containment, traversal prevention) - `PluginRef.swift` — parsed `name@repo` plugin references; resolves the `name@` id Claude Code keys plugins by (`@org/repo` needs a `marketplace list` lookup), plus the `plugin list` / `marketplace list` JSON models - `ProjectDetector.swift` — walk-up project root detection (`.git/` or `CLAUDE.local.md`) @@ -95,7 +99,7 @@ mcs config set # Set a configuration value (true/false) - `ProjectIndex.swift` — cross-project index (`~/.mcs/projects.yaml`) mapping project paths to pack IDs for reference counting - `MCSError.swift` — error types for the CLI - `MCSConfig.swift` — user preferences (`~/.mcs/config.yaml`): `update-check`. Load is pure (never writes); the one-shot migration from the deprecated `update-check-packs` / `update-check-cli` pair to the unified `update-check` key is persisted by callers via `persistMigrationIfNeeded` on write-safe paths only (skipped on dry-run and SessionStart-hook reads). -- `UpdateChecker.swift` — pack freshness checks (`git ls-remote`), CLI version checks (`git ls-remote --tags`), cooldown management +- `UpdateChecker.swift` — pack freshness checks (`git ls-remote`), CLI version checks (`git ls-remote --tags`), cooldown management, and the platform's self-upgrade command (`brew upgrade` on macOS; on Linux a staged-and-verified tarball swap at the path `/proc/self/exe` resolves, or nothing when it cannot) ### TechPack System (`Sources/mcs/TechPack/`) - `TechPack.swift` — protocol for tech packs (components, templates, hooks, doctor checks, project configuration) @@ -186,6 +190,15 @@ swiftlint --fix - CI runs both in strict mode (warnings become errors) with GitHub Actions inline annotations - SwiftLint excludes `Tests/` — only Sources and Package.swift are linted - **Never use `try?` to silently discard errors** — use `do/catch` and surface the error (via `output.warn()`, logging, or propagation). `try?` hides root causes and makes debugging impossible. The only acceptable use is when the absence of a result is the *entire* semantic meaning (e.g., `FileManager.fileExists` alternative) +- **Platform support**: `mcs` builds for macOS 13+ and Linux (glibc). The canonical shape is +```swift +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif +``` + Platform-dependent **values and control flow** live only in `Core/TerminalAttributes.swift`, `Core/Environment.swift`, `Core/Homebrew.swift`, `Core/Constants.swift`, `Core/ClaudePrerequisite.swift` and `Core/UpdateChecker.swift` — everything else calls into them. `TerminalAttributes` is on the list without a single `#if`: it owns the termios layout, which differs per platform through `VMIN`/`VTIME`/`tcflag_t` rather than through a branch. `ClaudePrerequisite` and `UpdateChecker` are there because what differs is control flow, not a value: macOS offers a Homebrew install of Claude Code and a `brew upgrade` of mcs itself, and Linux can do neither, so there is no constant to move. The `#if canImport(Darwin) / #elseif canImport(Glibc)` import chain at the top of a file that calls libc directly (`ShellRunner`, `PTYBridge`, `CLIOutput`, `FileLock`, `GlobMatcher`) is not platform knowledge in this sense — it selects the same API from a different module — and is allowed wherever it is needed. SHA-256 comes from swift-crypto's `Crypto` on both platforms; it re-exports CryptoKit on Darwin, so no `#if` is needed to hash. A `#if` is for a value or API that genuinely differs, never to make a diagnostic go away; the same goes for `_ =`. Several Foundation methods are `@discardableResult` on Darwin and not on Linux — use the result, it always means something. A test gated out on one platform is a coverage regression: give the `#else` branch the equivalent assertion. Rationale and the compatibility matrix are in `docs/linux-support.md` - **Comments carry the non-obvious "why", not a narration of the code** — don't restate the line below, don't describe what the code used to do. If the code already says it, delete the comment; if the rationale needs more than a line or two, it belongs in the issue or a memory. State a given rationale once, at the site that owns it, rather than repeating it at every call site ## Testing diff --git a/README.md b/README.md index 92579595..a353ca5e 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ [![Swift 6.0+](https://img.shields.io/badge/Swift-6.0+-F05138.svg?logo=swift&logoColor=white)](https://swift.org) [![macOS](https://img.shields.io/badge/macOS-13+-000000.svg?logo=apple&logoColor=white)](https://www.apple.com/macos/) +[![Linux](https://img.shields.io/badge/Linux-glibc-FCC624.svg?logo=linux&logoColor=black)](docs/linux-support.md) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![Homebrew](https://img.shields.io/badge/Homebrew-tap-FBB040.svg?logo=homebrew&logoColor=white)](https://github.com/mcs-cli/homebrew-tap) [![Tech Packs](https://img.shields.io/badge/Tech_Packs-Browse-8B5CF6.svg)](https://techpacks.mcs-cli.dev) @@ -21,10 +22,25 @@ ### 1. Install +**macOS** — Homebrew: + ```bash brew install mcs-cli/tap/mcs ``` +**Linux** (x86_64 or aarch64, glibc 2.35+, `libstdc++6`) — download the release tarball from +[the latest release](https://github.com/mcs-cli/mcs/releases/latest): + +```bash +# ...-linux-x86_64.tar.gz or ...-linux-aarch64.tar.gz +tar -xzf mcs--linux-.tar.gz +install -D -m 0755 mcs ~/.local/bin/mcs +``` + +Supported platforms: macOS 13+ and Linux (glibc, x86_64 and aarch64). See +[Linux support](docs/linux-support.md) for prerequisites, the compatibility matrix and the +handful of behaviours that differ. + ### 2. Add tech packs Browse available packs at **[techpacks.mcs-cli.dev](https://techpacks.mcs-cli.dev)**, then add them: @@ -156,7 +172,7 @@ Use `mcs pack validate` to check your pack for structural errors and best practi ## 🔍 Verifying Your Setup with Poirot -After `mcs sync`, want to confirm everything landed correctly? [**Poirot**](https://github.com/leonardocardoso/poirot) is a native macOS companion that gives you a visual overview of your Claude Code configuration — MCP servers, settings, sessions, and more — all in one place. +After `mcs sync`, want to confirm everything landed correctly? [**Poirot**](https://github.com/leonardocardoso/poirot) (macOS only) is a native companion that gives you a visual overview of your Claude Code configuration — MCP servers, settings, sessions, and more — all in one place. The perfect complement to `mcs`: configure your environment with `mcs`, then use Poirot to see exactly what's installed and running. @@ -171,6 +187,7 @@ The perfect complement to `mcs`: configure your environment with `mcs`, then use | 📋 [Tech Pack Schema](docs/techpack-schema.md) | Complete `techpack.yaml` field reference | | 🏗️ [Architecture](docs/architecture.md) | Internal design, sync flow, safety guarantees, and extension points | | 🔧 [Troubleshooting](docs/troubleshooting.md) | Common issues and fixes | +| 🐧 [Linux support](docs/linux-support.md) | Supported configurations, prerequisites, compatibility matrix, and porting decisions | --- @@ -179,7 +196,8 @@ The perfect complement to `mcs`: configure your environment with `mcs`, then use ```bash swift build # Build swift test # Run tests -swift build -c release --arch arm64 --arch x86_64 # Universal binary +swift build -c release --arch arm64 --arch x86_64 # macOS universal binary +swift build -c release --static-swift-stdlib # Linux release binary ``` See [Architecture](docs/architecture.md) for project structure and design decisions. diff --git a/Sources/mcs/TechPack/HookInterpreter.swift b/Sources/mcs/TechPack/HookInterpreter.swift index 4e19c5e0..e6f87195 100644 --- a/Sources/mcs/TechPack/HookInterpreter.swift +++ b/Sources/mcs/TechPack/HookInterpreter.swift @@ -33,7 +33,10 @@ enum HookInterpreter { /// author at publish time to declare `hookInterpreter` explicitly. private static let ambiguousExtensions: Set = ["ts", "mts", "cts", "tsx"] - /// Interpreters whose absence is not worth reporting — always present on macOS. + /// Interpreters whose absence is not worth reporting — always present on macOS. `zsh` is not + /// installed by default on Debian, Ubuntu or Fedora, so a `.zsh` hook's missing interpreter goes + /// unreported there; a per-platform set would be a sixth home for platform knowledge for one + /// entry, so this is listed as a known limitation instead. private static let assumedPresent: Set = ["bash", "sh", "zsh"] /// Longest interpreter string accepted, guarding against a pathological manifest value. diff --git a/docs/README.md b/docs/README.md index c1dd37de..e0f917e4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,7 @@ Creating your own shareable Claude Code configuration: How mcs works under the hood: 1. [Architecture](architecture.md) — Package structure, sync flow, convergence model, safety guarantees, and extension points +2. [Linux support](linux-support.md) — Supported configurations, prerequisites, the per-feature compatibility matrix, and the decisions behind every platform branch ## Quick Links @@ -37,7 +38,8 @@ How mcs works under the hood: | Diagnose issues | [CLI > mcs doctor](cli.md#mcs-doctor) | | Understand internals | [Architecture](architecture.md) | | Fix a problem | [Troubleshooting](troubleshooting.md) | +| Run mcs on Linux | [Linux support](linux-support.md) | --- -[Home](README.md) | [CLI Reference](cli.md) | [Creating Tech Packs](creating-tech-packs.md) | [Schema](techpack-schema.md) | [Architecture](architecture.md) | [Troubleshooting](troubleshooting.md) +[Home](README.md) | [CLI Reference](cli.md) | [Creating Tech Packs](creating-tech-packs.md) | [Schema](techpack-schema.md) | [Architecture](architecture.md) | [Troubleshooting](troubleshooting.md) | [Linux support](linux-support.md) diff --git a/docs/architecture.md b/docs/architecture.md index c184026b..e1492003 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,7 +5,7 @@ This document describes the internal architecture of `mcs` for contributors and ## Package Structure ``` -Package.swift # swift-tools-version: 6.0, macOS 13+ +Package.swift # swift-tools-version: 6.0, macOS 13+ and Linux (glibc) Sources/mcs/ CLI.swift # @main entry, version, subcommand registration Core/ # Shared infrastructure @@ -32,7 +32,7 @@ The primary command is **`mcs sync`**, which handles both global and per-project ### Environment (`Core/Environment.swift`) -Central path resolution for all file locations. Detects architecture (arm64/x86_64), resolves Homebrew path, and locates the user's shell RC file. Key paths: +Central path resolution for all file locations. Detects architecture (arm64/x86_64), resolves the Homebrew path, and locates the user's shell RC file. The Homebrew prefix is platform-dependent — `/opt/homebrew` or `/usr/local` on macOS, `/home/linuxbrew/.linuxbrew` on Linux — and is derived from a `brew` on `PATH` when there is one, resolving symlinks and stripping a trailing `Homebrew` component (see [Linux support, D7](linux-support.md#d7--homebrew-on-linux)). Key paths: - `~/.claude/` — Claude Code configuration directory - `~/.claude/settings.json` — user settings (global) @@ -95,6 +95,14 @@ Written by `mcs sync` after convergence. | **Format** | JSON | JSON | | **Tracks** | Globally installed components, pack IDs, file hashes | Per-pack artifact records, configured pack IDs | +### Locked (`Core/Locked.swift`) + +A value guarded by an `NSLock`, with the `withLock { $0 }` shape of `OSAllocatedUnfairLock`, which is Darwin-only. Used by `WarningCounter` and by `ScriptRunner`'s timeout flag. `Synchronization.Mutex` would be the modern answer but is macOS 15+, above this package's deployment target. + +### TerminalAttributes (`Core/TerminalAttributes.swift`) + +termios helpers for the raw-mode picker. `c_cc` is a fixed-size tuple whose length and element order are platform-defined and `tcflag_t` differs in width, so the control-character index comes from the platform's own `VMIN`/`VTIME` and every flag mask is widened through `tcflag_t`. + ### Backup (`Core/Backup.swift`) Before modifying files with user content (e.g., `CLAUDE.local.md`), a timestamped backup is created (e.g., `CLAUDE.local.md.backup.20260222_143000`). Tool-managed files are not backed up since they can be regenerated. The `mcs cleanup` command discovers and deletes these backups. @@ -373,9 +381,13 @@ The command (`Commands/ExportCommand.swift`) is a read-only `ParsableCommand` (n The codebase uses Swift 6's strict concurrency. All core types conform to `Sendable`. `TechPack` is a `Sendable` protocol. No mutable global state exists outside the installer's in-progress mutation context. +## Platform Support + +`mcs` builds for macOS 13+ and for Linux (glibc, x86_64 and aarch64). Platform-dependent knowledge is confined to `Core/TerminalAttributes.swift`, `Core/Environment.swift`, `Core/Homebrew.swift`, `Core/Constants.swift`, `Core/ClaudePrerequisite.swift` and `Core/UpdateChecker.swift`; everything else calls into them. `swift-crypto` supplies SHA-256 on both platforms — it re-exports CryptoKit on Darwin, so no conditional import is needed. See [Linux support](linux-support.md) for the compatibility matrix, the decisions behind each platform branch, and the rules for adding a new one. + --- -**Next**: Having issues? See [Troubleshooting](troubleshooting.md). +**Next**: Having issues? See [Troubleshooting](troubleshooting.md). Running on Linux? See [Linux support](linux-support.md). --- diff --git a/docs/creating-tech-packs.md b/docs/creating-tech-packs.md index ba9029c8..c3bded5f 100644 --- a/docs/creating-tech-packs.md +++ b/docs/creating-tech-packs.md @@ -122,6 +122,8 @@ components: When a user runs `mcs sync`, these get installed via `brew install`. The engine auto-verifies them with `mcs doctor`, which looks for the command on `PATH` and falls back to `brew list` — so a cask, a versioned formula like `node@22`, or a formula whose command is spelled differently (`ripgrep` installs `rg`) still verifies correctly. +> **On Linux, Homebrew is usually absent.** Verification then has only the `PATH` probe, so a `brew:` component is satisfied only when the **formula name is also the command name**: `brew: node` passes when `node` is on `PATH`, `brew: ripgrep` does not, because the command is `rg`. mcs cannot install it either — that needs [Linuxbrew](https://docs.brew.sh/Homebrew-on-Linux) — so `mcs doctor` reports the package missing and `mcs doctor --fix` names the system package manager as the way to get it. Prefer formulae whose name matches their command when you can, and say in your pack's description which tools a Linux user has to install themselves. There is no per-platform component gating in `techpack.yaml` yet; see [Linux support](linux-support.md). + A tap-qualified package (`owner/tap/formula`) works too, but note that installing one taps a third-party repository without asking anyone; `mcs pack validate` warns when your pack declares one. Need to depend on Homebrew itself? That's a special case — Homebrew can't install itself, so use `shell:` with an explicit doctor check. In a project sync this component runs after every `brew:` component, so it cannot bootstrap them; it only helps later components and global syncs: @@ -129,7 +131,7 @@ Need to depend on Homebrew itself? That's a special case — Homebrew can't inst ```yaml - id: homebrew displayName: Homebrew - description: macOS package manager + description: macOS package manager (this component is macOS-only in practice) type: brewPackage shell: '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"' doctorChecks: diff --git a/docs/linux-support.md b/docs/linux-support.md new file mode 100644 index 00000000..02590c63 --- /dev/null +++ b/docs/linux-support.md @@ -0,0 +1,478 @@ +# Linux support + +`mcs` runs on macOS and on Linux (glibc). This document records what is verified, what differs +between the two platforms, and why each porting decision was made. + +Sections 1–3 and 6 describe the platform. Section 4 is the per-feature compatibility checklist: +a row is filled in only after that feature has been **run** on Linux. Section 5 carries one +ADR-style entry per decision. + +## 1. Status and supported configurations + +`mcs` is built and tested on **Linux with glibc**, on x86_64 and aarch64. Every feature works, and +section 4 records how each one was verified; a handful behave differently from macOS, and the matrix +says how. + +| | | +|---|---| +| Architecture | x86_64 and aarch64, one release tarball each. | +| libc | glibc. musl is untested; there is no `canImport(Musl)` branch. | +| glibc floor | 2.35 — both binaries are built on Ubuntu 22.04. | +| Tested on | Ubuntu 24.04 (development); the `ubuntu-22.04` and `ubuntu-22.04-arm` GitHub runners in CI. | +| Other distributions | Any glibc ≥ 2.35 distribution is expected to work. **Nothing is claimed about Fedora, Alpine or NixOS** — they have not been tested. | + +CI runs `swift build`, the full test suite and the release build on both Linux architectures for +every pull request, alongside the two macOS jobs. The `Linux Smoke` workflow (manual dispatch) runs +whole command flows against a real pack; it is what the matrix below rests on. Lint runs on macOS only: SwiftFormat and SwiftLint give the same verdicts on both, so a +second run would only add version skew. + +## 2. Prerequisites + +`mcs` shells out to a few tools by absolute path and assumes they exist: + +| Path | Used for | Present on | +|---|---|---| +| `/usr/bin/which` | resolving every command name to a path | Ubuntu/Debian (`debianutils`), most distributions | +| `/usr/bin/env` | invoking the `claude` CLI | coreutils | +| `/bin/bash` | running `shell:` components and pack scripts | every mainstream distribution | +| `libstdc++.so.6` | the published binary links against it dynamically | most distributions; minimal containers need `apt-get install -y libstdc++6` | + +**If `/usr/bin/which` is missing** (it is absent on NixOS, and Debian has been retiring it), +`ShellRunner.resolvedPath` returns `nil` for *every* command, so `Homebrew.provides`, the Claude Code +prerequisite and every command-based doctor check report "not found". Measured with `which` masked +on a machine where Claude Code is genuinely installed: + +| Command | Result | +|---|---| +| `mcs sync` | **exits 1 and does nothing** — "Claude Code CLI not found", then the install instructions | +| `mcs update` | **exits 1 and does nothing** — same message | +| `mcs doctor` | exits 0, reporting everything as missing | + +So the two commands that change anything refuse to run and tell the user to install software they +already have. See ADR D8 for why this is documented rather than worked around. + +Homebrew is **optional** on Linux. Without it, `brew:` components are verified through `PATH` only +and cannot be installed by mcs — see the `brew:` row in section 4 and ADR D7. + +## 3. Install, build and test on Linux + +### Install + +Download the tarball for the release you want from +, unpack it, and put `mcs` on your `PATH`: + +```bash +tar -xzf mcs--linux-x86_64.tar.gz +install -D -m 0755 mcs ~/.local/bin/mcs +mcs --version +``` + +There is no Homebrew formula for Linux: the tap publishes the macOS universal binary only. +`mcs check-updates` therefore tells Linux users to download the latest release rather than to run +`brew upgrade`. + +### Build from source + +```bash +# Install a toolchain, e.g. via swiftly (https://swift.org/install/linux/) +swift build +swift test +swift build -c release --static-swift-stdlib # what the release workflow ships +``` + +`swift test` output does not display in some terminals, so redirect it and read the file: + +```bash +mkdir -p .test-output && swift test > .test-output/results.txt 2>&1 +``` + +`.test-output/` is gitignored. + +**Do not run the suite as root.** `UpdateCheckerTests.registryWriteFailureContract` makes a +directory read-only and asserts that a write into it fails; root ignores directory permissions, so +that one test fails under `sudo` or in a root container. This is why the CI job runs on the +`ubuntu-latest` runner rather than in a `swift:*` container image. + +**Run it with `--no-parallel` on Linux.** CI does, for the reason in the known limitations below; +without it roughly one full run in four fails with a spurious `ETXTBSY`. Serial costs about a +quarter more wall-clock time (42s against 34s here). + +## 4. Compatibility matrix + +Legend: `verified` — run on Linux and observed; `verified (with a difference)` — works, but not identically to macOS; the Notes column says how. + +| Feature | macOS | Linux | Notes | +|---|---|---|---| +| `mcs sync` (project) | supported | verified | `mcs sync --pack linux-probe` in a git project: components installed, `settings.local.json` composed, `CLAUDE.local.md` generated, `.mcs-project` written. | +| `mcs sync --global` | supported | verified | `mcs sync --global --pack git-probe`: artifacts under `~/.claude/`, `settings.json` composed, `global-state.json` written. | +| Raw-mode pickers | supported | verified | Driven under a real PTY: `↓` moved the cursor, `Space` toggled, `Enter` applied; in-place redraw and cursor hide/show correct. A hangup mid-prompt answers No and warns rather than spinning. | +| Non-TTY fallback picker | supported | verified | stdin a PTY, stdout a pipe: numeric toggle + `Enter`, colours disabled. | +| `shell:` components | supported | verified | `shell: touch ` created the marker. | +| `shellInteractive: true` (PTY/sudo) | supported | verified | `forkpty` path ran the command; marker file created, with stdin from `/dev/null` and under a real controlling terminal. Also asserted by `LifecycleIntegrationTests`. The PTY is allocated with a default 0×0 window size on both platforms. | +| `brew:` components | supported | verified (with a difference) | Same predicate on both platforms. Without Linuxbrew a `brew:` component is satisfied only when the formula name is also the command name — `brew: node` passes with `node` on PATH, `brew: ripgrep` does not because the command is `rg`. Doctor then reports it missing, and `--fix` names the system package manager instead of pointing back at `mcs sync`. Installing a formula still needs Linuxbrew. | +| `mcp:` (`claude mcp add`) | supported | verified | `demo-server` registered via `claude mcp add -s local`; `claude mcp list` shows it; removal deregisters it. | +| `plugin:` | supported | verified | `hookify@claude-code-plugins` installed through `claude plugin install`; `claude plugin list` shows it enabled. | +| `hook:` / `command:` / `skill:` / `agent:` copies | supported | verified | All four installed; hooks namespaced under `/`; interpreter inferred (`bash` for `.sh`, `python3` for `.py`). | +| `settingsFile:` | supported | verified | Deep-merged into `settings.local.json` with `__GREETING__` substituted. | +| `gitignore:` | supported | verified | Entry added to `~/.config/git/ignore`; reference counting kept it when one of two scopes was removed. | +| `mcs update` | supported | verified | `mcs update --project --trust-all` re-fetched the pack and re-applied the scope. | +| `mcs doctor` | supported | verified | 20 checks across every section; `✓`/`⚠` rendering correct. | +| `mcs doctor --fix` | supported | verified | Deleting `.claude` from the global gitignore then `doctor --fix` re-added it (`GitignoreCheck.fix`). | +| `mcs pack add` (git) | supported | verified | `mcs pack add git://…/gitprobe` cloned, prompted for trust, registered. | +| `mcs pack add` (local) | supported | verified | `mcs pack add ` registered in place with `commitSHA: local`. | +| `mcs pack remove` / `list` / `update` / `validate` | supported | verified | `remove` unconfigured every affected scope; `update` reported "already up to date"; `validate` produced the python3 heuristic warning. | +| Pack trust hashing | supported | verified | Trust prompt shown on add for both a local and a git pack; re-validated on `pack update` (SHA-256 now from swift-crypto). | +| `mcs export` (incl. brew formula hints) | supported | verified (with a difference) | Exported `techpack.yaml`, hooks, skill, command, agent, settings and template from a live project. The brew formula hints are empty without Linuxbrew — `detectFormula` reads symlinks under the Linuxbrew prefixes. | +| `mcs cleanup` | supported | verified | Found and deleted a `CLAUDE.local.md.backup.*` file, with and without `--force`. | +| `mcs check-updates` + SessionStart hook | supported | verified (with a difference) | `check-updates`, `--json` and `--hook` all run; `mcs config set update-check true` registered `mcs check-updates --hook` in `~/.claude/settings.json` and the cooldown file was written. The upgrade command differs: `brew upgrade` on macOS, a staged tarball swap on Linux — see ADR D12. | +| `mcs config` | supported | verified | `list` / `get` / `set` against `~/.mcs/config.yaml`. | +| `$HOME` override | supported (behavior change) | verified | `mcs` resolves its home from `$HOME`, falling back to the passwd entry, on both platforms — including a `~` typed in a pack path or a pack's doctor `path:`. Previously `$HOME` was ignored everywhere (ADR D11). | +| File lock (`flock`) | supported | verified | Two concurrent syncs: the second exited 1 with "Another mcs process is running". | +| Terminal colours / width | supported | verified | ANSI colour and the wrapped/re-rendered picker observed under a PTY; colours suppressed when stdout is a pipe. | +| Claude Code prerequisite | supported | verified (with a difference) | With `claude` off PATH, Linux prints the native-installer and npm commands and returns false. macOS still offers the Homebrew install; `claude-code` is a cask and Linuxbrew has no casks. | +| Release artifact | `.tar.gz` (universal) | verified (with a difference) | `swift build -c release --static-swift-stdlib` produces one ~95 MB binary per architecture; each tarball holds exactly one file and `./mcs --version` prints the version. `ldd` still shows `libstdc++.so.6`, `libgcc_s.so.1`, `libm`, `libc` and `ld-linux`. | + +## 5. Decisions (ADR entries) + +Each entry records the context, the options considered, the decision, and what it costs. + +### D1 — swift-crypto supplies SHA-256 on both platforms + +**Context.** Three files hash with `SHA256.hash(data:)`. CryptoKit is Apple-only, so Linux needs +another source for the digest. + +**Options considered.** (a) An unconditional swift-crypto dependency and `import Crypto` everywhere. +(b) A Linux-only *target* dependency plus a `#if canImport(CryptoKit)` import shim in each file. +(c) Hand-roll SHA-256. (d) Shell out to `sha256sum`. + +**Decision: (a).** + +```swift +.product(name: "Crypto", package: "swift-crypto") +``` + +On Apple platforms swift-crypto compiles its API surface down to nothing and re-exports CryptoKit, so +`import Crypto` *is* CryptoKit there: same implementation, bit-identical digests for the hashes +persisted in `.mcs-project`. Its manifest gates `CCryptoBoringSSL` and friends on non-Darwin +platforms, so the 13 MB C library is not compiled on macOS either. What (a) buys over (b) is that +four files lose a conditional-import block and the platform rule loses a clause. + +**Why not (c)/(d).** Hand-rolled crypto is a maintenance liability even for a hash; shelling out +costs a process per file and makes `FileHasher` untestable offline. + +**Consequences.** `swift package resolve` fetches **two** more repositories on macOS — swift-crypto +and its transitive `swift-asn1`. The CI cache key is `hashFiles('Package.swift')`, so that is paid +once per manifest change. `Package.resolved` is gitignored, so `from: "3.0.0"` floats across 3.x — +the same exposure the two existing dependencies already have. SwiftPM does emit a +`swift-crypto_Crypto.bundle` beside the release binary, holding only `PrivacyInfo.xcprivacy`; the +release tarball still ships the single `mcs` file, and a binary copied away from that bundle runs +`--version`, `pack validate` (which hashes) and `doctor` unchanged — verified before choosing (a). +`FileHasherTests` and `SettingsHasherTests` each pin a known digest, so a divergence between the two +implementations fails the suite instead of silently invalidating state files. + +### D2 — `Locked` replaces `OSAllocatedUnfairLock` + +**Context.** `import os` is Darwin-only. Two sites used `OSAllocatedUnfairLock`: the warning counter +behind `CLIOutput` and a one-shot timeout flag in `ScriptRunner`. + +**Options considered.** (a) `NSLock` inline at both sites. (b) One small `Locked`. +(c) `Synchronization.Mutex`. (d) A serial `DispatchQueue`. (e) An actor. + +**Decision: (b).** One named type reads better than two bespoke lock/unlock pairs, and it is twenty +lines. **Why not (c):** `Mutex` is macOS 15+, and this package's floor is macOS 13 — raising it would +drop macOS 13 and 14 users for a lock. **Why not (e):** `ScriptRunner` reads its flag synchronously +after `waitUntilExit()`; an actor forces `await` into a synchronous path. + +**Consequences.** `NSLock` is marginally slower than an unfair lock. Both sites are cold. The +`@unchecked Sendable` on `Locked` is the type's purpose rather than a way to quiet the checker: the +invariant — every access happens under the lock — is real and cannot be expressed to the compiler, +and the doc comment says so at the one site that owns it. + +### D3 — Platform imports are an explicit `#if canImport` chain, per file + +**Context.** Five files call libc directly. On macOS they got those symbols from Foundation's Darwin +re-export or an outright `import Darwin`. + +**Options considered.** (a) A chain in each file. (b) A `Platform.swift` with `@_exported import`. +(c) A C shim target. + +**Decision: (a)**, verbatim in each of the five files: + +```swift +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif +``` + +**Why not (b).** `@_exported` is an underscored attribute, and it hides from every reader of +`ShellRunner.swift` that the file depends on libc. **Why not (c).** Nothing is missing from +SwiftGlibc — see D4. No `canImport(Musl)` branch is added: nothing here builds for musl, and an +untested branch is worse than no branch. + +**Consequence beyond the imports.** Two Foundation methods — `fileExists(atPath:isDirectory:)` and +`createFile(atPath:contents:)` — are `@discardableResult` on Darwin and not on Linux, so their +results had to be used rather than dropped, at four call sites. Three now require the file to exist +before treating it as a directory: identical in practice, and now defined, where Apple documents +`isDirectory` as undefined for a missing path. The fourth is a small +**macOS-visible change**: `GitignoreManager`'s bootstrap used `createFile(atPath:contents:)`, whose +`false` return was ignored, and now writes through `Data` — so a failure to create the global +gitignore throws out of `ensureFileExists()` instead of passing silently and failing later at the +read. + +### D4 — `forkpty()` is called from Glibc; no C shim, no `posix_openpt` rewrite + +**Context.** `ShellRunner.runInteractive` allocates a real PTY so `sudo` can read a password. The +open question was whether SwiftGlibc exposes `forkpty`. + +**Decision: it does.** `pty.h` is in `SwiftGlibc.h.gyb`, the Glibc modulemap declares `link "util"`, +and the call links and runs on glibc 2.39 with no extra linker flag. `cfmakeraw` is exposed too. + +**Why not the alternatives.** A C shim or `@_silgen_name` would work around a declaration that is +not missing; reimplementing with `posix_openpt`/`grantpt`/`setsid`/`ioctl(TIOCSCTTY)` duplicates +forty lines of subtle libc behaviour for no benefit. + +**Consequences.** One real difference did surface inside the fork child: glibc declares the +`execve` and `chdir` parameters `__nonnull`, so Swift imports them as non-optional and rejects the +`Optional` element of the `argv` array that Darwin's unannotated signatures accept (`strdup` itself +imports as an implicitly unwrapped optional on both). A force-unwrap there would trap through the +Swift runtime after `fork()`, where only async-signal-safe calls are legal, so every C string the +child needs is allocated and checked in the parent. If a future toolchain drops `pty.h` the build fails +immediately and the C shim remains a ~15-line fallback. + +### D5 — termios: index `c_cc` through `VMIN`/`VTIME`, widen masks through `tcflag_t` + +**Context.** The raw-mode picker set `raw.c_cc.16` and `raw.c_cc.17` with `// VMIN` / `// VTIME` +comments, and masked with `~UInt(ICANON | ECHO)`. Both are Darwin facts: `tcflag_t` is `UInt` there +and `UInt32` on Glibc, and `c_cc` is a tuple whose length and order differ (`NCCS` is 20 against 32, +`VMIN` is index 16 against 6). + +**Options considered.** (a) `#if canImport(Darwin) raw.c_cc.16 = 1 #else raw.c_cc.6 = 1 #endif`. +(b) A small `TerminalAttributes` type that binds `c_cc` as `cc_t` memory and subscripts it with the +platform's own constant. + +**Decision: (b).** (a) is a `#if` hiding a real difference behind two magic numbers; indexing by +`VMIN` has no second place to get wrong. `withUnsafeMutableBytes` + `bindMemory(to: cc_t.self)` is +sound — `c_cc` is already a tuple of `cc_t` — and nothing in Foundation or the stdlib does it for +you. `TerminalAttributesTests` asserts the same things on both platforms without needing a TTY, +which is why the read accessor exists; its doc comment says so, so it is not deleted as dead code. + +### D6 — `TIOCGWINSZ` widens to `UInt` + +`TIOCGWINSZ` imports as `Int32` on Glibc and `UInt` on Darwin, and `ioctl` takes a `UInt` request on +both. `ioctl(STDOUT_FILENO, UInt(TIOCGWINSZ), &ws)` compiles everywhere and is a no-op conversion on +Darwin, so no `#if` is needed. In the same file `Darwin.read(...)` loses its module qualifier: +unqualified `read` resolves to libc on both platforms, because `CLIOutput` has a `write` member but +no `read` member. + +### D7 — Homebrew on Linux + +**Context.** mcs resolves a Homebrew prefix, puts its `bin` on the PATH it hands to subprocesses, +and verifies `brew:` components. Linuxbrew installs at `/home/linuxbrew/.linuxbrew`, and most Linux +users have no Homebrew at all. + +**Decisions.** + +1. **The prefix derivation resolves symlinks, then strips a trailing `Homebrew` component.** + Homebrew's installer creates `$PREFIX/bin/brew -> ../Homebrew/bin/brew` on Linux *and on Intel + macOS*; only arm64 macOS has a real file there. Resolving alone therefore yields the repository + checkout, `$PREFIX/Homebrew`, whose `bin` holds only `brew`, and `pathWithBrew` prepended that + useless directory. Not resolving at all would instead break a shim — `~/.local/bin/brew` pointing + at the real install with `$PREFIX/bin` off PATH — by reporting the shim's directory as the prefix. + Resolve-then-strip handles arm64, Intel, Linuxbrew and shims alike. `brewPrefix` feeds only + `pathWithBrew`; formula detection walks `Homebrew.allPrefixes`. +2. **The fallback prefix and `allPrefixes` are platform-aware.** Linux gets + `/home/linuxbrew/.linuxbrew` and `~/.linuxbrew`. No `brew --prefix` subprocess is spawned. +3. **`Homebrew.provides` does not change.** It probes PATH under the name with any tap qualifier + stripped, then asks `brew list`. **The consequence, stated plainly: on Linux without Linuxbrew a + `brew:` component is satisfied only when the formula name is also the command name.** `brew: node` + passes when `node` is on PATH; `brew: ripgrep` does not, because the command is `rg`. There is no + alias table, because no function can map formula names to command names in general; adding + heuristics would trade a clear rule for a list that is always incomplete. +4. **The messages say what actually works.** When Homebrew is absent, telling the user to run + `mcs sync` is a loop with no exit: sync prints "Homebrew not found" and sends them back to doctor. + `ComponentExecutor` (install and uninstall) and `BrewPackageCheck.fix()` now name the package and + the system package manager. The command-exists check drops its `mcs sync` hint entirely, because + nothing there knows which component — if any — would install that command. +5. **`ensureClaudeCLI` never offers Homebrew on Linux.** `claude-code` is a Homebrew *cask*, and + Linuxbrew has no casks, so the offer would have failed on exactly the machines that have brew. + Linux prints the native-installer and npm commands and returns `false`. + +**Rejected.** Auto-installing Linuxbrew (a package manager installing a package manager, unprompted) +and auto-installing Claude Code via `curl | bash` or npm (a trust decision mcs cannot make for the +user; npm's global prefix may be root-owned). Also rejected, for now: a `platforms:` key in +`techpack.yaml` so a pack could mark a component macOS-only. It is the right long-term answer, but it +is a manifest schema change with ecosystem impact and is not needed to make mcs work on Linux. + +### D8 — `/usr/bin/which` stays, documented as a prerequisite + +**Context.** `ShellRunner.resolvedPath` and `Environment.resolveCommand` shell out to +`/usr/bin/which`. It is present on Ubuntu 24.04, absent on NixOS, and Debian has been retiring it. + +**Options considered.** (a) Keep it and document it. (b) Replace it with an in-process PATH scan. +(c) Keep it and add a `mcs doctor` check that fails loudly when it is missing. + +**Decision: (a).** (b) widens a portability PR with a behaviour change to a hot path shared by every +command. (c) sounds small but is not: `FileExistsCheck.fix()` returns "Run 'mcs sync' to install", +which is exactly the dead-end message decision D7.4 removes, so it would need a new parameter; the +check would then print a permanently-passing line in every `mcs doctor` run on *both* platforms; and +touching doctor mandates an integration test. Three files and a test, for a condition that does not +occur on the platforms mcs ships for. + +**Consequences.** The failure mode is documented in section 2 rather than detected, and it is worse +than "reports an empty machine": `mcs sync` and `mcs update` refuse to run and blame a missing Claude +Code CLI that is in fact installed. That is a bad failure, and it is why the decision is documented +prominently rather than left implicit — but it does not change the arithmetic, because the doctor +check considered in (c) would not have helped either. `mcs doctor` is the one command that still +completes, so a user who runs it sees a `✗ which` line; a user who runs `sync` gets the misleading +error and never reaches doctor. Detecting it where it actually bites means a check in `sync`'s +prerequisite path, which is a different change from the one (c) proposed. + +### D9 — No Subprocess 1.0, no tools-version bump, macOS 13 floor stays + +swift-tools-version 6.0 already supports `.product(…, condition: .when(platforms:))`, and +swift-crypto is tools-version 5.9 / macOS 10.15, so nothing forces a bump. Adopting Subprocess 1.0 +would rewrite `ShellRunner`, `ScriptRunner`, `PackFetcher`, `ClaudeIntegration` and `UpdateChecker` +against an async API, push `async` through `ParsableCommand.run()`, and require re-verifying the PTY +story — a rewrite of the process layer in the middle of a port, for no benefit to this goal. The +platforms list stays `[.macOS(.v13)]`; Linux needs no entry. Raising the floor to macOS 15 would let +`Synchronization.Mutex` replace `Locked` (see D2), but that drops macOS 13 and 14 users, so it is a +separate decision rather than a side effect of this port. + +### D10 — One release, three artifacts + +The release workflow builds the macOS universal binary and both Linux binaries in parallel and +publishes them from a single `release` job, so one `SHA256SUMS` covers all three and the Homebrew tap +is updated once. The tap formula keeps pointing at the macOS tarball; Linux ships as plain release +assets, because Linuxbrew is not how a Linux user is expected to install mcs. +**Consequence: the release is now all-or-nothing across platforms** — a Linux build or test failure +blocks the macOS tarball, the GitHub release and the tap update, which could not happen before. That +is the intended trade (a half-published release is worse than a late one), and it is why `test-linux` +runs before `build-linux` rather than after the release is cut. + +### D11 — The home directory comes from `$HOME`, falling back to the passwd entry + +**Context.** Every path mcs owns hangs off one home directory. Foundation's `NSHomeDirectory()` +resolves the passwd entry first on Darwin and corelibs alike, and consults `$HOME` only when there +is no passwd entry (`CFFIXED_USER_HOME`, when set, replaces both). So on *every* platform a +`HOME=… mcs …` invocation — what a container, a CI runner and `sudo -H` all set up — silently wrote +to the real user's `~/.claude`, `~/.mcs` and global gitignore. Verified on macOS with a compiled +probe: `HOME=/tmp/x` still returned the passwd home. + +**Options considered.** (a) Leave it and document it. (b) Prefer a non-empty `$HOME`, falling back +to `NSHomeDirectory()`. (c) Also route the two tilde-expansion sites through the same home. + +**Decision: (b) and (c).** This is a deliberate behavior change on macOS as much as on Linux: an +invocation whose `$HOME` differs from the passwd home — `sudo` with `env_keep`, a launchd agent, a +sandboxed test — now reads and writes under `$HOME`. `Environment.defaultHomeDirectory(environment:)` +takes the environment as a parameter so it can be tested as a pure function — swift-testing runs in +parallel, so a test that called `setenv` would leak into every other test in flight. +`Homebrew.allPrefixes` uses the same helper, so the single-user Linuxbrew prefix cannot drift from +it. The two places that expand a tilde a *user* typed — a path given to `mcs pack add`, and a pack's +doctor `path:` — go through `Environment.expandingTilde(_:)` rather than Foundation's +`expandingTildeInPath`, so `~/pack` and `~/.mcs` can never name different homes. + +**Consequences.** `HOME= mcs …` is now a working sandbox for the whole binary. A `$HOME` that +points at a missing directory is not validated; `mcs sync --global` creates the tree, `mcs doctor` +reports everything under it as missing with the path shown. `~user/…` is not expanded — neither +site ever accepted it. + + +### D12 — The Linux upgrade command is a staged, verified tarball swap + +**Context.** The SessionStart hook tells Claude what to run if the user agrees to update. On macOS +that is `brew update && brew upgrade mcs-cli/tap/mcs`. Linux installs from a release tarball, so +there is no package manager to delegate to, and a CLI-only update would otherwise render an empty +`On yes, run:`. + +**Options considered.** (a) Emit no command and describe the tarball in prose. (b) Emit a command +built from the binary's own path. (c) Suppress CLI update checks on Linux entirely. + +**Decision: (b).** `UpdateChecker.cliUpgradeCommands(toVersion:)` reads `/proc/self/exe` — the +kernel's answer, not `argv[0]`, which a caller controls — and builds one command that downloads the +tagged asset for `releaseArch`, writes the tarball member to a *sibling* temp file, runs `--version` +on it, and only then `mv`s it over the target. The rename is same-directory, so it is atomic and +legal while the old binary is still mapped. `sudo sh -c` wraps it only when the directory is not +writable, which keeps the binary's ownership in the common case. + +**Why not (a).** Claude is the one running these commands; handing it prose where every other update +is a command makes the CLI update the odd one out and easy to skip. + +**Why not (c).** A Linux user would never learn a new version exists. + +**Consequences.** When the path cannot be resolved the function returns `[]`, and the hook then names +the releases page and asks no question at all — the ask is gated on having something to run. A +truncated download fails `--version` before anything is replaced. The command is only as correct as +the asset naming in `release.yml`, so `build-linux` fails the job if the runner's `uname -m` and the +asset's architecture disagree. +## 6. Known limitations + +- **glibc ≥ 2.35**, because both release binaries are built on Ubuntu 22.04. musl is untested, and + there is no `canImport(Musl)` branch. +- **The binary is ~95 MB and needs `libstdc++6`.** `--static-swift-stdlib` links the *Swift* runtime + statically only; `ldd` still shows `libstdc++.so.6`, `libgcc_s.so.1`, `libm`, `libc` and + `ld-linux`. A minimal container needs `apt-get install -y libstdc++6`. A fully static binary would + need the Static Linux SDK (musl), which would require a `canImport(Musl)` branch in every import + chain and a musl verification pass. +- **`/usr/bin/which` is required**, with the "everything reports not found" symptom described in + section 2. +- **No Linuxbrew auto-install**, and **no Claude Code auto-install** on Linux. +- **`brew:` components are name-sensitive** without Linuxbrew — see D7 decision 3. +- **A `.zsh` hook's missing interpreter is not reported on Linux.** `HookInterpreter` treats `zsh` + as always present, which is true on macOS and false on Debian, Ubuntu and Fedora by default. Packs + that ship zsh hooks should say so; a per-platform set was not worth a sixth home for platform + knowledge. +- **`mcs export`'s brew formula hints are empty** without Linuxbrew: `detectFormula` reads symlinks + under the Homebrew prefixes. +- **The PTY is allocated with a 0×0 window size** — the same on macOS, so full-screen TUIs run inside + a `shellInteractive` component see no size. Line-oriented programs like `sudo` are unaffected. +- **`techpack.yaml` cannot mark a component macOS-only.** A pack declaring `brew: mas` will run on + Linux and fail that component with a clear message. See D7's rejected options. +- **The test suite runs serially on Linux (`swift test --no-parallel`).** corelibs Foundation opens + files for writing without `O_CLOEXEC` — measured with `strace` on both its atomic path + (`openat(…, ".dat.nosyncXXXX", O_RDWR|O_CREAT|O_EXCL, 0666)`) and its non-atomic one + (`openat(…, "b.sh", O_WRONLY|O_CREAT|O_TRUNC, 0666)`) — so when one test forks a subprocess while + another is mid-write, the child inherits that write descriptor and a later `exec` of the same file + fails with `ETXTBSY`, surfacing as `NSCocoaErrorDomain 256`. Measured at about one failure in four + full parallel runs. **mcs itself is unaffected**: it never executes a file it has just written. + Since the leak is in Foundation rather than in mcs, there is no `open`-side fix available the way + there was for the process lock, and the interfering forks come from unrelated suites, so marking + one suite `.serialized` would not close it. The macOS jobs stay parallel. +- **Nothing is claimed about Fedora, Alpine or NixOS** — they are untested. + +## 7. How to add a platform-specific path + +The canonical shape, which the repo's `.swiftformat` (`--ifdef noindent`) leaves alone: + +```swift +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif +``` + +Three rules keep this from spreading: + +1. **Platform-dependent values and control flow live in six places only** — + `Core/TerminalAttributes.swift`, `Core/Environment.swift`, `Core/Homebrew.swift`, + `Core/Constants.swift`, `Core/ClaudePrerequisite.swift` and `Core/UpdateChecker.swift`. + Everything else calls into them. If a seventh file needs a `#if` around a *value or a branch*, + that is a sign it belongs in one of these. `TerminalAttributes` is on the list without holding a + single `#if`: it owns the termios layout, which differs through `VMIN`/`VTIME`/`tcflag_t` rather + than through a branch. `ClaudePrerequisite` and `UpdateChecker` are on it because what differs + there is *control flow*, not a value: macOS can offer a Homebrew install of Claude Code and a + `brew upgrade` of mcs itself, Linux can do neither, so there is no constant to move. + The import chain above is the one `#if` this rule does not cover: a file that calls libc + directly (`ShellRunner`, `PTYBridge`, `CLIOutput`, `FileLock`, `GlobMatcher`, and + `FileLockTests`) carries it at the top, per D3. It selects the same API from a different module + and encodes no platform behaviour. SHA-256 needs no `#if` at all — swift-crypto's `Crypto` serves + both platforms, per D1. +2. **A `#if` is for a value or API that genuinely differs**, never for making a diagnostic go away. + The same applies to `_ =`, `try?`, `@unchecked` and `nonisolated(unsafe)`: fix the cause. When a + Foundation method is `@discardableResult` on Darwin and not on Linux, use the result — it always + means something. +3. **Write the assertion for both platforms.** A test that is `#if`-gated out on one platform is a + coverage regression; give the `#else` branch the equivalent assertion, as + `TerminalAttributesTests` and `EnvironmentTests` do. diff --git a/docs/techpack-schema.md b/docs/techpack-schema.md index 4041539a..dad838af 100644 --- a/docs/techpack-schema.md +++ b/docs/techpack-schema.md @@ -64,6 +64,14 @@ Tap-qualified (`owner/tap/formula`) and versioned (`node@22`) names work, as do passed to `brew` verbatim; only the PATH probe behind the auto-derived check uses the last path component. +> **Linux**: verification falls back to `brew list` only when Homebrew is installed, which on Linux +> it usually is not. Without it a `brew:` component is satisfied only when the formula name is also +> the command name (`node` yes, `ripgrep` → `rg` no), and `mcs` cannot install the formula — that +> needs Linuxbrew. `mcs doctor` reports the package missing and `mcs doctor --fix` points the user at +> their system package manager. There is no per-platform component gating in this schema yet, so a macOS-only +> formula will simply fail its component on Linux with that message. See +> [Linux support](linux-support.md). + > **Tapped formulae install from an unaudited source.** `brew install owner/tap/formula` clones the > tap's repository and evaluates its formula. Homebrew taps it without asking, and `mcs` runs `brew` > without a terminal, so Homebrew's own plan prompt cannot appear either. Nothing in `mcs` gates @@ -317,7 +325,7 @@ Infers: `type: configuration`, `installAction: gitignoreEntries` | Field | Type | Description | |-------|------|-------------| | `shell` | `String` | Shell command to execute | -| `shellInteractive` | `Bool` | When `true`, allocates a PTY so commands like `sudo` can prompt for passwords securely. Default: `false` | +| `shellInteractive` | `Bool` | When `true`, allocates a PTY so commands like `sudo` can prompt for passwords securely. Default: `false`. Works the same on macOS and Linux — `forkpty` on both — with a 0×0 window size on both | **Does not infer `type`** — you must provide `type:` explicitly. This is because a shell command could install anything (a brew package, a skill, a tool). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8c074146..14cee233 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -12,14 +12,14 @@ mcs doctor --global # Check globally-configured packs only ### Homebrew not installed -**Symptom**: `mcs sync` fails with "Homebrew is required but not installed." +**Symptom**: `mcs sync` prints `Homebrew not found — ...` and the package is not installed. The rest of the line differs by platform: macOS points at , Linux names your system package manager. -**Fix**: Install Homebrew first: +**Fix on macOS**: install Homebrew, then re-run `mcs sync`: ```bash /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" ``` -Then re-run `mcs sync`. +**Fix on Linux**: Homebrew is optional. Install the package with your distribution's package manager (`apt`, `dnf`, `pacman`, …) and make sure it is on your `PATH`; `mcs doctor` verifies `brew:` components through `PATH` first, so that is enough. Note that this only works when the formula name is also the command name — `brew: ripgrep` installs a command called `rg`, which mcs cannot know. See [Linux support](linux-support.md#d7--homebrew-on-linux). If you want mcs to install formulae for you, install [Linuxbrew](https://docs.brew.sh/Homebrew-on-Linux). ### Xcode Command Line Tools missing @@ -47,16 +47,41 @@ If you manage Node.js through nvm or similar, make sure it's available in your P **Symptom**: MCP servers and plugins can't be registered. -**Fix**: Install Claude Code: +**Fix on macOS**: ```bash brew install claude-code ``` +**Fix on Linux**: `claude-code` is a Homebrew cask, and Linuxbrew has no casks, so mcs does not offer a brew install there. Use the native installer or npm: +```bash +curl -fsSL https://claude.ai/install.sh | bash +# or +npm install -g @anthropic-ai/claude-code +``` + Verify: ```bash claude --version ``` +### `mcs doctor` reports that nothing is installed (Linux) + +**Symptom**: every command-based check says "not found" on a machine where the tools are clearly present. + +**Cause**: mcs resolves command names through `/usr/bin/which`. If that binary is missing — it is absent on NixOS, and Debian has been retiring it — every resolution returns nothing: `mcs sync` and `mcs update` refuse to run (they report Claude Code as missing even when it is installed) and `mcs doctor` reports everything as not found. + +**Fix**: install it (`apt-get install debianutils`, or your distribution's equivalent) and re-run `mcs doctor`. + +### `mcs: command not found` after unpacking the Linux tarball + +**Symptom**: the tarball unpacked, but the shell cannot find `mcs`. + +**Fix**: the tarball contains a bare binary, not an installer. Put it somewhere on your `PATH`: +```bash +install -D -m 0755 mcs ~/.local/bin/mcs +``` +If the binary is found but fails to start with an error about `libstdc++.so.6`, install it — the released binary links the Swift runtime statically but still needs the C++ runtime (`apt-get install -y libstdc++6`). + ## MCP Servers ### MCP server not registered From edcf93e7a72fc706b6f12f606958d9fa066ff39d Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Tue, 29 Sep 2026 23:32:07 +0200 Subject: [PATCH 09/28] Install the Swift toolchain dependencies swiftly asks for - Ubuntu 22.04 needs pkg-config and python3-lldb-13 on top of libcurl's headers, and the names differ by release and architecture - Read them back from swiftly's own error instead of hardcoding a list Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu --- .github/actions/setup-swift-linux/action.yml | 24 ++++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/.github/actions/setup-swift-linux/action.yml b/.github/actions/setup-swift-linux/action.yml index 31cc85df..89c54de9 100644 --- a/.github/actions/setup-swift-linux/action.yml +++ b/.github/actions/setup-swift-linux/action.yml @@ -9,19 +9,19 @@ inputs: runs: using: composite steps: - - name: Install the toolchain's system dependencies - # swiftly refuses to install a toolchain until Swift's documented Ubuntu packages are - # present; libcurl4-openssl-dev is the one ubuntu-latest lacks. - shell: bash - run: sudo apt-get update && sudo apt-get install -y libcurl4-openssl-dev - - name: Install Swift via swiftly + # swiftly refuses to install a toolchain until Swift's own system dependencies are present, + # and names the missing ones in an `apt-get -y install …` line. Those names differ by Ubuntu + # release and architecture — 22.04 wants pkg-config and python3-lldb-13 where 24.04 does not — + # so they are read back from swiftly rather than hardcoded into a list that goes stale. # Unpacked outside the checkout so the tree the caller builds and tests in stays clean. shell: bash env: SWIFT_VERSION: ${{ inputs.swift-version }} run: | + set -euo pipefail cd "$RUNNER_TEMP" + sudo apt-get update curl -O "https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz" tar zxf "swiftly-$(uname -m).tar.gz" ./swiftly init --quiet-shell-followup --assume-yes --skip-install @@ -30,5 +30,15 @@ runs: ENV_SH="${XDG_DATA_HOME:-$HOME/.local/share}/swiftly/env.sh" test -f "$ENV_SH" || { echo "::error::swiftly env.sh not found at $ENV_SH"; exit 1; } . "$ENV_SH" - swiftly install --use "$SWIFT_VERSION" --assume-yes + + LOG="$RUNNER_TEMP/swiftly-install.log" + if ! swiftly install --use "$SWIFT_VERSION" --assume-yes 2>&1 | tee "$LOG"; then + PKGS=$(sed -n 's/^apt-get -y install //p' "$LOG" | tr '\n' ' ') + test -n "$PKGS" || { echo "::error::swiftly failed without naming missing packages"; exit 1; } + echo "Installing the dependencies swiftly asked for: $PKGS" + for pkg in $PKGS; do + sudo apt-get -y install "$pkg" || echo "::warning::apt could not install $pkg" + done + swiftly install --use "$SWIFT_VERSION" --assume-yes + fi echo "$SWIFTLY_BIN_DIR" >> "$GITHUB_PATH" From 7a0c3d55c00221638ca38cce84848704f482a487 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Tue, 29 Sep 2026 23:35:43 +0200 Subject: [PATCH 10/28] Lint on Linux and fix two shellcheck findings in the release job - SwiftFormat and SwiftLint both publish static Linux binaries, so linting needs no Swift toolchain and no macOS runner; the newest release is still what runs - Quote the release tarball globs so a name with a leading dash cannot become an option (actionlint/shellcheck SC2035) Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu --- .github/workflows/pr-checks.yml | 27 ++++++++++++++++++++++++--- .github/workflows/release.yml | 4 ++-- CLAUDE.md | 7 ++++--- docs/linux-support.md | 5 +++-- 4 files changed, 33 insertions(+), 10 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 36cfc6aa..c1060f92 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -17,14 +17,35 @@ env: jobs: lint: name: Lint - runs-on: macos-latest + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v7 - name: Install linters + # Both projects publish fully static Linux binaries, so linting needs no Swift toolchain. + # SwiftLint's archive carries a dynamically linked `swiftlint` next to `swiftlint-static`; + # only the static one runs without the Swift runtime installed. Tracking the newest release + # rather than a pin keeps the macOS behaviour this job replaces, where brew always upgraded. + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p "$RUNNER_TEMP/linters" + cd "$RUNNER_TEMP/linters" + gh release download --repo realm/SwiftLint --pattern swiftlint_linux_amd64.zip + gh release download --repo nicklockwood/SwiftFormat --pattern swiftformat_linux.zip + unzip -q swiftlint_linux_amd64.zip -d swiftlint + unzip -q swiftformat_linux.zip -d swiftformat + install -m 0755 swiftlint/swiftlint-static swiftlint-bin + install -m 0755 swiftformat/swiftformat_linux swiftformat + mv swiftlint-bin swiftlint + echo "$RUNNER_TEMP/linters" >> "$GITHUB_PATH" + + # Recorded because the job tracks the newest release: a verdict that changed without a source + # change is explained by the version that produced it. + - name: Linter versions run: | - brew install swiftformat swiftlint || true - brew upgrade swiftformat swiftlint || true + swiftformat --version + swiftlint --version - name: SwiftFormat run: swiftformat --lint --strict . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dbf59795..8ab24582 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -247,7 +247,7 @@ jobs: TAG_NAME: ${{ github.ref_name }} run: | # sha256sum, not shasum: identical " " output and present on every runner. - sha256sum *.tar.gz > SHA256SUMS + sha256sum -- *.tar.gz > SHA256SUMS cat SHA256SUMS echo "TARBALL=mcs-${TAG_NAME}-macos-universal.tar.gz" >> "$GITHUB_ENV" @@ -261,7 +261,7 @@ jobs: --repo "${{ github.repository }}" \ --verify-tag \ --notes "${NOTES}" \ - *.tar.gz SHA256SUMS + ./*.tar.gz SHA256SUMS - name: Update Homebrew tap env: diff --git a/CLAUDE.md b/CLAUDE.md index 30801b72..b21624a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -167,9 +167,10 @@ mcs config set # Set a configuration value (true/false) ## Code Style -SwiftFormat and SwiftLint enforce consistent code style. CI installs both from Homebrew and always -gets the latest release; a local install may come from a different manager (Mint, for instance) and -sit earlier on `PATH`, so `brew install` can appear to succeed while the old binary keeps answering. +SwiftFormat and SwiftLint enforce consistent code style. CI downloads the newest release of each as +a static Linux binary, so it always lints against the latest rules; a local install may come from a +different manager (Mint, for instance) and sit earlier on `PATH`, so `brew install` can appear to +succeed while the old binary keeps answering. If the formatter fails with `error: Unknown rule ''` on every file, that is a stale local install rather than a broken config — check `which -a swiftformat` and the version of each copy. diff --git a/docs/linux-support.md b/docs/linux-support.md index 02590c63..d069e7d2 100644 --- a/docs/linux-support.md +++ b/docs/linux-support.md @@ -23,8 +23,9 @@ says how. CI runs `swift build`, the full test suite and the release build on both Linux architectures for every pull request, alongside the two macOS jobs. The `Linux Smoke` workflow (manual dispatch) runs -whole command flows against a real pack; it is what the matrix below rests on. Lint runs on macOS only: SwiftFormat and SwiftLint give the same verdicts on both, so a -second run would only add version skew. +whole command flows against a real pack; it is what the matrix below rests on. Lint runs on Linux only: both +linters publish static Linux binaries, and the two platforms give the same verdicts, so a second run +would only add version skew. ## 2. Prerequisites From 58ef140f2e877ce222539528ab4463a275d99299 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Tue, 29 Sep 2026 23:37:42 +0200 Subject: [PATCH 11/28] Install the linters into their own bin directory - The unzip directory and the install destination shared a name, so install resolved source and target to the same file and exited 1 Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu --- .github/workflows/pr-checks.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index c1060f92..bbd9f4eb 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -29,16 +29,15 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - mkdir -p "$RUNNER_TEMP/linters" + mkdir -p "$RUNNER_TEMP/linters/bin" cd "$RUNNER_TEMP/linters" gh release download --repo realm/SwiftLint --pattern swiftlint_linux_amd64.zip gh release download --repo nicklockwood/SwiftFormat --pattern swiftformat_linux.zip - unzip -q swiftlint_linux_amd64.zip -d swiftlint - unzip -q swiftformat_linux.zip -d swiftformat - install -m 0755 swiftlint/swiftlint-static swiftlint-bin - install -m 0755 swiftformat/swiftformat_linux swiftformat - mv swiftlint-bin swiftlint - echo "$RUNNER_TEMP/linters" >> "$GITHUB_PATH" + unzip -q swiftlint_linux_amd64.zip -d swiftlint-zip + unzip -q swiftformat_linux.zip -d swiftformat-zip + install -m 0755 swiftlint-zip/swiftlint-static bin/swiftlint + install -m 0755 swiftformat-zip/swiftformat_linux bin/swiftformat + echo "$RUNNER_TEMP/linters/bin" >> "$GITHUB_PATH" # Recorded because the job tracks the newest release: a verdict that changed without a source # change is explained by the version that produced it. From a4e3093345f962d0f128744b3ae8ca9bef235e38 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Tue, 29 Sep 2026 23:44:09 +0200 Subject: [PATCH 12/28] Use the fileExists result when deciding a source is a directory - fileExists(atPath:isDirectory:) is @discardableResult only on Darwin, so discarding it fails the Linux build under -warnings-as-errors - Gating on the return value is also what Apple documents: isDirectory is undefined when the path does not exist Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu --- Sources/mcs/Sync/ComponentExecutor.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/mcs/Sync/ComponentExecutor.swift b/Sources/mcs/Sync/ComponentExecutor.swift index b6eb44c3..624c314c 100644 --- a/Sources/mcs/Sync/ComponentExecutor.swift +++ b/Sources/mcs/Sync/ComponentExecutor.swift @@ -235,11 +235,11 @@ struct ComponentExecutor { ) var isDir: ObjCBool = false - fm.fileExists(atPath: source.path, isDirectory: &isDir) + let sourceIsDirectory = fm.fileExists(atPath: source.path, isDirectory: &isDir) && isDir.boolValue var shippedFiles: [String] = [] var installedHashes: [String: String] = [:] - if isDir.boolValue { + if sourceIsDirectory { // Source is a directory — copy all files recursively try fm.createDirectory(at: destURL, withIntermediateDirectories: true) let contents = try fm.contentsOfDirectory(at: source, includingPropertiesForKeys: nil) @@ -318,12 +318,12 @@ struct ComponentExecutor { ) var isDir: ObjCBool = false - fm.fileExists(atPath: source.path, isDirectory: &isDir) + let sourceIsDirectory = fm.fileExists(atPath: source.path, isDirectory: &isDir) && isDir.boolValue var installedPaths: [String] = [] var shippedFiles: [String] = [] var installedHashes: [String: String] = [:] - if isDir.boolValue { + if sourceIsDirectory { try fm.createDirectory(at: destURL, withIntermediateDirectories: true) let contents = try fm.contentsOfDirectory(at: source, includingPropertiesForKeys: nil) for file in contents { @@ -402,9 +402,9 @@ struct ComponentExecutor { ) throws { let fm = FileManager.default var isDir: ObjCBool = false - fm.fileExists(atPath: source.path, isDirectory: &isDir) + let sourceIsDirectory = fm.fileExists(atPath: source.path, isDirectory: &isDir) && isDir.boolValue - if isDir.boolValue { + if sourceIsDirectory { try fm.createDirectory(at: destination, withIntermediateDirectories: true) let contents = try fm.contentsOfDirectory(at: source, includingPropertiesForKeys: nil) for child in contents { From 23a142c6193333e37c7ab732389ba0762e8b027c Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Tue, 29 Sep 2026 23:48:58 +0200 Subject: [PATCH 13/28] Cache the Swift toolchain on Linux runners - Each Linux job downloaded 959 MB and spent ~50s installing the toolchain; a cache keyed on version and architecture skips that on a hit - The system packages stay outside the cache, since a cached toolchain needs them too; swiftly's own list remains the fallback on a miss - The action now reports the resolved Swift version, which also proves a cache hit is usable before anything builds Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu --- .github/actions/setup-swift-linux/action.yml | 47 ++++++++++++++++---- .github/workflows/pr-checks.yml | 3 -- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/.github/actions/setup-swift-linux/action.yml b/.github/actions/setup-swift-linux/action.yml index 89c54de9..2196c9b7 100644 --- a/.github/actions/setup-swift-linux/action.yml +++ b/.github/actions/setup-swift-linux/action.yml @@ -9,27 +9,45 @@ inputs: runs: using: composite steps: + - name: Restore the Swift toolchain + id: toolchain + uses: actions/cache@v6 + with: + # swiftly keeps the toolchains and env.sh under the data dir and its selected-version + # state under the config dir; a hit needs both or `swift` resolves to nothing. + path: | + ~/.local/share/swiftly + ~/.config/swiftly + key: swiftly-${{ runner.os }}-${{ runner.arch }}-${{ inputs.swift-version }} + + - name: Install the toolchain's system dependencies + # Not cacheable — these are system packages, and the toolchain needs them on a cache hit too. + # The names are Ubuntu-release-specific, so a miss is tolerated here: on a cache miss the + # install step below asks swiftly for the authoritative list and retries. + shell: bash + run: | + set -euo pipefail + sudo apt-get update + for pkg in libcurl4-openssl-dev pkg-config python3-lldb-13; do + sudo apt-get -y install "$pkg" || echo "::warning::apt could not install $pkg" + done + - name: Install Swift via swiftly + if: steps.toolchain.outputs.cache-hit != 'true' # swiftly refuses to install a toolchain until Swift's own system dependencies are present, # and names the missing ones in an `apt-get -y install …` line. Those names differ by Ubuntu - # release and architecture — 22.04 wants pkg-config and python3-lldb-13 where 24.04 does not — - # so they are read back from swiftly rather than hardcoded into a list that goes stale. - # Unpacked outside the checkout so the tree the caller builds and tests in stays clean. + # release and architecture, so they are read back from swiftly rather than trusted to the + # list above. Unpacked outside the checkout so the tree the caller builds in stays clean. shell: bash env: SWIFT_VERSION: ${{ inputs.swift-version }} run: | set -euo pipefail cd "$RUNNER_TEMP" - sudo apt-get update curl -O "https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz" tar zxf "swiftly-$(uname -m).tar.gz" ./swiftly init --quiet-shell-followup --assume-yes --skip-install - # env.sh lives under $XDG_DATA_HOME when that is set; fail loudly rather than silently - # continuing without swiftly on PATH if the layout ever moves. - ENV_SH="${XDG_DATA_HOME:-$HOME/.local/share}/swiftly/env.sh" - test -f "$ENV_SH" || { echo "::error::swiftly env.sh not found at $ENV_SH"; exit 1; } - . "$ENV_SH" + . "${XDG_DATA_HOME:-$HOME/.local/share}/swiftly/env.sh" LOG="$RUNNER_TEMP/swiftly-install.log" if ! swiftly install --use "$SWIFT_VERSION" --assume-yes 2>&1 | tee "$LOG"; then @@ -41,4 +59,15 @@ runs: done swiftly install --use "$SWIFT_VERSION" --assume-yes fi + + - name: Put Swift on PATH + shell: bash + run: | + set -euo pipefail + # env.sh lives under $XDG_DATA_HOME when that is set; fail loudly rather than silently + # continuing without swiftly on PATH if the layout ever moves or a cache hit is partial. + ENV_SH="${XDG_DATA_HOME:-$HOME/.local/share}/swiftly/env.sh" + test -f "$ENV_SH" || { echo "::error::swiftly env.sh not found at $ENV_SH"; exit 1; } + . "$ENV_SH" echo "$SWIFTLY_BIN_DIR" >> "$GITHUB_PATH" + "$SWIFTLY_BIN_DIR/swift" --version diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index bbd9f4eb..1d121ced 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -72,9 +72,6 @@ jobs: key: spm-${{ matrix.os }}-${{ hashFiles('Package.swift') }} restore-keys: spm-${{ matrix.os }}- - - name: Swift version - run: swift --version - - name: Resolve packages run: swift package resolve From 6db5ee3f567ec8e1ff323c99ed13b379c1af8214 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Tue, 29 Sep 2026 23:51:48 +0200 Subject: [PATCH 14/28] Share one dependency-source cache across every build job - .build/checkouts and the resolver state are git clones with no absolute paths, so a per-runner key stored the same bytes six times and made five jobs miss - Compiled output stays uncached: it is not portable across runner or toolchain Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu --- .github/workflows/pr-checks.yml | 16 ++++++++++------ .github/workflows/release.yml | 20 ++++++++++---------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 1d121ced..1b459ed4 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -63,14 +63,18 @@ jobs: steps: - uses: actions/checkout@v7 + # Dependency *sources* only, and the key is deliberately platform-independent: these are git + # clones with no absolute paths, so every macOS and Linux job shares one entry. Compiled + # output is not cached — it is not portable across runner or toolchain, and a cold build of + # the whole graph costs seconds next to running the suite. - uses: actions/cache@v6 with: path: | .build/checkouts .build/repositories .build/workspace-state.json - key: spm-${{ matrix.os }}-${{ hashFiles('Package.swift') }} - restore-keys: spm-${{ matrix.os }}- + key: spm-${{ hashFiles('Package.swift') }} + restore-keys: spm- - name: Resolve packages run: swift package resolve @@ -103,8 +107,8 @@ jobs: .build/checkouts .build/repositories .build/workspace-state.json - key: spm-${{ matrix.runner }}-${{ hashFiles('Package.swift') }} - restore-keys: spm-${{ matrix.runner }}- + key: spm-${{ hashFiles('Package.swift') }} + restore-keys: spm- - name: Swift version run: swift --version @@ -138,8 +142,8 @@ jobs: .build/checkouts .build/repositories .build/workspace-state.json - key: spm-${{ runner.os }}-${{ hashFiles('Package.swift') }} - restore-keys: spm-${{ runner.os }}- + key: spm-${{ hashFiles('Package.swift') }} + restore-keys: spm- - name: Resolve packages run: swift package resolve diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8ab24582..b90ca42a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,8 +74,8 @@ jobs: .build/checkouts .build/repositories .build/workspace-state.json - key: spm-${{ runner.os }}-${{ hashFiles('Package.swift') }} - restore-keys: spm-${{ runner.os }}- + key: spm-${{ hashFiles('Package.swift') }} + restore-keys: spm- - name: Resolve packages run: swift package resolve @@ -93,8 +93,8 @@ jobs: .build/checkouts .build/repositories .build/workspace-state.json - key: spm-${{ runner.os }}-${{ hashFiles('Package.swift') }} - restore-keys: spm-${{ runner.os }}- + key: spm-${{ hashFiles('Package.swift') }} + restore-keys: spm- - name: Test run: swift test @@ -120,8 +120,8 @@ jobs: .build/checkouts .build/repositories .build/workspace-state.json - key: spm-${{ matrix.runner }}-${{ hashFiles('Package.swift') }} - restore-keys: spm-${{ matrix.runner }}- + key: spm-${{ hashFiles('Package.swift') }} + restore-keys: spm- # --no-parallel for the reason in docs/linux-support.md: corelibs Foundation leaks write # descriptors into concurrently forked children, surfacing as a spurious ETXTBSY. @@ -141,8 +141,8 @@ jobs: .build/checkouts .build/repositories .build/workspace-state.json - key: spm-${{ runner.os }}-${{ hashFiles('Package.swift') }} - restore-keys: spm-${{ runner.os }}- + key: spm-${{ hashFiles('Package.swift') }} + restore-keys: spm- - name: Build universal binary run: | @@ -197,8 +197,8 @@ jobs: .build/checkouts .build/repositories .build/workspace-state.json - key: spm-${{ matrix.runner }}-${{ hashFiles('Package.swift') }} - restore-keys: spm-${{ matrix.runner }}- + key: spm-${{ hashFiles('Package.swift') }} + restore-keys: spm- - name: Build release binary run: | From 58dfcde6dca012d3c24744fe4bf6a694933e74d2 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Tue, 29 Sep 2026 23:53:37 +0200 Subject: [PATCH 15/28] Pin the linter versions and assert what ran - A new upstream release could turn a PR red without a source change; the pins make a lint verdict depend on the repo alone - The job asserts each binary reports its pinned version, so a resolution that silently drifted cannot pass unnoticed Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu --- .github/workflows/pr-checks.yml | 21 ++++++++++++--------- CLAUDE.md | 10 ++++++---- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 1b459ed4..7bd846f4 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -13,6 +13,10 @@ concurrency: env: # Keep in sync with release.yml, which pins the same toolchain for the release build. SWIFT_VERSION: '6.2.0' + # Pinned so a lint verdict is a function of the repo: a new rule lands when this is bumped, + # not when upstream tags a release mid-review. + SWIFTLINT_VERSION: '0.65.1' + SWIFTFORMAT_VERSION: '0.63.0' jobs: lint: @@ -24,27 +28,26 @@ jobs: - name: Install linters # Both projects publish fully static Linux binaries, so linting needs no Swift toolchain. # SwiftLint's archive carries a dynamically linked `swiftlint` next to `swiftlint-static`; - # only the static one runs without the Swift runtime installed. Tracking the newest release - # rather than a pin keeps the macOS behaviour this job replaces, where brew always upgraded. + # only the static one runs without the Swift runtime installed. env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | mkdir -p "$RUNNER_TEMP/linters/bin" cd "$RUNNER_TEMP/linters" - gh release download --repo realm/SwiftLint --pattern swiftlint_linux_amd64.zip - gh release download --repo nicklockwood/SwiftFormat --pattern swiftformat_linux.zip + gh release download "$SWIFTLINT_VERSION" --repo realm/SwiftLint --pattern swiftlint_linux_amd64.zip + gh release download "$SWIFTFORMAT_VERSION" --repo nicklockwood/SwiftFormat --pattern swiftformat_linux.zip unzip -q swiftlint_linux_amd64.zip -d swiftlint-zip unzip -q swiftformat_linux.zip -d swiftformat-zip install -m 0755 swiftlint-zip/swiftlint-static bin/swiftlint install -m 0755 swiftformat-zip/swiftformat_linux bin/swiftformat echo "$RUNNER_TEMP/linters/bin" >> "$GITHUB_PATH" - # Recorded because the job tracks the newest release: a verdict that changed without a source - # change is explained by the version that produced it. - - name: Linter versions + # Asserted, not just printed: a pin that silently resolved to something else would make every + # later verdict untrustworthy. + - name: Verify linter versions run: | - swiftformat --version - swiftlint --version + swiftformat --version | grep -qx "$SWIFTFORMAT_VERSION" + swiftlint --version | grep -qx "$SWIFTLINT_VERSION" - name: SwiftFormat run: swiftformat --lint --strict . diff --git a/CLAUDE.md b/CLAUDE.md index b21624a6..91cbe621 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -167,10 +167,12 @@ mcs config set # Set a configuration value (true/false) ## Code Style -SwiftFormat and SwiftLint enforce consistent code style. CI downloads the newest release of each as -a static Linux binary, so it always lints against the latest rules; a local install may come from a -different manager (Mint, for instance) and sit earlier on `PATH`, so `brew install` can appear to -succeed while the old binary keeps answering. +SwiftFormat and SwiftLint enforce consistent code style. CI downloads a **pinned** version of each +as a static Linux binary (`SWIFTLINT_VERSION` / `SWIFTFORMAT_VERSION` in `pr-checks.yml`) and asserts +the binary reports it, so a lint verdict depends on the repo alone — new rules arrive when someone +bumps the pin. Keep the local install on those versions or CI will disagree with you; a local install +may also come from a different manager (Mint, for instance) and sit earlier on `PATH`, so +`brew install` can appear to succeed while the old binary keeps answering. If the formatter fails with `error: Unknown rule ''` on every file, that is a stale local install rather than a broken config — check `which -a swiftformat` and the version of each copy. From 029ee500fd53995afb0bf9ddf83bc050743f6b90 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Wed, 30 Sep 2026 00:04:31 +0200 Subject: [PATCH 16/28] Run the Linux smoke job on this PR to fill the compatibility matrix - TEMPORARY trigger, reverted before merge: workflow_dispatch only reaches workflows already on the default branch, so a new one cannot be exercised - The runner becomes a matrix so a pull_request event covers both architectures Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu --- .github/workflows/linux-smoke.yml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/linux-smoke.yml b/.github/workflows/linux-smoke.yml index 46244988..2b96e892 100644 --- a/.github/workflows/linux-smoke.yml +++ b/.github/workflows/linux-smoke.yml @@ -3,6 +3,10 @@ name: Linux Smoke # Manual only: this is the Linux acceptance check behind docs/linux-support.md's compatibility # matrix. It exercises whole command flows against a real pack, which the unit suite cannot. on: + # TEMPORARY, reverted before merge: workflow_dispatch only reaches workflows that already exist on + # the default branch, so this is the one way to exercise the job while it is still only on a branch. + pull_request: + branches: [main] workflow_dispatch: inputs: runner: @@ -23,8 +27,13 @@ env: jobs: smoke: - name: Smoke (${{ inputs.runner }}) - runs-on: ${{ inputs.runner }} + name: Smoke (${{ matrix.runner }}) + # `inputs.runner` is empty on a pull_request event, so the matrix falls back to both runners. + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + runner: ${{ inputs.runner && fromJSON(format('["{0}"]', inputs.runner)) || fromJSON('["ubuntu-22.04","ubuntu-22.04-arm"]') }} steps: - uses: actions/checkout@v7 From c8b0f1683cf6d69873ed35ed5209993a10e8d5b6 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Wed, 30 Sep 2026 00:09:43 +0200 Subject: [PATCH 17/28] Fill the compatibility matrix from the Linux smoke run - Seven rows for the flows that landed after the original Linux verification, each observed on x86_64 and aarch64 - The legend now names the workflow the rows come from, so a stale table is re-dispatchable rather than guesswork Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu --- docs/linux-support.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/linux-support.md b/docs/linux-support.md index d069e7d2..81c41856 100644 --- a/docs/linux-support.md +++ b/docs/linux-support.md @@ -100,7 +100,10 @@ quarter more wall-clock time (42s against 34s here). ## 4. Compatibility matrix -Legend: `verified` — run on Linux and observed; `verified (with a difference)` — works, but not identically to macOS; the Notes column says how. +Legend: `verified` — run on Linux and observed; `verified (with a difference)` — works, but not +identically to macOS; the Notes column says how. The rows for whole command flows come from the +`Linux Smoke` workflow, which runs them against a real pack on `ubuntu-22.04` and +`ubuntu-22.04-arm`; re-dispatch it rather than trusting this table after a behavioural change. | Feature | macOS | Linux | Notes | |---|---|---|---| @@ -126,6 +129,13 @@ Legend: `verified` — run on Linux and observed; `verified (with a difference)` | `mcs export` (incl. brew formula hints) | supported | verified (with a difference) | Exported `techpack.yaml`, hooks, skill, command, agent, settings and template from a live project. The brew formula hints are empty without Linuxbrew — `detectFormula` reads symlinks under the Linuxbrew prefixes. | | `mcs cleanup` | supported | verified | Found and deleted a `CLAUDE.local.md.backup.*` file, with and without `--force`. | | `mcs check-updates` + SessionStart hook | supported | verified (with a difference) | `check-updates`, `--json` and `--hook` all run; `mcs config set update-check true` registered `mcs check-updates --hook` in `~/.claude/settings.json` and the cooldown file was written. The upgrade command differs: `brew upgrade` on macOS, a staged tarball swap on Linux — see ADR D12. | +| `mcs bootstrap` | supported | verified | `mcs.yaml` with a seeded `values:` entry: the pack installed and the skill body carried the seeded value, with "Reusing 1 previously configured value(s)". | +| `mcs bootstrap --trust-all` | supported | verified | Ran with no TTY; the pack's executable content was approved without a prompt. | +| `mcs sync --pack --prune --yes` | supported | verified | Made the named pack the exact set and skipped the removal confirmation. | +| `mcs doctor --fix --yes` (re-sync) | supported | verified | `6 passed 0 warnings 0 issues` before and after, so the re-sync path is idempotent. | +| `mcs update --trust-all` | supported | verified | `mcs update --project --trust-all` re-applied the scope with no trust prompt. | +| `mcs pack list --json` | supported | verified | Machine-readable output including `isLocal` and the `local` commit sentinel. | +| Non-interactive prompt resolution | supported | verified | `mcs sync --all < /dev/null` resolved every key from the stored prior instead of blocking on stdin. | | `mcs config` | supported | verified | `list` / `get` / `set` against `~/.mcs/config.yaml`. | | `$HOME` override | supported (behavior change) | verified | `mcs` resolves its home from `$HOME`, falling back to the passwd entry, on both platforms — including a `~` typed in a pack path or a pack's doctor `path:`. Previously `$HOME` was ignored everywhere (ADR D11). | | File lock (`flock`) | supported | verified | Two concurrent syncs: the second exited 1 with "Another mcs process is running". | From 59c3c144f372b43e12db0d47d6b3c64aca6c3875 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Wed, 30 Sep 2026 00:09:50 +0200 Subject: [PATCH 18/28] Restore the smoke workflow to manual dispatch only - The pull_request trigger existed to produce the matrix evidence while the workflow was not yet on the default branch; that run is recorded Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu --- .github/workflows/linux-smoke.yml | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/.github/workflows/linux-smoke.yml b/.github/workflows/linux-smoke.yml index 2b96e892..46244988 100644 --- a/.github/workflows/linux-smoke.yml +++ b/.github/workflows/linux-smoke.yml @@ -3,10 +3,6 @@ name: Linux Smoke # Manual only: this is the Linux acceptance check behind docs/linux-support.md's compatibility # matrix. It exercises whole command flows against a real pack, which the unit suite cannot. on: - # TEMPORARY, reverted before merge: workflow_dispatch only reaches workflows that already exist on - # the default branch, so this is the one way to exercise the job while it is still only on a branch. - pull_request: - branches: [main] workflow_dispatch: inputs: runner: @@ -27,13 +23,8 @@ env: jobs: smoke: - name: Smoke (${{ matrix.runner }}) - # `inputs.runner` is empty on a pull_request event, so the matrix falls back to both runners. - runs-on: ${{ matrix.runner }} - strategy: - fail-fast: false - matrix: - runner: ${{ inputs.runner && fromJSON(format('["{0}"]', inputs.runner)) || fromJSON('["ubuntu-22.04","ubuntu-22.04-arm"]') }} + name: Smoke (${{ inputs.runner }}) + runs-on: ${{ inputs.runner }} steps: - uses: actions/checkout@v7 From 4c137dcb60ce9c7d36461e20ca517952659ba183 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Wed, 30 Sep 2026 00:10:58 +0200 Subject: [PATCH 19/28] Require the smoke workflow to follow a changed command contract - The matrix in the Linux docs cites a run, so a new command, flag contract or manifest key that never reaches the smoke job leaves it asserting untested behaviour - Stated once under Testing, with a checkbox in the engine-changes checklist Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu --- .github/PULL_REQUEST_TEMPLATE.md | 1 + CLAUDE.md | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 10699f64..0d308218 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -48,6 +48,7 @@ Only check items that apply to this PR. Delete irrelevant ones. - [ ] Any `fix()` implementation does cleanup/migration only — never installs or registers resources - [ ] State migrations are guarded by `isNeeded()` to stay idempotent with `mcs sync` - [ ] Integration tests updated for new features (`LifecycleIntegrationTests` or `DoctorRunnerIntegrationTests`) +- [ ] `Linux Smoke` updated and re-dispatched if a command, flag contract, or manifest key changed, and the matrix row in `docs/linux-support.md` updated from the run - [ ] New file write/copy/delete paths use `PathContainment.safePath()` and handle the `nil` (escape) case - [ ] Docs updated if behavior changed (`CLAUDE.md`, `docs/`, `techpack.yaml` schema in `ExternalPackManifest.swift`) diff --git a/CLAUDE.md b/CLAUDE.md index 91cbe621..eb771e50 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -212,6 +212,7 @@ import Glibc - **Important**: `swift test` output does not display in Claude Code's terminal. Redirect to a file and read it: `swift test > .test-output/results.txt 2>&1` then read `.test-output/results.txt` - **Integration tests are mandatory for new features** that touch components, settings composition, hook entries, or doctor checks — add cases to `LifecycleIntegrationTests.swift` (sync → doctor lifecycle) or `DoctorRunnerIntegrationTests.swift` (doctor-specific flows) - Integration tests use `LifecycleTestBed` for sandbox setup — see existing tests for the pattern +- **A new command, a new flag that changes a command's contract, or a new manifest key belongs in the `Linux Smoke` workflow too.** It is the only thing that runs whole flows end to end against a real pack, so it is what the compatibility matrix in `docs/linux-support.md` rests on. Add a step (or extend the fixture pack), dispatch the workflow on both runners, and update the matrix row from what you observed. An internal refactor with no user-visible contract change needs none of this. Left undone, the matrix keeps asserting behaviour nobody has run ## Git From 47a024008e914e27ae73c6818efa0edf3a7befaf Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Wed, 30 Sep 2026 00:16:13 +0200 Subject: [PATCH 20/28] Run the smoke flows in PR checks on Linux and macOS - One composite action holds the flows; the jobs that already build a release binary call it, so the coverage costs seconds instead of another build - macOS runs them against the universal binary users install, which nothing exercised end to end before - The manual workflow calls the same action, for reproducing a failure on a runner PR checks does not cover Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- .github/actions/mcs-smoke/action.yml | 149 +++++++++++++++++++++++++++ .github/workflows/linux-smoke.yml | 109 ++------------------ .github/workflows/pr-checks.yml | 19 +++- CLAUDE.md | 2 +- docs/linux-support.md | 13 ++- 6 files changed, 182 insertions(+), 112 deletions(-) create mode 100644 .github/actions/mcs-smoke/action.yml diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 0d308218..9dfb908d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -48,7 +48,7 @@ Only check items that apply to this PR. Delete irrelevant ones. - [ ] Any `fix()` implementation does cleanup/migration only — never installs or registers resources - [ ] State migrations are guarded by `isNeeded()` to stay idempotent with `mcs sync` - [ ] Integration tests updated for new features (`LifecycleIntegrationTests` or `DoctorRunnerIntegrationTests`) -- [ ] `Linux Smoke` updated and re-dispatched if a command, flag contract, or manifest key changed, and the matrix row in `docs/linux-support.md` updated from the run +- [ ] `.github/actions/mcs-smoke` has a step for any new command, flag contract, or manifest key, and the matrix row in `docs/linux-support.md` reflects it - [ ] New file write/copy/delete paths use `PathContainment.safePath()` and handle the `nil` (escape) case - [ ] Docs updated if behavior changed (`CLAUDE.md`, `docs/`, `techpack.yaml` schema in `ExternalPackManifest.swift`) diff --git a/.github/actions/mcs-smoke/action.yml b/.github/actions/mcs-smoke/action.yml new file mode 100644 index 00000000..7900f3b8 --- /dev/null +++ b/.github/actions/mcs-smoke/action.yml @@ -0,0 +1,149 @@ +name: mcs smoke flows +description: Run whole mcs command flows against a fixture pack, using an already-built binary. + +inputs: + mcs: + description: Path to the mcs binary under test. + required: true + +runs: + using: composite + steps: + # A stand-in for the Claude Code CLI: every command below calls ensureClaudeCLI, which refuses + # to run without it, and nothing here asserts on what Claude itself does. + - name: Stub the claude CLI + shell: bash + run: | + set -euo pipefail + mkdir -p "$HOME/.local/bin" + printf '#!/bin/sh\nexit 0\n' > "$HOME/.local/bin/claude" + chmod +x "$HOME/.local/bin/claude" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + # A local pack exercises the same manifest, trust and artifact paths as a git pack without + # making the smoke run depend on a network fetch. + - name: Create a fixture pack + shell: bash + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/fixture-pack/skills/greet" "$RUNNER_TEMP/project" + cat > "$RUNNER_TEMP/fixture-pack/techpack.yaml" <<'PACK' + schemaVersion: 1 + identifier: smoke + displayName: Smoke Pack + description: Fixture pack for the smoke flows + prompts: + - key: GREETING + type: input + prompt: Greeting to use + default: hello + components: + - id: greet-skill + displayName: Greet skill + description: Skill whose body is substituted from a prompt value + skill: + source: skills/greet + destination: greet + - id: git-present + displayName: git + description: A formula whose name is also its command, so the PATH probe satisfies it + brew: git + PACK + cat > "$RUNNER_TEMP/fixture-pack/skills/greet/SKILL.md" <<'SKILL' + --- + name: greet + description: Says __GREETING__ + --- + Says __GREETING__. + SKILL + cat > "$RUNNER_TEMP/project/mcs.yaml" <> "$GITHUB_ENV" - # A stand-in for the Claude Code CLI: every command below calls ensureClaudeCLI, which - # refuses to run without it, and nothing here asserts on what Claude itself does. - - name: Stub the claude CLI - run: | - mkdir -p "$HOME/.local/bin" - printf '#!/bin/sh\nexit 0\n' > "$HOME/.local/bin/claude" - chmod +x "$HOME/.local/bin/claude" - echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - # A local pack: exercises the same manifest, trust and artifact paths as a git pack without - # depending on a network fetch inside the smoke test. - - name: Create a fixture pack - run: | - mkdir -p "$RUNNER_TEMP/fixture-pack/skills/greet" "$RUNNER_TEMP/project" - cat > "$RUNNER_TEMP/fixture-pack/techpack.yaml" <<'PACK' - schemaVersion: 1 - identifier: smoke - displayName: Smoke Pack - description: Fixture pack for the Linux smoke test - prompts: - - key: GREETING - type: input - prompt: Greeting to use - default: hello - components: - - id: greet-skill - displayName: Greet skill - description: Skill whose body is substituted from a prompt value - skill: - source: skills/greet - destination: greet - - id: git-present - displayName: git - description: A formula whose name is also its command, so the PATH probe satisfies it - brew: git - PACK - cat > "$RUNNER_TEMP/fixture-pack/skills/greet/SKILL.md" <<'SKILL' - --- - name: greet - description: Says __GREETING__ - --- - Says __GREETING__. - SKILL - cat > "$RUNNER_TEMP/project/mcs.yaml" <> "$GITHUB_ENV" + + # Whole command flows against a real pack, riding on the binary just built — the unit suite + # cannot reach them, and here they cost seconds rather than a build. + - uses: ./.github/actions/mcs-smoke + with: + mcs: ${{ env.MCS }} build-universal: name: Universal Binary @@ -152,4 +160,11 @@ jobs: run: swift package resolve - name: Build universal binary - run: swift build -c release --arch arm64 --arch x86_64 + run: | + swift build -c release --arch arm64 --arch x86_64 + echo "MCS=$(swift build -c release --arch arm64 --arch x86_64 --show-bin-path)/mcs" >> "$GITHUB_ENV" + + # The same flows the Linux jobs run, against the universal binary users actually install. + - uses: ./.github/actions/mcs-smoke + with: + mcs: ${{ env.MCS }} diff --git a/CLAUDE.md b/CLAUDE.md index eb771e50..ab11517e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -212,7 +212,7 @@ import Glibc - **Important**: `swift test` output does not display in Claude Code's terminal. Redirect to a file and read it: `swift test > .test-output/results.txt 2>&1` then read `.test-output/results.txt` - **Integration tests are mandatory for new features** that touch components, settings composition, hook entries, or doctor checks — add cases to `LifecycleIntegrationTests.swift` (sync → doctor lifecycle) or `DoctorRunnerIntegrationTests.swift` (doctor-specific flows) - Integration tests use `LifecycleTestBed` for sandbox setup — see existing tests for the pattern -- **A new command, a new flag that changes a command's contract, or a new manifest key belongs in the `Linux Smoke` workflow too.** It is the only thing that runs whole flows end to end against a real pack, so it is what the compatibility matrix in `docs/linux-support.md` rests on. Add a step (or extend the fixture pack), dispatch the workflow on both runners, and update the matrix row from what you observed. An internal refactor with no user-visible contract change needs none of this. Left undone, the matrix keeps asserting behaviour nobody has run +- **A new command, a new flag that changes a command's contract, or a new manifest key belongs in `.github/actions/mcs-smoke` too.** It is the only thing that runs whole flows end to end against a real pack, and PR checks run it on both Linux architectures and against the macOS universal binary — so a flow that is not a step there is a flow nothing guards. Add a step or extend the fixture pack; the run is what the compatibility matrix in `docs/linux-support.md` cites, so update the affected row from it. An internal refactor with no user-visible contract change needs none of this ## Git diff --git a/docs/linux-support.md b/docs/linux-support.md index 81c41856..d08cb026 100644 --- a/docs/linux-support.md +++ b/docs/linux-support.md @@ -22,8 +22,10 @@ says how. | Other distributions | Any glibc ≥ 2.35 distribution is expected to work. **Nothing is claimed about Fedora, Alpine or NixOS** — they have not been tested. | CI runs `swift build`, the full test suite and the release build on both Linux architectures for -every pull request, alongside the two macOS jobs. The `Linux Smoke` workflow (manual dispatch) runs -whole command flows against a real pack; it is what the matrix below rests on. Lint runs on Linux only: both +every pull request, alongside the two macOS jobs. It also runs whole command flows against a real +pack — `.github/actions/mcs-smoke`, on both Linux architectures and against the macOS universal +binary — which is what the matrix below rests on. The `Linux Smoke` workflow runs the same flows on +demand, for reproducing a Linux-only failure. Lint runs on Linux only: both linters publish static Linux binaries, and the two platforms give the same verdicts, so a second run would only add version skew. @@ -101,9 +103,10 @@ quarter more wall-clock time (42s against 34s here). ## 4. Compatibility matrix Legend: `verified` — run on Linux and observed; `verified (with a difference)` — works, but not -identically to macOS; the Notes column says how. The rows for whole command flows come from the -`Linux Smoke` workflow, which runs them against a real pack on `ubuntu-22.04` and -`ubuntu-22.04-arm`; re-dispatch it rather than trusting this table after a behavioural change. +identically to macOS; the Notes column says how. The rows for whole command flows come from +`.github/actions/mcs-smoke`, which PR checks run against a real pack on `ubuntu-22.04`, +`ubuntu-22.04-arm` and macOS, so they are re-verified on every pull request rather than at the +date of this table. | Feature | macOS | Linux | Notes | |---|---|---|---| From 1f6258bc3b3c4f0aafb279ca83c0d0b53de8089a Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Wed, 30 Sep 2026 00:28:44 +0200 Subject: [PATCH 21/28] Run the Linux test and release build in parallel - The two are independent, and serialising them made Linux the critical path of PR checks; the smoke flows ride with the release build they need - Drop the unconditional apt-get update, which had grown to cost more than the toolchain download, and fall back to it only when a package will not resolve Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu --- .github/actions/setup-swift-linux/action.yml | 5 ++- .github/workflows/pr-checks.yml | 39 +++++++++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/.github/actions/setup-swift-linux/action.yml b/.github/actions/setup-swift-linux/action.yml index 2196c9b7..10eacaf4 100644 --- a/.github/actions/setup-swift-linux/action.yml +++ b/.github/actions/setup-swift-linux/action.yml @@ -27,8 +27,11 @@ runs: shell: bash run: | set -euo pipefail - sudo apt-get update + # No `apt-get update` first: it costs more than everything else here, and the runner images + # ship a usable index. A package that cannot be resolved falls back to updating once. for pkg in libcurl4-openssl-dev pkg-config python3-lldb-13; do + sudo apt-get -y install "$pkg" && continue + sudo apt-get update sudo apt-get -y install "$pkg" || echo "::warning::apt could not install $pkg" done diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index adc98358..43fd15b6 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -85,8 +85,10 @@ jobs: - name: Test run: swift test - build-and-test-linux: - name: Build & Test (${{ matrix.runner }}) + # Split from the release build below rather than run before it: the two are independent, and + # Linux is the longest leg of these checks, so serialising them set the critical path. + test-linux: + name: Test (${{ matrix.runner }}) needs: lint # 22.04 on both arches so PR checks build against the same glibc floor the release does. runs-on: ${{ matrix.runner }} @@ -113,9 +115,6 @@ jobs: key: spm-${{ hashFiles('Package.swift') }} restore-keys: spm- - - name: Swift version - run: swift --version - - name: Resolve packages run: swift package resolve @@ -128,6 +127,36 @@ jobs: - name: Test run: swift test --no-parallel + build-linux: + name: Build & Smoke (${{ matrix.runner }}) + needs: lint + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + runner: [ubuntu-22.04, ubuntu-22.04-arm] + steps: + - uses: actions/checkout@v7 + + # Not a `container:` job: the official swift images run as root, and + # UpdateCheckerTests.registryWriteFailureContract asserts that a write into a 0555 + # directory fails, which root ignores. + - uses: ./.github/actions/setup-swift-linux + with: + swift-version: ${{ env.SWIFT_VERSION }} + + - uses: actions/cache@v6 + with: + path: | + .build/checkouts + .build/repositories + .build/workspace-state.json + key: spm-${{ hashFiles('Package.swift') }} + restore-keys: spm- + + - name: Resolve packages + run: swift package resolve + # The shape release.yml ships. Without this, a break in it first shows up on a tag. - name: Build release binary run: | From 7d9bde72a04df9e19189746c4f31afc475ebc444 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Wed, 30 Sep 2026 01:09:38 +0200 Subject: [PATCH 22/28] Stop hangs and unconfirmed actions when input is closed - A shellInteractive command gets a PTY only on a terminal; a picker that loses its input throws instead of answering - The Linux upgrade command runs only for a plain X.Y.Z tag, quotes its paths, and hands a sudo form to the user - Injected environments reach bootstrap's pack resolver and Homebrew prefixes; $HOME must be absolute, zsh is checked --- Sources/mcs/Bootstrap/PackAdder.swift | 18 +-- Sources/mcs/Commands/BootstrapCommand.swift | 4 +- Sources/mcs/Commands/CleanupCommand.swift | 2 +- Sources/mcs/Commands/ExportCommand.swift | 13 +- Sources/mcs/Commands/PackCommand.swift | 2 +- Sources/mcs/Commands/SyncCommand.swift | 4 +- Sources/mcs/Commands/UpdateCommand.swift | 8 +- Sources/mcs/Core/CLIOutput.swift | 103 ++++++------ Sources/mcs/Core/ClaudePrerequisite.swift | 8 +- Sources/mcs/Core/Environment.swift | 19 +-- Sources/mcs/Core/Homebrew.swift | 20 ++- Sources/mcs/Core/PTYBridge.swift | 5 +- Sources/mcs/Core/ShellRunner.swift | 20 ++- Sources/mcs/Core/UpdateChecker.swift | 127 ++++++++++----- Sources/mcs/Doctor/DoctorRunner.swift | 8 +- Sources/mcs/Export/ManifestBuilder.swift | 4 +- .../ExternalPack/ExternalDoctorCheck.swift | 2 +- .../mcs/ExternalPack/PackSourceResolver.swift | 2 +- .../mcs/ExternalPack/PackTrustManager.swift | 4 +- Sources/mcs/ExternalPack/PromptExecutor.swift | 8 +- Sources/mcs/Sync/Configurator.swift | 12 +- .../mcs/Sync/CrossPackPromptResolver.swift | 2 +- Sources/mcs/Sync/GlobalSyncStrategy.swift | 16 +- Sources/mcs/Sync/PackUpdater.swift | 16 +- Sources/mcs/Sync/ProjectSyncStrategy.swift | 17 +- Sources/mcs/Sync/SyncStrategy.swift | 10 ++ Sources/mcs/TechPack/HookInterpreter.swift | 8 +- Tests/MCSTests/CLIOutputTests.swift | 37 ++++- Tests/MCSTests/ClaudePrerequisiteTests.swift | 20 +-- Tests/MCSTests/CoreDoctorCheckTests.swift | 5 +- Tests/MCSTests/EnvironmentTests.swift | 8 +- Tests/MCSTests/ExternalDoctorCheckTests.swift | 59 +++++-- Tests/MCSTests/HomebrewTests.swift | 12 +- Tests/MCSTests/HookInterpreterTests.swift | 5 +- .../MCSTests/LifecycleIntegrationTests.swift | 12 +- Tests/MCSTests/ManifestBuilderTests.swift | 10 +- Tests/MCSTests/PTYBridgeTests.swift | 18 ++- Tests/MCSTests/PackAdderTests.swift | 10 +- Tests/MCSTests/PackSourceResolverTests.swift | 40 ++--- Tests/MCSTests/PackTrustManagerTests.swift | 4 +- Tests/MCSTests/ShellRunnerTests.swift | 21 ++- Tests/MCSTests/UpdateCheckerTests.swift | 150 ++++++++++++++---- 42 files changed, 559 insertions(+), 314 deletions(-) diff --git a/Sources/mcs/Bootstrap/PackAdder.swift b/Sources/mcs/Bootstrap/PackAdder.swift index 009a45d4..77e39e19 100644 --- a/Sources/mcs/Bootstrap/PackAdder.swift +++ b/Sources/mcs/Bootstrap/PackAdder.swift @@ -91,7 +91,7 @@ struct PackAdder { throw error } - guard resolveDuplicate( + guard try resolveDuplicate( manifest: manifest, sourceURL: gitURL, registryData: registryData, @@ -109,7 +109,7 @@ struct PackAdder { fetcher.removeQuietly(packPath: fetchResult.localPath) throw error } - if !collisions.isEmpty, !acceptCollisions(policy: options.duplicatePolicy) { + if !collisions.isEmpty, try !acceptCollisions(policy: options.duplicatePolicy) { fetcher.removeQuietly(packPath: fetchResult.localPath) ctx.output.info("Pack not added.") return .declined @@ -203,7 +203,7 @@ struct PackAdder { let registryData = try ctx.loadRegistry() - guard resolveDuplicate( + guard try resolveDuplicate( manifest: manifest, sourceURL: path.path, registryData: registryData, @@ -214,7 +214,7 @@ struct PackAdder { } let collisions = try detectCollisions(manifest: manifest, registryData: registryData) - if !collisions.isEmpty, !acceptCollisions(policy: options.duplicatePolicy) { + if !collisions.isEmpty, try !acceptCollisions(policy: options.duplicatePolicy) { ctx.output.info("Pack not added.") return .declined } @@ -319,7 +319,7 @@ struct PackAdder { sourceURL: String, registryData: PackRegistryFile.RegistryData, policy: DuplicatePolicy - ) -> Bool { + ) throws -> Bool { guard let existing = registryData.packs.first(where: { $0.identifier == manifest.identifier }) else { return true } @@ -334,7 +334,7 @@ struct PackAdder { switch policy { case .prompt: - return ctx.output.askYesNo("Replace existing pack?", default: false) + return try ctx.output.askYesNo("Replace existing pack?", default: false) case .autoAccept: ctx.output.plain(" Replacing (declared by mcs.yaml).") return true @@ -343,10 +343,10 @@ struct PackAdder { /// Prompt on collision when policy requires it. `autoAccept` returns `true` /// after the warning is already printed by `detectCollisions`. - func acceptCollisions(policy: DuplicatePolicy) -> Bool { + func acceptCollisions(policy: DuplicatePolicy) throws -> Bool { switch policy { case .prompt: - ctx.output.askYesNo("Continue anyway?", default: false) + try ctx.output.askYesNo("Continue anyway?", default: false) case .autoAccept: true } @@ -359,7 +359,7 @@ struct PackAdder { ) throws -> [String: String]? { let trustManager = PackTrustManager(output: ctx.output, policy: policy) let items = try trustManager.analyzeScripts(manifest: manifest, packPath: packPath) - guard trustManager.promptForTrust( + guard try trustManager.promptForTrust( manifest: manifest, packPath: packPath, items: items diff --git a/Sources/mcs/Commands/BootstrapCommand.swift b/Sources/mcs/Commands/BootstrapCommand.swift index 14676c75..43af9df2 100644 --- a/Sources/mcs/Commands/BootstrapCommand.swift +++ b/Sources/mcs/Commands/BootstrapCommand.swift @@ -34,7 +34,7 @@ struct BootstrapCommand: LockedCommand { // Dry-run must not trigger the Homebrew install prompt for Claude Code — that // would violate the no-changes contract on a preview. if !dryRun { - guard ensureClaudeCLI(shell: ctx.shell, environment: ctx.env, output: ctx.output) else { + guard try ensureClaudeCLI(shell: ctx.shell, environment: ctx.env, output: ctx.output) else { throw ExitCode.failure } } @@ -112,7 +112,7 @@ struct BootstrapCommand: LockedCommand { file: BootstrapFile, ctx: PackCommandContext ) throws -> [String] { - let resolver = PackSourceResolver() + let resolver = PackSourceResolver(environment: ctx.env) let adder = PackAdder(ctx: ctx) // Bootstrap is non-interactive by design: mcs.yaml already expresses the user's // intent, so identifier duplicates and artifact collisions must not fall through diff --git a/Sources/mcs/Commands/CleanupCommand.swift b/Sources/mcs/Commands/CleanupCommand.swift index 3b9b4bed..60415590 100644 --- a/Sources/mcs/Commands/CleanupCommand.swift +++ b/Sources/mcs/Commands/CleanupCommand.swift @@ -46,7 +46,7 @@ struct CleanupCommand: LockedCommand { output.plain("") - guard force || output.askYesNo("Delete all \(backups.count) backup file(s)?", default: false) else { + guard try force || output.askYesNo("Delete all \(backups.count) backup file(s)?", default: false) else { output.info("No backups deleted.") return } diff --git a/Sources/mcs/Commands/ExportCommand.swift b/Sources/mcs/Commands/ExportCommand.swift index 7ae1334c..ac19156e 100644 --- a/Sources/mcs/Commands/ExportCommand.swift +++ b/Sources/mcs/Commands/ExportCommand.swift @@ -60,7 +60,7 @@ struct ExportCommand: ParsableCommand { let selection: Selection = if nonInteractive { selectAll(from: config) } else { - interactiveSelect(config: config, output: output) + try interactiveSelect(config: config, output: output) } // 4. Gather metadata @@ -76,7 +76,7 @@ struct ExportCommand: ParsableCommand { } // 5. Build manifest - let builder = ManifestBuilder() + let builder = ManifestBuilder(environment: env) let options = ManifestBuilder.BuildOptions( selectedMCPServers: selection.mcpServers, selectedHookFiles: selection.hookFiles, @@ -105,7 +105,7 @@ struct ExportCommand: ParsableCommand { output.plain("") output.success("Pack exported successfully!") - printPostExportHints(config: config, output: output) + printPostExportHints(config: config, environment: env, output: output) } } @@ -182,7 +182,7 @@ struct ExportCommand: ParsableCommand { private func interactiveSelect( config: ConfigurationDiscovery.DiscoveredConfiguration, output: CLIOutput - ) -> Selection { + ) throws -> Selection { var groups: [SelectableGroup] = [] var counter = 0 var mappings: [ItemCategory: [Int: String]] = [:] @@ -279,7 +279,7 @@ struct ExportCommand: ParsableCommand { } // Run multi-select - let selected = output.multiSelect(groups: &groups) + let selected = try output.multiSelect(groups: &groups) func selectedNames(_ category: ItemCategory) -> Set { guard let mapping = mappings[category] else { return [] } @@ -326,6 +326,7 @@ struct ExportCommand: ParsableCommand { private func printPostExportHints( config: ConfigurationDiscovery.DiscoveredConfiguration, + environment: Environment, output: CLIOutput ) { let resolvedPath = URL(fileURLWithPath: outputDir).standardizedFileURL.path @@ -334,7 +335,7 @@ struct ExportCommand: ParsableCommand { // Check for MCP servers that might need brew (dynamic symlink resolution) let detectedFormulas = Set(config.mcpServers.compactMap { server -> String? in guard let command = server.command else { return nil } - return Homebrew.detectFormula(for: command) + return Homebrew.detectFormula(for: command, environment: environment) }) if !detectedFormulas.isEmpty { hints.append("Some MCP servers may need brew packages: \(detectedFormulas.sorted().joined(separator: ", "))") diff --git a/Sources/mcs/Commands/PackCommand.swift b/Sources/mcs/Commands/PackCommand.swift index 4069f3f1..57be52d3 100644 --- a/Sources/mcs/Commands/PackCommand.swift +++ b/Sources/mcs/Commands/PackCommand.swift @@ -172,7 +172,7 @@ struct RemovePack: LockedCommand { // 4. Confirm if !force { - guard ctx.output.askYesNo("Remove pack '\(entry.displayName)'?", default: false) else { + guard try ctx.output.askYesNo("Remove pack '\(entry.displayName)'?", default: false) else { ctx.output.info("Pack not removed.") return } diff --git a/Sources/mcs/Commands/SyncCommand.swift b/Sources/mcs/Commands/SyncCommand.swift index d403e130..0064cdf9 100644 --- a/Sources/mcs/Commands/SyncCommand.swift +++ b/Sources/mcs/Commands/SyncCommand.swift @@ -57,7 +57,7 @@ struct SyncCommand: LockedCommand { let output = CLIOutput() let shell = ShellRunner(environment: env) - guard ensureClaudeCLI(shell: shell, environment: env, output: output) else { + guard try ensureClaudeCLI(shell: shell, environment: env, output: output) else { throw ExitCode.failure } @@ -255,7 +255,7 @@ struct SyncCommand: LockedCommand { output.plain(" Add '--global' to run global sync, or run from a project directory.") throw ExitCode.failure } - let useGlobal = output.askYesNo( + let useGlobal = try output.askYesNo( "It looks like you want to sync global scope. Use 'mcs sync --global' instead?", default: true ) diff --git a/Sources/mcs/Commands/UpdateCommand.swift b/Sources/mcs/Commands/UpdateCommand.swift index 56d8e856..714fb9c3 100644 --- a/Sources/mcs/Commands/UpdateCommand.swift +++ b/Sources/mcs/Commands/UpdateCommand.swift @@ -39,7 +39,7 @@ struct UpdateCommand: LockedCommand { let output = CLIOutput() let shell = ShellRunner(environment: env) - guard ensureClaudeCLI(shell: shell, environment: env, output: output) else { + guard try ensureClaudeCLI(shell: shell, environment: env, output: output) else { throw ExitCode.failure } @@ -53,7 +53,7 @@ struct UpdateCommand: LockedCommand { return } - if allProjects, !confirmFanOut(runs: runs, env: env, output: output) { + if allProjects, try !confirmFanOut(runs: runs, env: env, output: output) { output.info("Update cancelled.") return } @@ -173,7 +173,7 @@ struct UpdateCommand: LockedCommand { runs: [UpdateScopeResolver.ScopeRun], env: Environment, output: CLIOutput - ) -> Bool { + ) throws -> Bool { guard !dryRun, output.hasInteractiveStdin else { return true } let projectPaths = runs.compactMap(\.projectPath) @@ -202,7 +202,7 @@ struct UpdateCommand: LockedCommand { output.plain(" Each pack is re-applied in every listed scope. Local edits to") output.plain(" settings.local.json, hooks, or skills in those projects may be overwritten.") output.plain("") - return output.askYesNo("Proceed?", default: false) + return try output.askYesNo("Proceed?", default: false) } private func warnIfProjectScopeMissing( diff --git a/Sources/mcs/Core/CLIOutput.swift b/Sources/mcs/Core/CLIOutput.swift index c7e31e4e..0d22851f 100644 --- a/Sources/mcs/Core/CLIOutput.swift +++ b/Sources/mcs/Core/CLIOutput.swift @@ -26,6 +26,15 @@ final class WarningCounter: Sendable { } } +/// Thrown by a raw-mode picker whose input closed before it was answered. That picker only runs +/// when stdin was a terminal, so this is a hangup rather than an unattended run, and no default is +/// safe to assume for every caller. +struct InputClosedError: Error, LocalizedError { + var errorDescription: String? { + "Input closed before the prompt was answered." + } +} + /// Terminal output with ANSI color support and structured logging. struct CLIOutput { let colorsEnabled: Bool @@ -40,11 +49,14 @@ struct CLIOutput { /// Optional tally that `warn(_:)` increments. `nil` for most callers; set by /// callers (e.g. `DoctorRunner`) that need to count emitted warnings. let warningCounter: WarningCounter? + /// The descriptor the raw-mode pickers read keys from. + let input: Int32 init( colorsEnabled: Bool? = nil, warningCounter: WarningCounter? = nil, - interactiveStdin: Bool? = nil + interactiveStdin: Bool? = nil, + input: Int32 = STDIN_FILENO ) { if let explicit = colorsEnabled { self.colorsEnabled = explicit @@ -55,6 +67,7 @@ struct CLIOutput { isInteractiveTerminal = hasInteractiveStdin && isatty(STDOUT_FILENO) != 0 style = ANSIStyle(enabled: self.colorsEnabled) self.warningCounter = warningCounter + self.input = input } // MARK: - ANSI Codes (delegate to `style`) @@ -236,9 +249,9 @@ struct CLIOutput { /// Ask a yes/no question. Returns true for yes, false for no. /// Uses arrow-key navigation on TTY, falls back to text input otherwise. - func askYesNo(_ prompt: String, default defaultValue: Bool = true) -> Bool { + func askYesNo(_ prompt: String, default defaultValue: Bool = true) throws -> Bool { if isInteractiveTerminal { - return interactiveYesNo(prompt, default: defaultValue) + return try interactiveYesNo(prompt, default: defaultValue) } return fallbackYesNo(prompt, default: defaultValue) } @@ -264,20 +277,14 @@ struct CLIOutput { } } - private func interactiveYesNo(_ prompt: String, default defaultValue: Bool) -> Bool { - withRawTerminal { + func interactiveYesNo(_ prompt: String, default defaultValue: Bool) throws -> Bool { + try withRawTerminal { var selected = defaultValue renderYesNo(prompt: prompt, selected: selected) while true { - // Unlike the text fallback, this path only runs when stdin *was* a terminal, so - // end of input here is a hangup, not an unattended run — and the default may be - // the destructive answer. No is safe at every call site. - guard let byte = readByte() else { - reportInputClosed() - return false - } + let byte = try nextKey() switch byte { case 0x0A, 0x0D, 0x20: // Enter or Space — confirm @@ -378,9 +385,9 @@ struct CLIOutput { /// Multi-select checklist with arrow key navigation. /// Use arrow keys to move, space to toggle, Enter to confirm. /// Falls back to number-based input when not a TTY. - func multiSelect(groups: inout [SelectableGroup]) -> Set { + func multiSelect(groups: inout [SelectableGroup]) throws -> Set { if isInteractiveTerminal { - return interactiveMultiSelect(groups: &groups) + return try interactiveMultiSelect(groups: &groups) } return fallbackMultiSelect(groups: &groups) } @@ -397,31 +404,28 @@ struct CLIOutput { title: String, items: [(name: String, description: String)], initialIndex: Int = 0 - ) -> Int { + ) throws -> Int { guard !items.isEmpty else { return 0 } let seed = max(0, min(initialIndex, items.count - 1)) if isInteractiveTerminal { - return interactiveSingleSelect(title: title, items: items, initialIndex: seed) + return try interactiveSingleSelect(title: title, items: items, initialIndex: seed) } return fallbackSingleSelect(title: title, items: items, initialIndex: seed) } - private func interactiveSingleSelect( + func interactiveSingleSelect( title: String, items: [(name: String, description: String)], initialIndex: Int - ) -> Int { - withRawTerminal { + ) throws -> Int { + try withRawTerminal { var cursor = initialIndex renderSingleSelectList(title: title, items: items, cursor: cursor) while true { - guard let byte = readByte() else { - reportInputClosed() - return cursor - } + let byte = try nextKey() switch byte { case 0x0A, 0x0D, 0x20: // Enter or Space — confirm selection @@ -536,7 +540,7 @@ struct CLIOutput { // MARK: - Interactive Multi-Select (raw terminal) - private func interactiveMultiSelect(groups: inout [SelectableGroup]) -> Set { + func interactiveMultiSelect(groups: inout [SelectableGroup]) throws -> Set { var flatItems: [(groupIndex: Int, itemIndex: Int)] = [] for gi in groups.indices { for ii in groups[gi].items.indices { @@ -548,16 +552,13 @@ struct CLIOutput { return collectSelected(from: groups) } - return withRawTerminal { + return try withRawTerminal { var cursor = 0 renderInteractiveList(groups: groups, cursor: cursor) while true { - guard let byte = readByte() else { - reportInputClosed() - return collectSelected(from: groups) - } + let byte = try nextKey() switch byte { case 0x0A, 0x0D: // Enter @@ -701,24 +702,25 @@ struct CLIOutput { write(output) } - /// Leaves a line in scrollback saying why the prompt ended without a key — the pickers return - /// a value either way, and without this the transcript shows only the question. - private func reportInputClosed() { - write("\n") - warn("Input closed before the prompt was answered.") + /// The next key byte, or `InputClosedError` once input has ended. The pickers loop until they + /// recognise a key, so an end of input they could not tell apart from a key would spin forever. + private func nextKey() throws -> UInt8 { + guard let byte = readByte() else { + write("\n") + throw InputClosedError() + } + return byte } - /// One byte from `fd`, or `nil` at end of input. - /// - /// Optional because the pickers below loop until they recognise a key: on EOF or a read error - /// no key will ever arrive, and a zero byte matches no case, so returning one would spin at - /// 100% CPU until the process is killed. Reachable whenever stdin closes under a raw-mode - /// prompt — a hung-up terminal in a `nohup`/`setsid` wrapper that ignores SIGHUP, for instance. - /// The descriptor is a parameter so the EOF path can be driven from a pipe under test. - func readByte(from fd: Int32 = STDIN_FILENO) -> UInt8? { + /// One byte from `input`, or `nil` at end of input or on a read error. + func readByte() -> UInt8? { var byte: UInt8 = 0 - guard read(fd, &byte, 1) > 0 else { return nil } - return byte + while true { + let count = read(input, &byte, 1) + if count > 0 { return byte } + if count < 0, errno == EINTR { continue } + return nil + } } /// Reads a CSI arrow key escape sequence after the initial 0x1B byte. @@ -730,17 +732,20 @@ struct CLIOutput { /// Enters raw terminal mode (no echo, no canonical processing, hidden cursor), /// runs the body closure, then restores the terminal on return. - private func withRawTerminal(_ body: () -> T) -> T { + /// + /// When the attributes cannot be read there is nothing to restore — writing back a zeroed + /// `termios` would leave the user's shell without echo or signals — so the body runs as is. + private func withRawTerminal(_ body: () throws -> T) rethrows -> T { var original = termios() - tcgetattr(STDIN_FILENO, &original) + guard tcgetattr(input, &original) == 0 else { return try body() } var raw = TerminalAttributes.rawMode(from: original) - tcsetattr(STDIN_FILENO, TCSANOW, &raw) + tcsetattr(input, TCSANOW, &raw) write("\u{1B}[?25l") defer { write("\u{1B}[?25h") - tcsetattr(STDIN_FILENO, TCSANOW, &original) + tcsetattr(input, TCSANOW, &original) } - return body() + return try body() } // MARK: - Fallback Multi-Select (non-TTY) diff --git a/Sources/mcs/Core/ClaudePrerequisite.swift b/Sources/mcs/Core/ClaudePrerequisite.swift index 5f73509a..4f79e8dd 100644 --- a/Sources/mcs/Core/ClaudePrerequisite.swift +++ b/Sources/mcs/Core/ClaudePrerequisite.swift @@ -14,8 +14,8 @@ func ensureClaudeCLI( environment: Environment, output: CLIOutput, brewInstalled: Bool? = nil, - confirmInstall: (() -> Bool)? = nil -) -> Bool { + confirmInstall: (() throws -> Bool)? = nil +) throws -> Bool { if shell.commandExists(Constants.CLI.claudeCommand) { return true } @@ -30,8 +30,8 @@ func ensureClaudeCLI( return false } - let confirm = confirmInstall ?? { output.askYesNo("Install Claude Code via Homebrew?", default: true) } - guard confirm() else { + let confirm = confirmInstall ?? { try output.askYesNo("Install Claude Code via Homebrew?", default: true) } + guard try confirm() else { printManualClaudeInstallInstructions(output) return false } diff --git a/Sources/mcs/Core/Environment.swift b/Sources/mcs/Core/Environment.swift index 40bee39d..a76d67ee 100644 --- a/Sources/mcs/Core/Environment.swift +++ b/Sources/mcs/Core/Environment.swift @@ -54,7 +54,7 @@ struct Environment { brewPath = resolvedBrew brewPrefix = Self.brewPrefix(forBrewPath: resolvedBrew) } else { - brewPrefix = Self.defaultBrewPrefix + brewPrefix = Homebrew.defaultPrefix brewPath = "\(brewPrefix)/bin/brew" } @@ -87,7 +87,8 @@ struct Environment { static func defaultHomeDirectory( environment: [String: String] = ProcessInfo.processInfo.environment ) -> String { - guard let home = environment["HOME"], !home.isEmpty else { + // A relative `$HOME` would resolve against whatever directory mcs was launched from. + guard let home = environment["HOME"], home.hasPrefix("/") else { return NSHomeDirectory() } return home @@ -97,25 +98,13 @@ struct Environment { /// `expandingTildeInPath` reads — otherwise a `~/…` pack path or doctor check would resolve /// under a different home than `~/.mcs` whenever `$HOME` is overridden. func expandingTilde(_ path: String) -> String { - if path == "~" { + if path == "~" || path == "~/" { return homeDirectory.path } guard path.hasPrefix("~/") else { return path } return homeDirectory.appendingPathComponent(String(path.dropFirst(2))).path } - /// Where Homebrew installs itself when no `brew` is on PATH to ask. - static var defaultBrewPrefix: String { - #if canImport(Darwin) && arch(arm64) - "/opt/homebrew" - #elseif canImport(Darwin) - "/usr/local" - #else - // Linuxbrew's documented multi-user prefix; unlike macOS it does not vary by architecture. - "/home/linuxbrew/.linuxbrew" - #endif - } - /// Directory where external tech pack checkouts live (`~/.mcs/packs/`). var packsDirectory: URL { mcsDirectory.appendingPathComponent(Constants.ExternalPacks.packsDirectory) diff --git a/Sources/mcs/Core/Homebrew.swift b/Sources/mcs/Core/Homebrew.swift index 9154b41d..582a7668 100644 --- a/Sources/mcs/Core/Homebrew.swift +++ b/Sources/mcs/Core/Homebrew.swift @@ -2,13 +2,25 @@ import Foundation /// Manages Homebrew package installation and service management. struct Homebrew { + /// Where Homebrew installs itself when no `brew` is on PATH to ask. + static var defaultPrefix: String { + #if canImport(Darwin) && arch(arm64) + "/opt/homebrew" + #elseif canImport(Darwin) + "/usr/local" + #else + // Linuxbrew's documented multi-user prefix; unlike macOS it does not vary by architecture. + "/home/linuxbrew/.linuxbrew" + #endif + } + /// Every prefix Homebrew installs itself at on this platform: both macOS architectures, or /// Linuxbrew's multi-user and single-user locations. - static var allPrefixes: [String] { + static func allPrefixes(home: URL) -> [String] { #if canImport(Darwin) ["/opt/homebrew", "/usr/local"] #else - ["/home/linuxbrew/.linuxbrew", Environment.defaultHomeDirectory() + "/.linuxbrew"] + [defaultPrefix, home.appendingPathComponent(".linuxbrew").path] #endif } @@ -84,10 +96,10 @@ struct Homebrew { /// Uses single-hop symlink reading (`destinationOfSymbolicLink`) instead of full /// resolution because some commands chain through multiple symlinks where the final /// target leaves the Cellar path (e.g. npx → Cellar/node/.../npx → lib/node_modules/...). - static func detectFormula(for command: String) -> String? { + static func detectFormula(for command: String, environment: Environment) -> String? { let fm = FileManager.default let basename = bareName(of: command) - for prefix in allPrefixes { + for prefix in allPrefixes(home: environment.homeDirectory) { let binPath = "\(prefix)/bin/\(basename)" guard let dest = try? fm.destinationOfSymbolicLink(atPath: binPath) else { continue } diff --git a/Sources/mcs/Core/PTYBridge.swift b/Sources/mcs/Core/PTYBridge.swift index 79c48b7f..1d38e00a 100644 --- a/Sources/mcs/Core/PTYBridge.swift +++ b/Sources/mcs/Core/PTYBridge.swift @@ -27,7 +27,7 @@ enum PTYBridge { enum PTYAction: Equatable { /// Output is waiting, or the child hung up with output possibly still buffered. case read - /// The descriptor itself is bad; the command is gone and the bridge ends. + /// The descriptor errored or is invalid, with nothing left to read; the bridge ends. case close case idle } @@ -39,8 +39,9 @@ enum PTYBridge { } static func ptyAction(revents: Int16) -> PTYAction { - if revents & Int16(POLLERR | POLLNVAL) != 0 { return .close } + if revents & Int16(POLLNVAL) != 0 { return .close } if revents & Int16(POLLIN | POLLHUP) != 0 { return .read } + if revents & Int16(POLLERR) != 0 { return .close } return .idle } } diff --git a/Sources/mcs/Core/ShellRunner.swift b/Sources/mcs/Core/ShellRunner.swift index 7c1e94e4..5849b4cf 100644 --- a/Sources/mcs/Core/ShellRunner.swift +++ b/Sources/mcs/Core/ShellRunner.swift @@ -77,12 +77,11 @@ extension ShellRunning { /// Runs shell commands and captures output. struct ShellRunner: ShellRunning { - /// The warning for a failed interactive command. The child's own output already went to the - /// terminal, so the result's `stderr` is empty unless the bridge itself failed before `exec` — - /// `forkpty` refused, a C string could not be allocated — and then it is the only record of - /// why, so it is what gets printed. - static func interactiveFailureMessage(name: String, stderr: String) -> String { - stderr.isEmpty ? "\(name) failed (see output above)" : "\(name) failed: \(stderr)" + /// The warning for a failed shell component. A command that ran in a terminal already printed + /// its own output, so its `stderr` is empty unless the PTY bridge itself failed before `exec`. + static func failureMessage(name: String, stderr: String, ranInTerminal: Bool) -> String { + if !stderr.isEmpty { return "\(name) failed: \(stderr.prefix(200))" } + return ranInTerminal ? "\(name) failed (see output above)" : "\(name) failed" } let environment: Environment @@ -324,13 +323,14 @@ struct ShellRunner: ShellRunning { } let ptyAction = PTYBridge.ptyAction(revents: fds[1].revents) - if ptyAction == .close { break bridgeLoop } + if case .close = ptyAction { break bridgeLoop } // Terminal → PTY (user typing, including password input). poll(2) skips negative // fds, so a dropped stdin leaves the loop draining the PTY alone. switch PTYBridge.stdinAction(revents: fds[0].revents) { case .forward: let n = read(STDIN_FILENO, &buf, buf.count) + if n < 0, errno == EINTR { break } if n <= 0 { fds[0].fd = -1 } else { @@ -343,10 +343,14 @@ struct ShellRunner: ShellRunning { } // PTY → Terminal (command output, prompts, progress bars) - if ptyAction == .read { + switch ptyAction { + case .read: let n = read(ptyFD, &buf, buf.count) + if n < 0, errno == EINTR { continue } if n <= 0 { break bridgeLoop } // Child closed the PTY writeAll(fd: STDOUT_FILENO, buf: buf, count: n) + case .close, .idle: + break } } diff --git a/Sources/mcs/Core/UpdateChecker.swift b/Sources/mcs/Core/UpdateChecker.swift index e0072fe2..c9bb3eba 100644 --- a/Sources/mcs/Core/UpdateChecker.swift +++ b/Sources/mcs/Core/UpdateChecker.swift @@ -25,28 +25,74 @@ struct UpdateChecker { static let hookTimeout: Int = 30 static let hookStatusMessage = "Checking for updates..." - /// Commands that upgrade the mcs binary in place, or `[]` when this install offers no upgrade - /// path mcs can hand over — the caller then points at the releases page instead. - /// - /// Claude runs these after asking the user, so the Linux form has to be safe in a shell it does - /// not own: the tarball member goes to a sibling temp file rather than over the live binary, - /// its `--version` runs before anything is replaced, and the final `mv` is a same-directory - /// rename, which is atomic and legal while the old binary is still mapped. - static func cliUpgradeCommands(toVersion version: String) -> [String] { + /// How the installed mcs binary can be upgraded to a newer release. + enum CLIUpgrade: Equatable { + /// Commands Claude may run on the user's behalf once they agree. + case commands([String]) + /// A command that needs `sudo`, which Claude's shell cannot answer a password prompt for. + case runYourself(String) + /// No command can be offered; the user downloads the release by hand. + case manual(reason: String) + } + + static let manualUpgradeHint = "Download the latest release from \(Constants.MCSRepo.releasesURL)." + + /// The upgrade path for this install. macOS upgrades through Homebrew; Linux swaps the release + /// tarball's binary in at the path the kernel reports for this process. + static func cliUpgrade(toVersion version: String) -> CLIUpgrade { #if canImport(Darwin) - ["brew update", "brew upgrade \(Constants.MCSRepo.brewFormula)"] + .commands(["brew update", "brew upgrade \(Constants.MCSRepo.brewFormula)"]) #else - guard let path = installedBinaryPath else { return [] } + let path: String + do { + path = try installedBinaryPath() + } catch { + return .manual(reason: "the installed binary could not be located (\(error.localizedDescription))") + } let directory = (path as NSString).deletingLastPathComponent - let staged = "\(directory)/.mcs-upgrade" - let url = Constants.MCSRepo.assetURL(tag: version, asset: "mcs-\(version)-linux-\(releaseArch).tar.gz") - let script = "curl -fsSL \(url) | tar -xzO mcs > \(staged)" - + " && chmod +x \(staged) && \(staged) --version && mv -f \(staged) \(path)" - // Writing as the user keeps the binary's ownership; sudo only where the directory forbids it. - return FileManager.default.isWritableFile(atPath: directory) ? [script] : ["sudo sh -c '\(script)'"] + return linuxUpgrade( + toVersion: version, + binaryPath: path, + directoryWritable: FileManager.default.isWritableFile(atPath: directory), + arch: releaseArch + ) #endif } + /// The tarball swap, as a pure function of its inputs. Every step has to be safe in a shell mcs + /// does not own: the version comes from a remote tag and the path from the filesystem, so both + /// are validated or quoted; the member is staged beside the binary and run before anything is + /// replaced; and the final `mv` is a same-directory rename, atomic while the old binary is mapped. + static func linuxUpgrade( + toVersion version: String, + binaryPath path: String, + directoryWritable: Bool, + arch: String + ) -> CLIUpgrade { + guard version.range(of: #"^[0-9]+\.[0-9]+\.[0-9]+$"#, options: .regularExpression) != nil else { + return .manual(reason: "release '\(version)' is not a plain version number") + } + // The kernel appends this once the file behind a running process is replaced or unlinked. + guard !path.hasSuffix(" (deleted)") else { + return .manual(reason: "the running binary was replaced or removed") + } + let directory = (path as NSString).deletingLastPathComponent + let staged = shellQuoted("\(directory)/.mcs-upgrade") + let url = shellQuoted( + Constants.MCSRepo.assetURL(tag: version, asset: "mcs-\(version)-linux-\(arch).tar.gz") + ) + let script = "{ curl -fsSL \(url) | tar -xzO mcs > \(staged)" + + " && chmod +x \(staged) && \(staged) --version && mv -f \(staged) \(shellQuoted(path)); }" + + " || { rm -f \(staged); false; }" + // sudo only where the directory demands it, so a user-local install never ends up root-owned. + return directoryWritable ? .commands([script]) : .runYourself("sudo sh -c \(shellQuoted(script))") + } + + /// `value` as one single-quoted shell word. + static func shellQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: #"'\''"#) + "'" + } + /// The architecture slug used in the Linux release asset names. static var releaseArch: String { #if arch(arm64) @@ -57,18 +103,9 @@ struct UpdateChecker { } /// Where this process's binary lives, read from the kernel rather than `argv[0]`, which a - /// caller controls. Darwin has no equivalent that is worth trusting here, and does not need - /// one: Homebrew owns the upgrade there. - static var installedBinaryPath: String? { - #if canImport(Darwin) - nil - #else - do { - return try FileManager.default.destinationOfSymbolicLink(atPath: "/proc/self/exe") - } catch { - return nil - } - #endif + /// caller controls. + static func installedBinaryPath() throws -> String { + try FileManager.default.destinationOfSymbolicLink(atPath: "/proc/self/exe") } static let manualPackUpdateCommand = "mcs update --all-projects" @@ -626,7 +663,9 @@ struct UpdateChecker { if isHook { // Structured JSON for Claude Code SessionStart hook - let context = buildContextString(result) + let context = buildContextString( + result, upgrade: result.cliUpdate.map { cliUpgrade(toVersion: $0.latestVersion) } + ) let hookOutput: [String: Any] = [ "hookSpecificOutput": [ "hookEventName": Constants.HookEvent.sessionStart.rawValue, @@ -645,10 +684,14 @@ struct UpdateChecker { } else { // User-invoked: colored output for terminal readability if let cli = result.cliUpdate { - let commands = cliUpgradeCommands(toVersion: cli.latestVersion) - let how = commands.isEmpty - ? "Download the latest release from \(Constants.MCSRepo.releasesURL)." - : "Run '\(commands.joined(separator: " && "))' to update." + let how = switch cliUpgrade(toVersion: cli.latestVersion) { + case let .commands(commands): + "Run '\(commands.joined(separator: " && "))' to update." + case let .runYourself(command): + "Run '\(command)' to update." + case let .manual(reason): + "\(manualUpgradeHint) (No upgrade command: \(reason).)" + } output.warn("mcs \(cli.latestVersion) available (current: \(cli.currentVersion)). " + how) } if !result.packUpdates.isEmpty { @@ -669,7 +712,8 @@ struct UpdateChecker { return true } - static func buildContextString(_ result: CheckResult) -> String { + /// `upgrade` is the path for `result.cliUpdate`, resolved by the caller because it reads the host. + static func buildContextString(_ result: CheckResult, upgrade: CLIUpgrade?) -> String { // `additionalContext` arrives with the user's first message and can't pause it, so holding // the request until the user answers relies on AskUserQuestion being the first tool call. var lines: [String] = [] @@ -682,14 +726,19 @@ struct UpdateChecker { lines.append( "- New mcs version \(cli.latestVersion) is available (installed: \(cli.currentVersion))." ) - let upgrade = cliUpgradeCommands(toVersion: cli.latestVersion) - if upgrade.isEmpty { + switch upgrade { + case let .commands(upgradeCommands): + commands += upgradeCommands + case let .runYourself(command): lines.append( - " There is no upgrade command for this install; it has to be downloaded from" - + " \(Constants.MCSRepo.releasesURL)." + " Upgrading needs sudo, which cannot prompt for a password here, so do not run it;" + + " give the user this command to run in their own terminal: \(command)" ) + case let .manual(reason): + lines.append(" There is no upgrade command for this install (\(reason)). \(manualUpgradeHint)") + case nil: + break } - commands += upgrade } let hasPackUpdates = !result.packUpdates.isEmpty if hasPackUpdates { diff --git a/Sources/mcs/Doctor/DoctorRunner.swift b/Sources/mcs/Doctor/DoctorRunner.swift index 988463ae..f8f2b411 100644 --- a/Sources/mcs/Doctor/DoctorRunner.swift +++ b/Sources/mcs/Doctor/DoctorRunner.swift @@ -334,7 +334,7 @@ struct DoctorRunner { // Phase 2: Confirm and execute pending fixes (after summary) if fixMode { - executePendingFixes() + try executePendingFixes() summary.remainingIssues = failCount - fixedCount if fixedCount > 0 { output.plain("") @@ -686,7 +686,7 @@ struct DoctorRunner { /// Phase 2: Show a summary of pending fixes with their actual commands, /// prompt for confirmation, then execute. - private mutating func executePendingFixes() { + private mutating func executePendingFixes() throws { let ownFixes = pendingFixes.map(\.check).filter { $0.fixCommandPreview != nil } let (resyncs, hintOnly) = planResyncs(pendingFixes.filter { $0.check.fixCommandPreview == nil }) @@ -718,7 +718,7 @@ struct DoctorRunner { let count = ownFixes.count + resyncs.count let fixLabel = count == 1 ? "fix" : "fixes" if !skipConfirmation { - guard output.askYesNo("Apply \(count) \(fixLabel)?", default: false) else { + guard try output.askYesNo("Apply \(count) \(fixLabel)?", default: false) else { output.dimmed(" Skipped all fixes.") return } @@ -730,7 +730,7 @@ struct DoctorRunner { } guard !resyncs.isEmpty else { return } // An injected CLI stands in for the real binary; only the real one needs installing. - if claudeCLI == nil, !ensureClaudeCLI(shell: shell, environment: environment, output: output) { + if claudeCLI == nil, try !ensureClaudeCLI(shell: shell, environment: environment, output: output) { for check in resyncs.flatMap(\.checks) { docFixFailed(check.name, "re-sync needs the Claude Code CLI") } diff --git a/Sources/mcs/Export/ManifestBuilder.swift b/Sources/mcs/Export/ManifestBuilder.swift index c561fb52..1f1e294f 100644 --- a/Sources/mcs/Export/ManifestBuilder.swift +++ b/Sources/mcs/Export/ManifestBuilder.swift @@ -7,6 +7,8 @@ import Foundation /// 1. `buildManifest()` — constructs a typed `ExternalPackManifest` (compile-time coupling to schema) /// 2. `renderYAML()` — serializes the typed model to shorthand YAML with presentational formatting struct ManifestBuilder { + let environment: Environment + struct Metadata { let identifier: String let displayName: String @@ -111,7 +113,7 @@ struct ManifestBuilder { let id = "mcp-\(sanitizeID(server.name))" // Detect brew dependency hint via Homebrew symlink resolution - if let command = server.command, let formula = Homebrew.detectFormula(for: command) { + if let command = server.command, let formula = Homebrew.detectFormula(for: command, environment: environment) { brewHints[id] = formula } diff --git a/Sources/mcs/ExternalPack/ExternalDoctorCheck.swift b/Sources/mcs/ExternalPack/ExternalDoctorCheck.swift index 09a92319..f43c734a 100644 --- a/Sources/mcs/ExternalPack/ExternalDoctorCheck.swift +++ b/Sources/mcs/ExternalPack/ExternalDoctorCheck.swift @@ -486,7 +486,7 @@ enum ExternalDoctorCheckFactory { packPath: URL, projectRoot: URL?, scriptRunner: ScriptRunner, - environment: Environment = Environment() + environment: Environment ) -> any DoctorCheck { let section = definition.section ?? "External Pack" let scope = definition.scope ?? .global diff --git a/Sources/mcs/ExternalPack/PackSourceResolver.swift b/Sources/mcs/ExternalPack/PackSourceResolver.swift index 4ee0a4f6..100a2b48 100644 --- a/Sources/mcs/ExternalPack/PackSourceResolver.swift +++ b/Sources/mcs/ExternalPack/PackSourceResolver.swift @@ -35,7 +35,7 @@ struct PackSourceResolver { /// character to exclude path-like inputs such as `../foo` or `./bar`. static let shorthandPattern = #"^[a-zA-Z0-9][a-zA-Z0-9_.-]*/[a-zA-Z0-9][a-zA-Z0-9_.-]*$"# - var environment: Environment = .init() + let environment: Environment func resolve(_ input: String) throws -> PackSource { guard !input.hasPrefix("-") else { diff --git a/Sources/mcs/ExternalPack/PackTrustManager.swift b/Sources/mcs/ExternalPack/PackTrustManager.swift index a948c5c0..466c2626 100644 --- a/Sources/mcs/ExternalPack/PackTrustManager.swift +++ b/Sources/mcs/ExternalPack/PackTrustManager.swift @@ -149,7 +149,7 @@ struct PackTrustManager { manifest: ExternalPackManifest, packPath _: URL, items: [TrustableItem] - ) -> Bool { + ) throws -> Bool { if items.isEmpty { return true } @@ -237,7 +237,7 @@ struct PackTrustManager { } output.plain("") - return output.askYesNo("Trust this pack?", default: false) + return try output.askYesNo("Trust this pack?", default: false) } /// Heads the auto-trust block. The registry keeps the approved hashes either way, so what diff --git a/Sources/mcs/ExternalPack/PromptExecutor.swift b/Sources/mcs/ExternalPack/PromptExecutor.swift index 3dc2d43f..bd91a289 100644 --- a/Sources/mcs/ExternalPack/PromptExecutor.swift +++ b/Sources/mcs/ExternalPack/PromptExecutor.swift @@ -54,7 +54,7 @@ struct PromptExecutor { case .input: executeInput(prompt: prompt, priorValue: priorValue) case .select: - executeSelect(prompt: prompt, priorValue: priorValue) + try executeSelect(prompt: prompt, priorValue: priorValue) case .script: try executeScript(prompt: prompt, packPath: packPath, projectPath: projectPath) } @@ -126,7 +126,7 @@ struct PromptExecutor { let initialIndex = (priorValue.flatMap { files.firstIndex(of: $0) }) ?? (prompt.defaultValue.flatMap { files.firstIndex(of: $0) }) ?? 0 - let selected = output.singleSelect(title: label, items: items, initialIndex: initialIndex) + let selected = try output.singleSelect(title: label, items: items, initialIndex: initialIndex) return files[selected] } } @@ -207,7 +207,7 @@ struct PromptExecutor { /// `priorValue` (stored from a previous sync) becomes the pre-selected option when /// it matches an option value; otherwise the stored value is ignored (caller should /// have already purged invalid select priors upstream). - private func executeSelect(prompt: PromptDefinition, priorValue: String?) -> String { + private func executeSelect(prompt: PromptDefinition, priorValue: String?) throws -> String { guard let options = prompt.options, !options.isEmpty else { return priorValue ?? prompt.defaultValue ?? "" } @@ -217,7 +217,7 @@ struct PromptExecutor { } let label = prompt.label ?? "Select value for \(prompt.key)" let initialIndex = PromptOption.index(of: priorValue, in: options, fallback: prompt.defaultValue) - let selected = output.singleSelect(title: label, items: items, initialIndex: initialIndex) + let selected = try output.singleSelect(title: label, items: items, initialIndex: initialIndex) return options[selected].value } diff --git a/Sources/mcs/Sync/Configurator.swift b/Sources/mcs/Sync/Configurator.swift index 273b2b84..7647f9cb 100644 --- a/Sources/mcs/Sync/Configurator.swift +++ b/Sources/mcs/Sync/Configurator.swift @@ -123,7 +123,7 @@ struct Configurator { output.plain("") output.plain(" Selected packs stay or get added. Deselected packs are removed.") - let selectedNumbers = output.multiSelect(groups: &groups) + let selectedNumbers = try output.multiSelect(groups: &groups) let selectedPacks = selectablePacks.enumerated().compactMap { index, pack in selectedNumbers.contains(index + 1) ? pack : nil @@ -149,7 +149,7 @@ struct Configurator { // 'n' in the picker binds to "select none"; default: false lets a user who hit it // thinking "cancel" still abort safely from the confirmation. let prompt = summary.isFullWipe ? "Remove all configured packs?" : "Apply these changes?" - guard output.askYesNo(prompt, default: false) else { + guard try output.askYesNo(prompt, default: false) else { output.info("Sync cancelled.") return } @@ -269,7 +269,7 @@ struct Configurator { } } output.plain("") - guard output.askYesNo("Proceed with removal?", default: true) else { + guard try output.askYesNo("Proceed with removal?", default: true) else { output.info("Sync cancelled.") return } @@ -701,7 +701,7 @@ struct Configurator { ) let undeclaredKeys = plan.undeclaredKeys - let seedFromPriors = decideSeedStrategy( + let seedFromPriors = try decideSeedStrategy( reusableValues: plan.reusableValues, newDeclaredKeys: plan.newKeys, visibleValueKeys: CrossPackPromptResolver.visibleValueKeys(in: plan.declared), @@ -807,7 +807,7 @@ struct Configurator { newDeclaredKeys: Set, visibleValueKeys: Set, reusePriorValuesSilently: Bool - ) -> Bool { + ) throws -> Bool { let decision = Self.priorReuse( hasReusable: !reusableValues.isEmpty, hasNewKeys: !newDeclaredKeys.isEmpty, interactive: output.hasInteractiveStdin, silently: reusePriorValuesSilently @@ -841,7 +841,7 @@ struct Configurator { ) { output.dimmed(line) } - return output.askYesNo("Reuse these values?", default: true) + return try output.askYesNo("Reuse these values?", default: true) } /// Pre-load templates from disk (single read per pack). diff --git a/Sources/mcs/Sync/CrossPackPromptResolver.swift b/Sources/mcs/Sync/CrossPackPromptResolver.swift index 9a968ac4..9c6f980c 100644 --- a/Sources/mcs/Sync/CrossPackPromptResolver.swift +++ b/Sources/mcs/Sync/CrossPackPromptResolver.swift @@ -372,7 +372,7 @@ enum CrossPackPromptResolver { let items = mergedOptions.map { (name: $0.label, description: $0.value) } let label = "Select value for \(key)" let initialIndex = PromptOption.index(of: prior, in: mergedOptions, fallback: declaredDefault) - let selected = output.singleSelect(title: label, items: items, initialIndex: initialIndex) + let selected = try output.singleSelect(title: label, items: items, initialIndex: initialIndex) resolved[key] = mergedOptions[selected].value } else { // Default to text input; prior value seeds the Enter-to-accept default. diff --git a/Sources/mcs/Sync/GlobalSyncStrategy.swift b/Sources/mcs/Sync/GlobalSyncStrategy.swift index a6c81d80..557fc427 100644 --- a/Sources/mcs/Sync/GlobalSyncStrategy.swift +++ b/Sources/mcs/Sync/GlobalSyncStrategy.swift @@ -122,16 +122,20 @@ struct GlobalSyncStrategy: SyncStrategy { } case let .shellCommand(command, interactive): - if interactive { - output.plain(" Running \(component.displayName) (may prompt for your password)...") - } else { + let usesTerminal = interactive && output.hasInteractiveStdin + if !interactive { output.dimmed(" Running \(component.displayName)...") } - let result = shell.shell(command, interactive: interactive) + announceShellCommand( + component.displayName, interactive: interactive, usesTerminal: usesTerminal, output: output + ) + let result = shell.shell(command, interactive: usesTerminal) if result.succeeded { output.success(" \(component.displayName) installed") - } else if interactive { - output.warn(" " + ShellRunner.interactiveFailureMessage(name: component.displayName, stderr: result.stderr)) + } else if usesTerminal { + output.warn(" " + ShellRunner.failureMessage( + name: component.displayName, stderr: result.stderr, ranInTerminal: true + )) } else { output.warn(" \(component.displayName) requires manual installation:") output.plain(" \(command)") diff --git a/Sources/mcs/Sync/PackUpdater.swift b/Sources/mcs/Sync/PackUpdater.swift index 83f99f9d..996c34b6 100644 --- a/Sources/mcs/Sync/PackUpdater.swift +++ b/Sources/mcs/Sync/PackUpdater.swift @@ -187,13 +187,17 @@ struct PackUpdater { if !newItems.isEmpty { output.warn("\(entry.displayName) has new or modified scripts:") - guard trustManager.promptForTrust( - manifest: manifest, - packPath: packPath, - items: newItems - ) else { - return .trustDeclined + let trusted: Bool + do { + trusted = try trustManager.promptForTrust( + manifest: manifest, + packPath: packPath, + items: newItems + ) + } catch { + return .internalError(underlying: error) } + guard trusted else { return .trustDeclined } } // Rebuild the map from the analyzed items instead of merging into the old one: merging left diff --git a/Sources/mcs/Sync/ProjectSyncStrategy.swift b/Sources/mcs/Sync/ProjectSyncStrategy.swift index 7e3cebf9..ba920c6f 100644 --- a/Sources/mcs/Sync/ProjectSyncStrategy.swift +++ b/Sources/mcs/Sync/ProjectSyncStrategy.swift @@ -118,16 +118,15 @@ struct ProjectSyncStrategy: SyncStrategy { break case let .shellCommand(command, interactive): - if interactive { - output.plain(" Running \(component.displayName) (may prompt for your password)...") - } - let result = shell.shell(command, interactive: interactive) + let usesTerminal = interactive && output.hasInteractiveStdin + announceShellCommand( + component.displayName, interactive: interactive, usesTerminal: usesTerminal, output: output + ) + let result = shell.shell(command, interactive: usesTerminal) if !result.succeeded { - if interactive { - output.warn(" " + ShellRunner.interactiveFailureMessage(name: component.displayName, stderr: result.stderr)) - } else { - output.warn(" \(component.displayName) failed: \(String(result.stderr.prefix(200)))") - } + output.warn(" " + ShellRunner.failureMessage( + name: component.displayName, stderr: result.stderr, ranInTerminal: usesTerminal + )) } case .settingsMerge: diff --git a/Sources/mcs/Sync/SyncStrategy.swift b/Sources/mcs/Sync/SyncStrategy.swift index c5532d40..9779bd95 100644 --- a/Sources/mcs/Sync/SyncStrategy.swift +++ b/Sources/mcs/Sync/SyncStrategy.swift @@ -106,6 +106,16 @@ protocol SyncStrategy { // MARK: - Default Implementations extension SyncStrategy { + /// A `shellInteractive` command gets a PTY only when there is a terminal to bridge it to: off a + /// TTY nothing can answer its prompts, and a PTY child waiting on input would never see EOF. + func announceShellCommand(_ name: String, interactive: Bool, usesTerminal: Bool, output: CLIOutput) { + if usesTerminal { + output.plain(" Running \(name) (may prompt for your password)...") + } else if interactive { + output.warn(" \(name) wants a terminal, but stdin is not one; running it without, so any prompt it shows fails") + } + } + /// Default removal summary — prints all non-empty artifact fields. /// /// Uses `scope.claudeFilePath.lastPathComponent` for template section labels. diff --git a/Sources/mcs/TechPack/HookInterpreter.swift b/Sources/mcs/TechPack/HookInterpreter.swift index e6f87195..5cfcfaa6 100644 --- a/Sources/mcs/TechPack/HookInterpreter.swift +++ b/Sources/mcs/TechPack/HookInterpreter.swift @@ -33,11 +33,9 @@ enum HookInterpreter { /// author at publish time to declare `hookInterpreter` explicitly. private static let ambiguousExtensions: Set = ["ts", "mts", "cts", "tsx"] - /// Interpreters whose absence is not worth reporting — always present on macOS. `zsh` is not - /// installed by default on Debian, Ubuntu or Fedora, so a `.zsh` hook's missing interpreter goes - /// unreported there; a per-platform set would be a sixth home for platform knowledge for one - /// entry, so this is listed as a known limitation instead. - private static let assumedPresent: Set = ["bash", "sh", "zsh"] + /// Interpreters whose absence is not worth reporting — POSIX guarantees `sh`, and mcs itself + /// runs every shell component through `bash`. + private static let assumedPresent: Set = ["bash", "sh"] /// Longest interpreter string accepted, guarding against a pathological manifest value. /// diff --git a/Tests/MCSTests/CLIOutputTests.swift b/Tests/MCSTests/CLIOutputTests.swift index ef7c24df..267d352a 100644 --- a/Tests/MCSTests/CLIOutputTests.swift +++ b/Tests/MCSTests/CLIOutputTests.swift @@ -18,7 +18,7 @@ struct CLIOutputTests { #expect(write(writeEnd, &byte, 1) == 1) close(writeEnd) - #expect(CLIOutput(colorsEnabled: false).readByte(from: readEnd) == 0x6A) + #expect(CLIOutput(colorsEnabled: false, input: readEnd).readByte() == 0x6A) } @Test("readByte returns nil once the writer has closed, so a picker can exit instead of spinning") @@ -27,7 +27,7 @@ struct CLIOutputTests { defer { close(readEnd) } close(writeEnd) - #expect(CLIOutput(colorsEnabled: false).readByte(from: readEnd) == nil) + #expect(CLIOutput(colorsEnabled: false, input: readEnd).readByte() == nil) } @Test("readByte returns nil for a descriptor that cannot be read") @@ -37,6 +37,37 @@ struct CLIOutputTests { defer { close(writeEnd) } // Reading the write end fails with EBADF; the caller must see that as "no key coming". - #expect(CLIOutput(colorsEnabled: false).readByte(from: writeEnd) == nil) + #expect(CLIOutput(colorsEnabled: false, input: writeEnd).readByte() == nil) + } + + /// A picker reading from a pipe whose writer has closed: the hangup a raw-mode prompt sees. + private func outputWithClosedInput() -> (CLIOutput, Int32) { + let (readEnd, writeEnd) = makePipe() + close(writeEnd) + return (CLIOutput(colorsEnabled: false, input: readEnd), readEnd) + } + + @Test("A yes/no prompt that loses its input throws instead of answering, even with a Yes default") + func yesNoThrowsWhenInputCloses() { + let (output, fd) = outputWithClosedInput() + defer { close(fd) } + #expect(throws: InputClosedError.self) { try output.interactiveYesNo("Proceed?", default: true) } + } + + @Test("A single-select that loses its input throws instead of returning the cursor") + func singleSelectThrowsWhenInputCloses() { + let (output, fd) = outputWithClosedInput() + defer { close(fd) } + #expect(throws: InputClosedError.self) { + try output.interactiveSingleSelect(title: "Pick", items: [("a", ""), ("b", "")], initialIndex: 1) + } + } + + @Test("A multi-select that loses its input throws instead of applying unconfirmed toggles") + func multiSelectThrowsWhenInputCloses() { + let (output, fd) = outputWithClosedInput() + defer { close(fd) } + var groups = [SelectableGroup(title: "Packs", items: [SelectableItem(number: 1, name: "a", description: "", isSelected: true)])] + #expect(throws: InputClosedError.self) { try output.interactiveMultiSelect(groups: &groups) } } } diff --git a/Tests/MCSTests/ClaudePrerequisiteTests.swift b/Tests/MCSTests/ClaudePrerequisiteTests.swift index 8d227d0e..7a2c04d5 100644 --- a/Tests/MCSTests/ClaudePrerequisiteTests.swift +++ b/Tests/MCSTests/ClaudePrerequisiteTests.swift @@ -8,22 +8,22 @@ struct ClaudePrerequisiteTests { } @Test("A claude already on PATH is accepted without spawning anything") - func acceptsInstalledCLI() { + func acceptsInstalledCLI() throws { let shell = MockShellRunner() shell.commandExistsResult = true - #expect(ensureClaudeCLI(shell: shell, environment: shell.environment, output: silentOutput())) + #expect(try ensureClaudeCLI(shell: shell, environment: shell.environment, output: silentOutput())) #expect(shell.commandExistsCalls.contains(Constants.CLI.claudeCommand)) #expect(shell.runCalls.isEmpty) } @Test("A missing claude without brew is reported and nothing is installed") - func missingCLIWithoutHomebrew() { + func missingCLIWithoutHomebrew() throws { let shell = MockShellRunner() shell.commandExistsResult = false // The same on both platforms: on Linux `claude-code` is a cask Linuxbrew does not have. - #expect(!ensureClaudeCLI( + #expect(try !ensureClaudeCLI( shell: shell, environment: shell.environment, output: silentOutput(), brewInstalled: false, confirmInstall: { Issue.record("no prompt without brew"); return true } )) @@ -32,11 +32,11 @@ struct ClaudePrerequisiteTests { #if canImport(Darwin) @Test("Declining the Homebrew offer installs nothing") - func declinedHomebrewInstall() { + func declinedHomebrewInstall() throws { let shell = MockShellRunner() shell.commandExistsResult = false - #expect(!ensureClaudeCLI( + #expect(try !ensureClaudeCLI( shell: shell, environment: shell.environment, output: silentOutput(), brewInstalled: true, confirmInstall: { false } )) @@ -44,13 +44,13 @@ struct ClaudePrerequisiteTests { } @Test("Accepting the Homebrew offer runs brew install, then re-probes for claude") - func acceptedHomebrewInstall() { + func acceptedHomebrewInstall() throws { let shell = MockShellRunner() shell.commandExistsResult = false // The install "succeeds" but claude still is not on PATH, so the outcome is a failure — // the assertion that matters is which command was run. - #expect(!ensureClaudeCLI( + #expect(try !ensureClaudeCLI( shell: shell, environment: shell.environment, output: silentOutput(), brewInstalled: true, confirmInstall: { true } )) @@ -59,11 +59,11 @@ struct ClaudePrerequisiteTests { } #else @Test("Linux never offers the Homebrew cask, even with brew present and a willing user") - func linuxNeverOffersHomebrew() { + func linuxNeverOffersHomebrew() throws { let shell = MockShellRunner() shell.commandExistsResult = false - #expect(!ensureClaudeCLI( + #expect(try !ensureClaudeCLI( shell: shell, environment: shell.environment, output: silentOutput(), brewInstalled: true, confirmInstall: { Issue.record("no prompt on Linux"); return true } )) diff --git a/Tests/MCSTests/CoreDoctorCheckTests.swift b/Tests/MCSTests/CoreDoctorCheckTests.swift index 6ed1dc88..11d7ff18 100644 --- a/Tests/MCSTests/CoreDoctorCheckTests.swift +++ b/Tests/MCSTests/CoreDoctorCheckTests.swift @@ -46,11 +46,12 @@ struct HookInterpreterCheckTests { #expect(!message.contains("mcs sync")) } - @Test("bash, sh and zsh are assumed present and never checked") + @Test("bash and sh are assumed present; zsh, absent from most Linux installs, is checked") func shellsAreAssumedPresent() { - for shell in ["bash", "sh", "zsh"] { + for shell in ["bash", "sh"] { #expect(!HookInterpreter.isCheckable(binary: shell)) } + #expect(HookInterpreter.isCheckable(binary: "zsh")) #expect(HookInterpreter.isCheckable(binary: "node")) } } diff --git a/Tests/MCSTests/EnvironmentTests.swift b/Tests/MCSTests/EnvironmentTests.swift index 3877324e..632bff50 100644 --- a/Tests/MCSTests/EnvironmentTests.swift +++ b/Tests/MCSTests/EnvironmentTests.swift @@ -118,6 +118,7 @@ struct EnvironmentTests { func defaultHomeDirectoryFallsBackToPasswd() { #expect(Environment.defaultHomeDirectory(environment: [:]) == NSHomeDirectory()) #expect(Environment.defaultHomeDirectory(environment: ["HOME": ""]) == NSHomeDirectory()) + #expect(Environment.defaultHomeDirectory(environment: ["HOME": "relative/home"]) == NSHomeDirectory()) } @Test("Environment derives every path from the resolved home") @@ -140,6 +141,7 @@ struct EnvironmentTests { let env = Environment(home: URL(fileURLWithPath: "/sandbox/home")) #expect(env.expandingTilde("~") == "/sandbox/home") + #expect(env.expandingTilde("~/") == "/sandbox/home") #expect(env.expandingTilde("~/packs/ios") == "/sandbox/home/packs/ios") #expect(env.expandingTilde("/abs/path") == "/abs/path") #expect(env.expandingTilde("relative/~/x") == "relative/~/x") @@ -199,11 +201,11 @@ struct EnvironmentTests { @Test("The fallback prefix is the platform's own default") func defaultBrewPrefixPerPlatform() { #if canImport(Darwin) && arch(arm64) - #expect(Environment.defaultBrewPrefix == "/opt/homebrew") + #expect(Homebrew.defaultPrefix == "/opt/homebrew") #elseif canImport(Darwin) - #expect(Environment.defaultBrewPrefix == "/usr/local") + #expect(Homebrew.defaultPrefix == "/usr/local") #else - #expect(Environment.defaultBrewPrefix == "/home/linuxbrew/.linuxbrew") + #expect(Homebrew.defaultPrefix == "/home/linuxbrew/.linuxbrew") #endif } diff --git a/Tests/MCSTests/ExternalDoctorCheckTests.swift b/Tests/MCSTests/ExternalDoctorCheckTests.swift index e0dafebb..0ed2fdc1 100644 --- a/Tests/MCSTests/ExternalDoctorCheckTests.swift +++ b/Tests/MCSTests/ExternalDoctorCheckTests.swift @@ -28,7 +28,7 @@ struct ExternalDoctorCheckTests { command: "/bin/ls", args: [], fixCommand: nil, - scriptRunner: makeScriptRunner() + scriptRunner: makeScriptRunner(), environment: Environment() ) let result = check.check() if case .pass = result { @@ -46,7 +46,7 @@ struct ExternalDoctorCheckTests { command: "/bin/ls", args: ["--nonexistent-flag-xyz"], fixCommand: nil, - scriptRunner: makeScriptRunner() + scriptRunner: makeScriptRunner(), environment: Environment() ) let result = check.check() if case .fail = result { @@ -64,7 +64,7 @@ struct ExternalDoctorCheckTests { command: "nonexistent-tool-xyz-12345", args: [], fixCommand: nil, - scriptRunner: makeScriptRunner() + scriptRunner: makeScriptRunner(), environment: Environment() ) let result = check.check() if case .fail = result { @@ -82,7 +82,7 @@ struct ExternalDoctorCheckTests { command: "nonexistent", args: [], fixCommand: nil, - scriptRunner: makeScriptRunner() + scriptRunner: makeScriptRunner(), environment: Environment() ) let result = check.fix() guard case let .notFixable(message) = result else { @@ -138,6 +138,33 @@ struct ExternalDoctorCheckTests { } } + @Test("The factory hands its environment to every global path check", arguments: [ + ExternalDoctorCheckType.fileExists, .directoryExists, .fileContains, .fileNotContains, + ]) + func factoryThreadsEnvironmentIntoPathChecks(type: ExternalDoctorCheckType) throws { + let home = try makeTmpDir() + defer { try? FileManager.default.removeItem(at: home) } + try FileManager.default.createDirectory(at: home.appendingPathComponent(".marker-dir"), withIntermediateDirectories: true) + try "present".write(to: home.appendingPathComponent(".marker"), atomically: true, encoding: .utf8) + + let isDirectory = type == .directoryExists + let definition = ExternalDoctorCheckDefinition( + type: type, + name: "marker", + path: isDirectory ? "~/.marker-dir" : "~/.marker", + pattern: type == .fileNotContains ? "absent" : (type == .fileContains ? "present" : nil), + scope: .global + ) + let check = ExternalDoctorCheckFactory.makeCheck( + from: definition, packPath: home, projectRoot: nil, + scriptRunner: makeScriptRunner(), environment: Environment(home: home) + ) + guard case .pass = check.check() else { + Issue.record("\(type) resolved ~ somewhere other than the injected home: \(check.check())") + return + } + } + @Test("File exists check fails for missing file") func fileExistsFail() { let check = ExternalFileExistsCheck( @@ -486,7 +513,7 @@ struct ExternalDoctorCheckTests { from: definition, packPath: tmpDir, projectRoot: nil, - scriptRunner: makeScriptRunner() + scriptRunner: makeScriptRunner(), environment: Environment() ) #expect(check.name == "git check") @@ -519,7 +546,7 @@ struct ExternalDoctorCheckTests { from: definition, packPath: tmpDir, projectRoot: nil, - scriptRunner: makeScriptRunner() + scriptRunner: makeScriptRunner(), environment: Environment() ) #expect(check.section == "External Pack") @@ -551,7 +578,7 @@ struct ExternalDoctorCheckTests { from: definition, packPath: tmpDir, projectRoot: nil, - scriptRunner: makeScriptRunner() + scriptRunner: makeScriptRunner(), environment: Environment() ) #expect(check is ExternalHookEventExistsCheck) @@ -577,7 +604,7 @@ struct ExternalDoctorCheckTests { from: definition, packPath: tmpDir, projectRoot: nil, - scriptRunner: makeScriptRunner() + scriptRunner: makeScriptRunner(), environment: Environment() ) // `command` means something different per check type; this asserts it lands on the hook @@ -613,7 +640,7 @@ struct ExternalDoctorCheckTests { from: definition, packPath: tmpDir, projectRoot: nil, - scriptRunner: makeScriptRunner() + scriptRunner: makeScriptRunner(), environment: Environment() ) #expect(check is ExternalSettingsKeyEqualsCheck) @@ -654,11 +681,11 @@ struct ExternalDoctorCheckTests { let hookCheck = ExternalDoctorCheckFactory.makeCheck( from: settingsCheckDefinition(type: .hookEventExists, scope: nil), - packPath: tmpDir, projectRoot: projectRoot, scriptRunner: makeScriptRunner() + packPath: tmpDir, projectRoot: projectRoot, scriptRunner: makeScriptRunner(), environment: Environment() ) let keyCheck = ExternalDoctorCheckFactory.makeCheck( from: settingsCheckDefinition(type: .settingsKeyEquals, scope: nil), - packPath: tmpDir, projectRoot: projectRoot, scriptRunner: makeScriptRunner() + packPath: tmpDir, projectRoot: projectRoot, scriptRunner: makeScriptRunner(), environment: Environment() ) #expect((hookCheck as? ExternalHookEventExistsCheck)?.projectRoot == projectRoot) @@ -692,7 +719,7 @@ struct ExternalDoctorCheckTests { ) let check = ExternalDoctorCheckFactory.makeCheck( from: definition, packPath: tmpDir, projectRoot: tmpDir, - scriptRunner: makeScriptRunner() + scriptRunner: makeScriptRunner(), environment: Environment() ) #expect( (check is any ScopedPathCheck) == type.honorsScope, @@ -712,11 +739,11 @@ struct ExternalDoctorCheckTests { for scope in [ExternalDoctorCheckScope.global, .project] { let hookCheck = ExternalDoctorCheckFactory.makeCheck( from: settingsCheckDefinition(type: .hookEventExists, scope: scope), - packPath: tmpDir, projectRoot: projectRoot, scriptRunner: makeScriptRunner() + packPath: tmpDir, projectRoot: projectRoot, scriptRunner: makeScriptRunner(), environment: Environment() ) let keyCheck = ExternalDoctorCheckFactory.makeCheck( from: settingsCheckDefinition(type: .settingsKeyEquals, scope: scope), - packPath: tmpDir, projectRoot: projectRoot, scriptRunner: makeScriptRunner() + packPath: tmpDir, projectRoot: projectRoot, scriptRunner: makeScriptRunner(), environment: Environment() ) #expect((hookCheck as? ExternalHookEventExistsCheck)?.projectRoot == projectRoot) @@ -750,7 +777,7 @@ struct ExternalDoctorCheckTests { from: definition, packPath: tmpDir, projectRoot: nil, - scriptRunner: makeScriptRunner() + scriptRunner: makeScriptRunner(), environment: Environment() ) #expect(check is MisconfiguredDoctorCheck) @@ -782,7 +809,7 @@ struct ExternalDoctorCheckTests { from: definition, packPath: tmpDir, projectRoot: nil, - scriptRunner: makeScriptRunner() + scriptRunner: makeScriptRunner(), environment: Environment() ) #expect(check is MisconfiguredDoctorCheck) diff --git a/Tests/MCSTests/HomebrewTests.swift b/Tests/MCSTests/HomebrewTests.swift index bbcddc00..eb588d68 100644 --- a/Tests/MCSTests/HomebrewTests.swift +++ b/Tests/MCSTests/HomebrewTests.swift @@ -19,17 +19,11 @@ struct HomebrewTests { @Test("allPrefixes lists this platform's Homebrew locations") func allPrefixesPerPlatform() { + let home = URL(fileURLWithPath: "/tmp/mcs-home") #if canImport(Darwin) - #expect(Homebrew.allPrefixes == ["/opt/homebrew", "/usr/local"]) + #expect(Homebrew.allPrefixes(home: home) == ["/opt/homebrew", "/usr/local"]) #else - // Asserted by shape, not by re-evaluating the source's own expression: `allPrefixes` is - // static and reads the process home, so a literal home cannot be injected here — that - // contract is pinned in EnvironmentTests instead. - let prefixes = Homebrew.allPrefixes - #expect(prefixes.count == 2) - #expect(prefixes[0] == "/home/linuxbrew/.linuxbrew") - #expect(prefixes[1].hasSuffix("/.linuxbrew")) - #expect(prefixes[1] != prefixes[0], "the single-user prefix is the user's home, not the shared one") + #expect(Homebrew.allPrefixes(home: home) == ["/home/linuxbrew/.linuxbrew", "/tmp/mcs-home/.linuxbrew"]) #endif } diff --git a/Tests/MCSTests/HookInterpreterTests.swift b/Tests/MCSTests/HookInterpreterTests.swift index 5cce78ce..407b1fa2 100644 --- a/Tests/MCSTests/HookInterpreterTests.swift +++ b/Tests/MCSTests/HookInterpreterTests.swift @@ -301,8 +301,9 @@ struct HookInterpreterCheckableBinaryTests { func skipsShells() { #expect(binaries([ "bash .claude/hooks/p/a.sh", - "zsh .claude/hooks/p/b.zsh", - ]).isEmpty) + "sh .claude/hooks/p/b.sh", + "zsh .claude/hooks/p/c.zsh", + ]) == ["zsh"]) } @Test("Reports the binary only, not its arguments") diff --git a/Tests/MCSTests/LifecycleIntegrationTests.swift b/Tests/MCSTests/LifecycleIntegrationTests.swift index a11b5e55..e4073430 100644 --- a/Tests/MCSTests/LifecycleIntegrationTests.swift +++ b/Tests/MCSTests/LifecycleIntegrationTests.swift @@ -1279,12 +1279,13 @@ struct ShellCommandLifecycleTests { #expect(FileManager.default.fileExists(atPath: markerPath)) } - @Test("shellCommand with interactive flag really runs under a PTY") - func shellCommandInteractiveAccepted() throws { + @Test("An interactive shellCommand off a terminal runs without a PTY, so its prompt ends instead of hanging") + func shellCommandInteractiveOffTerminalDoesNotHang() throws { let bed = try LifecycleTestBed() defer { bed.cleanup() } - let markerPath = bed.home.appendingPathComponent("interactive-marker.txt").path + // `read` sees EOF only without a PTY: under one, nothing would ever end the child's input. + let markerPath = bed.home.appendingPathComponent("eof-marker.txt").path let pack = MockTechPack( identifier: "interactive-pack", displayName: "Interactive Pack", @@ -1295,15 +1296,12 @@ struct ShellCommandLifecycleTests { description: "Install with interactive flag", type: .configuration, packIdentifier: "interactive-pack", - installAction: .shellCommand(command: "touch '\(markerPath)'", interactive: true) + installAction: .shellCommand(command: "read answer || touch '\(markerPath)'", interactive: true) ), ] ) let registry = TechPackRegistry(packs: [pack]) - // Interactive commands go through forkpty() in the real ShellRunner, so the marker file - // proves the whole path ran — fork, execve and the terminal/PTY bridge loop — not just - // that the component was recorded. let configurator = bed.makeGlobalSyncConfigurator(registry: registry) try configurator.configure(packs: [pack], confirmRemovals: false) diff --git a/Tests/MCSTests/ManifestBuilderTests.swift b/Tests/MCSTests/ManifestBuilderTests.swift index 64cc0f00..fa5fe044 100644 --- a/Tests/MCSTests/ManifestBuilderTests.swift +++ b/Tests/MCSTests/ManifestBuilderTests.swift @@ -100,7 +100,7 @@ struct ManifestBuilderTests { author: "Test Author" ) - let result = ManifestBuilder().build( + let result = ManifestBuilder(environment: Environment()).build( from: config, metadata: metadata, options: ManifestBuilder.BuildOptions( @@ -251,7 +251,7 @@ struct ManifestBuilderTests { ), ] - let result = ManifestBuilder().build( + let result = ManifestBuilder(environment: Environment()).build( from: config, metadata: ManifestBuilder.Metadata( identifier: "ts-pack", displayName: "TS Pack", description: "TypeScript hooks", author: nil @@ -287,7 +287,7 @@ struct ManifestBuilderTests { author: nil ) - let result = ManifestBuilder().build( + let result = ManifestBuilder(environment: Environment()).build( from: config, metadata: metadata, options: ManifestBuilder.BuildOptions( selectedMCPServers: [], selectedHookFiles: [], selectedSkillFiles: [], @@ -338,7 +338,7 @@ struct ManifestBuilderTests { author: nil ) - let result = ManifestBuilder().build( + let result = ManifestBuilder(environment: Environment()).build( from: config, metadata: metadata, options: ManifestBuilder.BuildOptions( selectedMCPServers: Set(config.mcpServers.map(\.name)), @@ -386,7 +386,7 @@ struct ManifestBuilderTests { let tmpDir = try makeTmpDir() defer { try? FileManager.default.removeItem(at: tmpDir) } - let result = ManifestBuilder().build( + let result = ManifestBuilder(environment: Environment()).build( from: config, metadata: metadata, options: ManifestBuilder.BuildOptions( selectedMCPServers: selectedMCPServers, diff --git a/Tests/MCSTests/PTYBridgeTests.swift b/Tests/MCSTests/PTYBridgeTests.swift index ca67dd27..aae0a4b3 100644 --- a/Tests/MCSTests/PTYBridgeTests.swift +++ b/Tests/MCSTests/PTYBridgeTests.swift @@ -19,14 +19,14 @@ struct PTYBridgeTests { } @Test( - "The child side reads through a hangup and closes only on a bad descriptor", + "The child side drains what is readable and closes only once nothing is left", arguments: [ (Int16(POLLIN), PTYBridge.PTYAction.read), (Int16(POLLIN | POLLHUP), .read), (Int16(POLLHUP), .read), (Int16(POLLERR), .close), (Int16(POLLNVAL), .close), - (Int16(POLLIN | POLLERR), .close), + (Int16(POLLIN | POLLERR), .read), (Int16(0), .idle), ] ) @@ -34,14 +34,22 @@ struct PTYBridgeTests { #expect(PTYBridge.ptyAction(revents: revents) == expected) } - @Test("A /dev/null stdin, which macOS reports as POLLNVAL, is dropped rather than polled forever") - func devNullStdinIsDropped() { + @Test("A /dev/null stdin is never left idle, so the bridge cannot poll it forever") + func devNullStdinIsNotPolledForever() { let fd = open("/dev/null", O_RDONLY) #expect(fd >= 0) defer { close(fd) } var fds = [pollfd(fd: fd, events: Int16(POLLIN), revents: 0)] #expect(poll(&fds, 1, 0) == 1) - #expect(PTYBridge.stdinAction(revents: fds[0].revents) != .idle) + #if canImport(Darwin) + // macOS reports POLLNVAL for /dev/null, with nothing to read. + #expect(PTYBridge.stdinAction(revents: fds[0].revents) == .drop) + #else + // Linux reports it readable; the bridge's read then returns 0 and drops the descriptor. + #expect(PTYBridge.stdinAction(revents: fds[0].revents) == .forward) + var byte: UInt8 = 0 + #expect(read(fd, &byte, 1) == 0) + #endif } } diff --git a/Tests/MCSTests/PackAdderTests.swift b/Tests/MCSTests/PackAdderTests.swift index f52c3595..749a2838 100644 --- a/Tests/MCSTests/PackAdderTests.swift +++ b/Tests/MCSTests/PackAdderTests.swift @@ -45,7 +45,7 @@ struct PackAdderPolicyTests { makeRegistryEntry(identifier: "foo", sourceURL: "https://example.com/original.git"), ]) - let proceed = adder.resolveDuplicate( + let proceed = try adder.resolveDuplicate( manifest: makeManifest(identifier: "foo"), sourceURL: "https://example.com/replacement.git", registryData: registry, @@ -65,7 +65,7 @@ struct PackAdderPolicyTests { makeRegistryEntry(identifier: "foo", sourceURL: "https://example.com/foo.git"), ]) - let proceed = adder.resolveDuplicate( + let proceed = try adder.resolveDuplicate( manifest: makeManifest(identifier: "foo"), sourceURL: "https://example.com/foo.git", registryData: registry, @@ -83,13 +83,13 @@ struct PackAdderPolicyTests { let adder = PackAdder(ctx: ctx) let registry = PackRegistryFile.RegistryData(packs: []) - #expect(adder.resolveDuplicate( + #expect(try adder.resolveDuplicate( manifest: makeManifest(identifier: "foo"), sourceURL: "https://example.com/foo.git", registryData: registry, policy: .autoAccept )) - #expect(adder.resolveDuplicate( + #expect(try adder.resolveDuplicate( manifest: makeManifest(identifier: "foo"), sourceURL: "https://example.com/foo.git", registryData: registry, @@ -104,7 +104,7 @@ struct PackAdderPolicyTests { let ctx = makeContext(home: home) let adder = PackAdder(ctx: ctx) - #expect(adder.acceptCollisions(policy: .autoAccept)) + #expect(try adder.acceptCollisions(policy: .autoAccept)) } } diff --git a/Tests/MCSTests/PackSourceResolverTests.swift b/Tests/MCSTests/PackSourceResolverTests.swift index 897d8a9b..ce06d69f 100644 --- a/Tests/MCSTests/PackSourceResolverTests.swift +++ b/Tests/MCSTests/PackSourceResolverTests.swift @@ -14,31 +14,31 @@ struct PackSourceResolverTests { @Test("HTTPS URL returns gitURL") func httpsURL() throws { - let result = try PackSourceResolver().resolve("https://github.com/user/repo.git") + let result = try PackSourceResolver(environment: Environment()).resolve("https://github.com/user/repo.git") #expect(result == .gitURL("https://github.com/user/repo.git")) } @Test("git@ URL returns gitURL") func gitAtURL() throws { - let result = try PackSourceResolver().resolve("git@github.com:user/repo.git") + let result = try PackSourceResolver(environment: Environment()).resolve("git@github.com:user/repo.git") #expect(result == .gitURL("git@github.com:user/repo.git")) } @Test("ssh:// URL returns gitURL") func sshURL() throws { - let result = try PackSourceResolver().resolve("ssh://git@github.com/user/repo") + let result = try PackSourceResolver(environment: Environment()).resolve("ssh://git@github.com/user/repo") #expect(result == .gitURL("ssh://git@github.com/user/repo")) } @Test("git:// URL returns gitURL") func gitProtocolURL() throws { - let result = try PackSourceResolver().resolve("git://github.com/user/repo.git") + let result = try PackSourceResolver(environment: Environment()).resolve("git://github.com/user/repo.git") #expect(result == .gitURL("git://github.com/user/repo.git")) } @Test("http:// URL returns gitURL") func httpURL() throws { - let result = try PackSourceResolver().resolve("http://example.com/user/repo.git") + let result = try PackSourceResolver(environment: Environment()).resolve("http://example.com/user/repo.git") #expect(result == .gitURL("http://example.com/user/repo.git")) } @@ -46,19 +46,19 @@ struct PackSourceResolverTests { @Test("user/repo expands to GitHub URL") func githubShorthand() throws { - let result = try PackSourceResolver().resolve("user/repo") + let result = try PackSourceResolver(environment: Environment()).resolve("user/repo") #expect(result == .gitURL("https://github.com/user/repo.git")) } @Test("user/repo.git deduplicates .git suffix") func githubShorthandDotGit() throws { - let result = try PackSourceResolver().resolve("user/repo.git") + let result = try PackSourceResolver(environment: Environment()).resolve("user/repo.git") #expect(result == .gitURL("https://github.com/user/repo.git")) } @Test("Shorthand with dots and hyphens expands correctly") func githubShorthandSpecialChars() throws { - let result = try PackSourceResolver().resolve("my-org/my.pack") + let result = try PackSourceResolver(environment: Environment()).resolve("my-org/my.pack") #expect(result == .gitURL("https://github.com/my-org/my.pack.git")) } @@ -66,14 +66,14 @@ struct PackSourceResolverTests { func threeComponents() throws { // three/levels/deep doesn't match shorthand regex, treated as path #expect(throws: PackSourceError.self) { - try PackSourceResolver().resolve("three/levels/deep") + try PackSourceResolver(environment: Environment()).resolve("three/levels/deep") } } @Test("Single component is not shorthand") func singleComponent() throws { #expect(throws: PackSourceError.self) { - try PackSourceResolver().resolve("justarepo") + try PackSourceResolver(environment: Environment()).resolve("justarepo") } } @@ -83,14 +83,14 @@ struct PackSourceResolverTests { func dotDotSlash() throws { // Should NOT match the shorthand regex (starts with .) #expect(throws: PackSourceError.self) { - try PackSourceResolver().resolve("../nonexistent") + try PackSourceResolver(environment: Environment()).resolve("../nonexistent") } } @Test("./foo is not treated as shorthand") func dotSlash() throws { #expect(throws: PackSourceError.self) { - try PackSourceResolver().resolve("./nonexistent") + try PackSourceResolver(environment: Environment()).resolve("./nonexistent") } } @@ -101,7 +101,7 @@ struct PackSourceResolverTests { let tmpDir = try makeTmpDir() defer { try? FileManager.default.removeItem(at: tmpDir) } - let result = try PackSourceResolver().resolve(tmpDir.path) + let result = try PackSourceResolver(environment: Environment()).resolve(tmpDir.path) guard case let .localPath(url) = result else { Issue.record("Expected .localPath, got \(result)") return @@ -130,7 +130,7 @@ struct PackSourceResolverTests { let tmpDir = try makeTmpDir() defer { try? FileManager.default.removeItem(at: tmpDir) } - let result = try PackSourceResolver().resolve("file://\(tmpDir.path)") + let result = try PackSourceResolver(environment: Environment()).resolve("file://\(tmpDir.path)") guard case let .localPath(url) = result else { Issue.record("Expected .localPath, got \(result)") return @@ -143,7 +143,7 @@ struct PackSourceResolverTests { let tmpDir = try makeTmpDir() defer { try? FileManager.default.removeItem(at: tmpDir) } - let result = try PackSourceResolver().resolve("file://localhost\(tmpDir.path)") + let result = try PackSourceResolver(environment: Environment()).resolve("file://localhost\(tmpDir.path)") guard case let .localPath(url) = result else { Issue.record("Expected .localPath, got \(result)") return @@ -160,14 +160,14 @@ struct PackSourceResolverTests { try "content".write(to: file, atomically: true, encoding: .utf8) #expect(throws: PackSourceError.self) { - try PackSourceResolver().resolve(file.path) + try PackSourceResolver(environment: Environment()).resolve(file.path) } } @Test("Nonexistent absolute path throws pathNotFound") func nonexistentAbsolutePath() throws { #expect(throws: PackSourceError.self) { - try PackSourceResolver().resolve("/nonexistent/path/to/pack") + try PackSourceResolver(environment: Environment()).resolve("/nonexistent/path/to/pack") } } @@ -188,7 +188,7 @@ struct PackSourceResolverTests { #expect(FileManager.default.changeCurrentDirectoryPath(tmpDir.path)) defer { #expect(FileManager.default.changeCurrentDirectoryPath(originalDir)) } - let result = try PackSourceResolver().resolve("org/pack") + let result = try PackSourceResolver(environment: Environment()).resolve("org/pack") guard case let .localPath(url) = result else { Issue.record("Expected .localPath, got \(result)") return @@ -201,14 +201,14 @@ struct PackSourceResolverTests { @Test("Input starting with dash is rejected") func dashInjection() throws { #expect(throws: PackSourceError.self) { - try PackSourceResolver().resolve("-malicious") + try PackSourceResolver(environment: Environment()).resolve("-malicious") } } @Test("Input starting with --flag is rejected") func flagInjection() throws { #expect(throws: PackSourceError.self) { - try PackSourceResolver().resolve("--upload-pack=evil") + try PackSourceResolver(environment: Environment()).resolve("--upload-pack=evil") } } } diff --git a/Tests/MCSTests/PackTrustManagerTests.swift b/Tests/MCSTests/PackTrustManagerTests.swift index 2d508892..453599c7 100644 --- a/Tests/MCSTests/PackTrustManagerTests.swift +++ b/Tests/MCSTests/PackTrustManagerTests.swift @@ -765,7 +765,7 @@ struct PackTrustManagerTests { // A hook file plus its interpreter: the prompt is genuinely reached, not skipped // by the `items.isEmpty` shortcut. #expect(items.count >= 2) - #expect(manager.promptForTrust(manifest: manifest, packPath: tmpDir, items: items)) + #expect(try manager.promptForTrust(manifest: manifest, packPath: tmpDir, items: items)) } @Test("policy defaults to .prompt so auto-accept is never implicit") @@ -787,6 +787,6 @@ struct PackTrustManagerTests { """, in: tmpDir) let manager = PackTrustManager(output: CLIOutput(colorsEnabled: false), policy: .autoAccept) - #expect(manager.promptForTrust(manifest: manifest, packPath: tmpDir, items: [])) + #expect(try manager.promptForTrust(manifest: manifest, packPath: tmpDir, items: [])) } } diff --git a/Tests/MCSTests/ShellRunnerTests.swift b/Tests/MCSTests/ShellRunnerTests.swift index afa7eddf..af451912 100644 --- a/Tests/MCSTests/ShellRunnerTests.swift +++ b/Tests/MCSTests/ShellRunnerTests.swift @@ -7,15 +7,20 @@ struct ShellRunnerTests { ShellRunner(environment: Environment()) } - @Test("An interactive failure points at the terminal unless the bridge itself reported why") - func interactiveFailureMessageShapes() { + @Test("A failure points at the terminal only when the command ran in one and left no stderr") + func failureMessageShapes() { #expect( - ShellRunner.interactiveFailureMessage(name: "Ollama", stderr: "") + ShellRunner.failureMessage(name: "Ollama", stderr: "", ranInTerminal: true) == "Ollama failed (see output above)" ) + #expect(ShellRunner.failureMessage(name: "Ollama", stderr: "", ranInTerminal: false) == "Ollama failed") #expect( - ShellRunner.interactiveFailureMessage(name: "Ollama", stderr: "forkpty failed: No such file or directory") - == "Ollama failed: forkpty failed: No such file or directory" + ShellRunner.failureMessage(name: "Ollama", stderr: "forkpty failed: No such file", ranInTerminal: true) + == "Ollama failed: forkpty failed: No such file" + ) + #expect( + ShellRunner.failureMessage(name: "x", stderr: String(repeating: "e", count: 500), ranInTerminal: false) + == "x failed: " + String(repeating: "e", count: 200) ) } @@ -80,4 +85,10 @@ struct ShellRunnerTests { ) #expect(result.stdout == "test_value") } + + @Test("An interactive command runs under a PTY and reports its exit status") + func interactiveCommandRunsUnderAPTY() { + let result = ShellRunner(environment: Environment()).shell("test -t 0 && exit 3", interactive: true) + #expect(result.exitCode == 3, "stdin is a terminal only inside the PTY") + } } diff --git a/Tests/MCSTests/UpdateCheckerTests.swift b/Tests/MCSTests/UpdateCheckerTests.swift index bcb083f9..bb480f53 100644 --- a/Tests/MCSTests/UpdateCheckerTests.swift +++ b/Tests/MCSTests/UpdateCheckerTests.swift @@ -1213,13 +1213,14 @@ struct UpdateCheckerContextStringTests { identifier: "ios", displayName: "iOS", localSHA: "aaa", remoteSHA: "bbb" ) - @Test("CLI-only update offers brew commands without --trust-all, doctor or cleanup") + private let brew = UpdateChecker.CLIUpgrade.commands(["brew update", "brew upgrade mcs-cli/tap/mcs"]) + + @Test("CLI-only update offers the upgrade commands without --trust-all, doctor or cleanup") func cliOnly() { let context = UpdateChecker.buildContextString( - UpdateChecker.CheckResult(packUpdates: [], cliUpdate: cliUpdate) + UpdateChecker.CheckResult(packUpdates: [], cliUpdate: cliUpdate), upgrade: brew ) - let upgrade = UpdateChecker.cliUpgradeCommands(toVersion: "2.0.0") - #expect(context.contains("On yes, run: \(upgrade.joined(separator: " && "))\n")) + #expect(context.contains("On yes, run: brew update && brew upgrade mcs-cli/tap/mcs\n")) #expect(context.contains("AskUserQuestion")) #expect(context.contains("mcs config set update-check false")) #expect(!context.contains("--trust-all")) @@ -1231,57 +1232,150 @@ struct UpdateCheckerContextStringTests { @Test("Pack-only update warns about --trust-all and gates cleanup on doctor") func packsOnly() throws { let context = UpdateChecker.buildContextString( - UpdateChecker.CheckResult(packUpdates: [packUpdate], cliUpdate: nil) + UpdateChecker.CheckResult(packUpdates: [packUpdate], cliUpdate: nil), upgrade: nil ) #expect(context.contains("On yes, run: \(UpdateChecker.hookPackUpdateCommand)\n")) #expect(context.contains("without review")) #expect(context.contains(UpdateChecker.manualPackUpdateCommand)) - for command in UpdateChecker.cliUpgradeCommands(toVersion: "2.0.0") { - #expect(!context.contains(command)) - } + #expect(!context.contains("brew upgrade")) let doctor = try #require(context.range(of: "'mcs doctor'")) let cleanup = try #require(context.range(of: "mcs cleanup -af")) #expect(doctor.lowerBound < cleanup.lowerBound) #expect(context.contains("mcs doctor --fix --yes")) } - @Test("The upgrade command matches how this platform ships mcs") - func upgradeCommandShapePerPlatform() throws { - let commands = UpdateChecker.cliUpgradeCommands(toVersion: "2.0.0") - #if canImport(Darwin) - #expect(commands == ["brew update", "brew upgrade \(Constants.MCSRepo.brewFormula)"]) - #else - let path = try #require(UpdateChecker.installedBinaryPath) - let command = try #require(commands.first) - #expect(commands.count == 1) - #expect(command.contains("mcs-2.0.0-linux-\(UpdateChecker.releaseArch).tar.gz")) - // Staged, verified, then renamed — nothing is ever written over the live binary. - #expect(command.contains("--version &&")) - #expect(command.hasSuffix(path)) - #endif + @Test("A sudo upgrade is handed to the user, never offered for Claude to run") + func sudoUpgradeIsTheUsersToRun() { + let context = UpdateChecker.buildContextString( + UpdateChecker.CheckResult(packUpdates: [], cliUpdate: cliUpdate), + upgrade: .runYourself("sudo sh -c 'swap'") + ) + #expect(context.contains("sudo sh -c 'swap'")) + #expect(!context.contains("On yes, run:")) + #expect(!context.contains("AskUserQuestion")) } - @Test("Nothing to run means no question is asked") - func noCommandsSkipTheAsk() { + @Test("With no upgrade command the reason and the releases page are given, and nothing is asked") + func manualUpgradeSkipsTheAsk() { let context = UpdateChecker.buildContextString( - UpdateChecker.CheckResult(packUpdates: [], cliUpdate: nil) + UpdateChecker.CheckResult(packUpdates: [], cliUpdate: cliUpdate), + upgrade: .manual(reason: "no /proc") ) + #expect(context.contains("no /proc")) + #expect(context.contains(Constants.MCSRepo.releasesURL)) #expect(!context.contains("On yes, run:")) #expect(!context.contains("AskUserQuestion")) } + @Test("macOS upgrades through Homebrew") + func upgradePathPerPlatform() { + let upgrade = UpdateChecker.cliUpgrade(toVersion: "2.0.0") + #if canImport(Darwin) + #expect(upgrade == .commands(["brew update", "brew upgrade \(Constants.MCSRepo.brewFormula)"])) + #else + // Either path is legitimate depending on where the test binary lives; neither is brew. + switch upgrade { + case let .commands(commands): + #expect(commands.count == 1) + #expect(commands[0].contains("mcs-2.0.0-linux-\(UpdateChecker.releaseArch).tar.gz")) + case let .runYourself(command): + #expect(command.hasPrefix("sudo sh -c ")) + case .manual: + Issue.record("/proc/self/exe is readable on Linux, so a command is expected") + } + #endif + } + @Test("CLI upgrade chains before the pack update and the request resumes last") func cliBeforePacks() { let context = UpdateChecker.buildContextString( - UpdateChecker.CheckResult(packUpdates: [packUpdate], cliUpdate: cliUpdate) + UpdateChecker.CheckResult(packUpdates: [packUpdate], cliUpdate: cliUpdate), upgrade: brew ) - let chain = (UpdateChecker.cliUpgradeCommands(toVersion: "2.0.0") + [UpdateChecker.hookPackUpdateCommand]) - .joined(separator: " && ") + let chain = "brew update && brew upgrade mcs-cli/tap/mcs && \(UpdateChecker.hookPackUpdateCommand)" #expect(context.contains("On yes, run: \(chain)\n")) #expect(context.hasSuffix("continue with the user's original request.")) } } +// MARK: - Linux Upgrade Command Tests + +struct LinuxUpgradeCommandTests { + private func command(path: String, version: String = "2.0.0", writable: Bool = true) -> UpdateChecker.CLIUpgrade { + UpdateChecker.linuxUpgrade(toVersion: version, binaryPath: path, directoryWritable: writable, arch: "x86_64") + } + + /// `sh -n` parses without running, so a quoting mistake fails here instead of in a user's shell. + private func shellParses(_ script: String) throws -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-n", "-c", script] + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 + } + + @Test("The binary is staged beside the target, verified, then renamed over it", arguments: [ + "/usr/local/bin/mcs", "/home/a b/.local/bin/mcs", "/home/o'brien/bin/mcs", + ]) + func stagedVerifiedRenamed(path: String) throws { + guard case let .commands(commands) = command(path: path) else { + Issue.record("a writable directory needs no sudo") + return + } + let script = try #require(commands.first) + let directory = (path as NSString).deletingLastPathComponent + #expect(script.contains(UpdateChecker.shellQuoted("\(directory)/.mcs-upgrade"))) + #expect(script.contains("--version && mv -f")) + #expect(script.contains("mv -f \(UpdateChecker.shellQuoted("\(directory)/.mcs-upgrade")) \(UpdateChecker.shellQuoted(path))")) + #expect(script.contains("mcs-2.0.0-linux-x86_64.tar.gz")) + #expect(script.contains("rm -f")) + #expect(try shellParses(script)) + } + + @Test("An unwritable directory yields a sudo command the user runs, which still parses", arguments: [ + "/usr/local/bin/mcs", "/opt/o'brien tools/mcs", + ]) + func sudoForm(path: String) throws { + guard case let .runYourself(sudo) = command(path: path, writable: false) else { + Issue.record("an unwritable directory needs sudo") + return + } + #expect(sudo.hasPrefix("sudo sh -c '")) + #expect(try shellParses(String(sudo.dropFirst("sudo ".count)))) + } + + @Test("A tag that is not a plain version never reaches a shell", arguments: [ + "2.0.0-rc1", "2.0.0-$(touch x)", "2.0.0;rm", "v2.0.0", "2.0", + ]) + func rejectsNonPlainVersions(version: String) { + guard case .manual = command(path: "/usr/local/bin/mcs", version: version) else { + Issue.record("'\(version)' must not produce a command") + return + } + } + + @Test("A replaced or unlinked running binary gets no command") + func rejectsDeletedTarget() { + guard case .manual = command(path: "/usr/local/bin/mcs (deleted)") else { + Issue.record("the kernel's deleted marker is not a path to write to") + return + } + } + + @Test("shellQuoted round-trips through the shell", arguments: ["plain", "a b", "o'brien", "$(x)`y`"]) + func shellQuotedRoundTrips(value: String) throws { + let process = Process() + let pipe = Pipe() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", "printf %s \(UpdateChecker.shellQuoted(value))"] + process.standardOutput = pipe + try process.run() + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + #expect(String(decoding: data, as: UTF8.self) == value) + } +} + // MARK: - Hook Config Gate Tests struct CheckUpdatesCommandHookGateTests { From 5edc7c756da17b436d5a9a68affddcac99b9c5c3 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Wed, 30 Sep 2026 01:15:46 +0200 Subject: [PATCH 23/28] Lint on macOS again and fold CI jobs into matrices - Lint uses pinned portable binaries on macOS, so SourceKit rules are enforced; release jobs hold write access only when publishing - Test and build run as per-platform matrices that keep the required check names; the manual Linux smoke workflow is gone - swiftly is pinned and checksummed, and smoke runs in a sandboxed HOME with a shellInteractive component under a time limit --- .github/actions/mcs-smoke/action.yml | 84 ++++----- .github/actions/setup-swift-linux/action.yml | 33 +++- .github/actions/spm-deps/action.yml | 38 ++++ .github/workflows/linux-smoke.yml | 43 ----- .github/workflows/pr-checks.yml | 180 ++++++------------- .github/workflows/release.yml | 170 ++++++------------ 6 files changed, 215 insertions(+), 333 deletions(-) create mode 100644 .github/actions/spm-deps/action.yml delete mode 100644 .github/workflows/linux-smoke.yml diff --git a/.github/actions/mcs-smoke/action.yml b/.github/actions/mcs-smoke/action.yml index 7900f3b8..c951eaf4 100644 --- a/.github/actions/mcs-smoke/action.yml +++ b/.github/actions/mcs-smoke/action.yml @@ -9,25 +9,29 @@ inputs: runs: using: composite steps: - # A stand-in for the Claude Code CLI: every command below calls ensureClaudeCLI, which refuses - # to run without it, and nothing here asserts on what Claude itself does. - - name: Stub the claude CLI + # Every later step runs with HOME inside the job's temp dir, so the flows can never touch the + # runner's real ~/.mcs or ~/.claude — and mcs honouring $HOME is itself under test. + - name: Sandbox HOME and stub the claude CLI shell: bash run: | - set -euo pipefail - mkdir -p "$HOME/.local/bin" - printf '#!/bin/sh\nexit 0\n' > "$HOME/.local/bin/claude" - chmod +x "$HOME/.local/bin/claude" - echo "$HOME/.local/bin" >> "$GITHUB_PATH" + SANDBOX="$RUNNER_TEMP/home" + mkdir -p "$SANDBOX/.local/bin" + # A stand-in for the Claude Code CLI: every command calls ensureClaudeCLI, which refuses to + # run without it, and nothing here asserts on what Claude itself does. + printf '#!/bin/sh\nexit 0\n' > "$SANDBOX/.local/bin/claude" + chmod +x "$SANDBOX/.local/bin/claude" + echo "$SANDBOX/.local/bin" >> "$GITHUB_PATH" + echo "HOME=$SANDBOX" >> "$GITHUB_ENV" + echo "MCS=${{ inputs.mcs }}" >> "$GITHUB_ENV" # A local pack exercises the same manifest, trust and artifact paths as a git pack without - # making the smoke run depend on a network fetch. + # making the smoke run depend on a network fetch. It lives under ~ so both `pack add` and + # mcs.yaml reach it through mcs's own tilde expansion. - name: Create a fixture pack shell: bash run: | - set -euo pipefail - mkdir -p "$RUNNER_TEMP/fixture-pack/skills/greet" "$RUNNER_TEMP/project" - cat > "$RUNNER_TEMP/fixture-pack/techpack.yaml" <<'PACK' + mkdir -p "$HOME/fixture-pack/skills/greet" "$RUNNER_TEMP/project" + cat > "$HOME/fixture-pack/techpack.yaml" <<'PACK' schemaVersion: 1 identifier: smoke displayName: Smoke Pack @@ -48,93 +52,85 @@ runs: displayName: git description: A formula whose name is also its command, so the PATH probe satisfies it brew: git + - id: asks-for-input + displayName: Asks for input + description: Off a terminal its prompt must see end of input rather than hang + type: configuration + shell: read answer || touch "$HOME/interactive-saw-eof" + shellInteractive: true PACK - cat > "$RUNNER_TEMP/fixture-pack/skills/greet/SKILL.md" <<'SKILL' + cat > "$HOME/fixture-pack/skills/greet/SKILL.md" <<'SKILL' --- name: greet description: Says __GREETING__ --- Says __GREETING__. SKILL - cat > "$RUNNER_TEMP/project/mcs.yaml" < "$RUNNER_TEMP/project/mcs.yaml" <<'BOOT' schemaVersion: 1 packs: - - source: $RUNNER_TEMP/fixture-pack + - source: ~/fixture-pack values: GREETING: hei BOOT - - name: mcs pack add (local), pack list --json, pack validate + - name: mcs pack add (tilde path), pack list --json, pack validate shell: bash working-directory: ${{ runner.temp }}/project - env: - MCS: ${{ inputs.mcs }} run: | - set -euo pipefail - "$MCS" pack add "$RUNNER_TEMP/fixture-pack" + # Quoted so the shell leaves the ~ for mcs to expand against $HOME. The shell component + # makes the pack ask for trust, and `pack add` has no --trust-all, so the answer is piped. + printf 'y\n' | "$MCS" pack add '~/fixture-pack' "$MCS" pack list --json "$MCS" pack validate smoke + test -f "$HOME/.mcs/registry.yaml" + # Bounded, so a regression to the PTY hang fails the job instead of running out its clock. + # perl rather than timeout(1), which macOS does not ship. - name: mcs bootstrap --trust-all shell: bash working-directory: ${{ runner.temp }}/project - env: - MCS: ${{ inputs.mcs }} run: | - set -euo pipefail git init -q . - "$MCS" bootstrap --trust-all + perl -e 'alarm shift; exec @ARGV' 300 "$MCS" bootstrap --trust-all # The seeded value has to reach the installed artifact, not just the prompt store. - test -f .claude/skills/greet/SKILL.md grep -q hei .claude/skills/greet/SKILL.md + test -f "$HOME/interactive-saw-eof" - name: mcs sync --pack --prune --yes shell: bash working-directory: ${{ runner.temp }}/project - env: - MCS: ${{ inputs.mcs }} run: | - set -euo pipefail "$MCS" sync --pack smoke --prune --yes + test -f .claude/skills/greet/SKILL.md - name: mcs doctor and doctor --fix shell: bash working-directory: ${{ runner.temp }}/project - env: - MCS: ${{ inputs.mcs }} run: | - set -euo pipefail "$MCS" doctor "$MCS" doctor --fix --yes - name: mcs update --trust-all and check-updates shell: bash working-directory: ${{ runner.temp }}/project - env: - MCS: ${{ inputs.mcs }} run: | - set -euo pipefail "$MCS" update --project --trust-all "$MCS" check-updates --json - # No TTY here, so every prompt has to resolve from the stored prior or a declared default - # rather than blocking on stdin. + # The one step that pins stdin to /dev/null explicitly: every prompt has to resolve from the + # stored prior or a declared default rather than blocking on stdin. - name: Non-interactive prompt resolution shell: bash working-directory: ${{ runner.temp }}/project - env: - MCS: ${{ inputs.mcs }} run: | - set -euo pipefail "$MCS" sync --all < /dev/null + grep -q hei .claude/skills/greet/SKILL.md - name: mcs export and cleanup shell: bash working-directory: ${{ runner.temp }}/project - env: - MCS: ${{ inputs.mcs }} run: | - set -euo pipefail "$MCS" export "$RUNNER_TEMP/exported" --identifier exported --non-interactive test -f "$RUNNER_TEMP/exported/techpack.yaml" "$MCS" cleanup --force @@ -142,8 +138,6 @@ runs: - name: mcs pack remove shell: bash working-directory: ${{ runner.temp }}/project - env: - MCS: ${{ inputs.mcs }} run: | - set -euo pipefail "$MCS" pack remove smoke --force + test ! -e .claude/skills/greet/SKILL.md diff --git a/.github/actions/setup-swift-linux/action.yml b/.github/actions/setup-swift-linux/action.yml index 10eacaf4..f13615ff 100644 --- a/.github/actions/setup-swift-linux/action.yml +++ b/.github/actions/setup-swift-linux/action.yml @@ -3,12 +3,22 @@ description: Install a pinned Swift toolchain on an Ubuntu runner via swiftly an inputs: swift-version: - description: The toolchain version swiftly installs and selects (for example 6.2.0). - required: true + description: The toolchain version swiftly installs and selects. + default: '6.2.0' runs: using: composite steps: + # swiftly installs a toolchain built for this Ubuntu release, so the cache key carries it: one + # restored onto another image may not run there. + - name: Identify the image + id: image + shell: bash + run: | + set -euo pipefail + . /etc/os-release + echo "release=${ID}-${VERSION_ID}" >> "$GITHUB_OUTPUT" + - name: Restore the Swift toolchain id: toolchain uses: actions/cache@v6 @@ -18,7 +28,7 @@ runs: path: | ~/.local/share/swiftly ~/.config/swiftly - key: swiftly-${{ runner.os }}-${{ runner.arch }}-${{ inputs.swift-version }} + key: swiftly-${{ steps.image.outputs.release }}-${{ runner.arch }}-${{ inputs.swift-version }} - name: Install the toolchain's system dependencies # Not cacheable — these are system packages, and the toolchain needs them on a cache hit too. @@ -44,11 +54,24 @@ runs: shell: bash env: SWIFT_VERSION: ${{ inputs.swift-version }} + # Pinned and checksummed: this binary runs before anything else in the job, so an + # unannounced upstream release must not be able to change or break it. + SWIFTLY_VERSION: '1.2.0' + SWIFTLY_SHA256_X86_64: 0b26b568811404374c7d7f07276b6aad4145db5d1893a45f307de6a94ca8d9c3 + SWIFTLY_SHA256_AARCH64: 590d855e03807791f1b84aab77471bf7e31f9e3b22151972526077da296ecf96 run: | set -euo pipefail cd "$RUNNER_TEMP" - curl -O "https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz" - tar zxf "swiftly-$(uname -m).tar.gz" + ARCH=$(uname -m) + case "$ARCH" in + x86_64) SHA="$SWIFTLY_SHA256_X86_64" ;; + aarch64) SHA="$SWIFTLY_SHA256_AARCH64" ;; + *) echo "::error::No pinned swiftly checksum for $ARCH"; exit 1 ;; + esac + TARBALL="swiftly-${SWIFTLY_VERSION}-${ARCH}.tar.gz" + curl -fsSL --retry 3 -o "$TARBALL" "https://download.swift.org/swiftly/linux/$TARBALL" + echo "$SHA $TARBALL" | sha256sum -c - + tar zxf "$TARBALL" ./swiftly init --quiet-shell-followup --assume-yes --skip-install . "${XDG_DATA_HOME:-$HOME/.local/share}/swiftly/env.sh" diff --git a/.github/actions/spm-deps/action.yml b/.github/actions/spm-deps/action.yml new file mode 100644 index 00000000..46a293d2 --- /dev/null +++ b/.github/actions/spm-deps/action.yml @@ -0,0 +1,38 @@ +name: Swift package dependencies +description: Restore (and optionally save) the cached package sources, then resolve them. + +inputs: + restore-only: + description: Restore the cache without saving it — for jobs that run after the one that owns it. + default: 'false' + +runs: + using: composite + steps: + # Dependency *sources* only, and the key is deliberately platform-independent: these are git + # clones with no absolute paths, so every macOS and Linux job shares one entry. Compiled output + # is not cached — it is not portable across runner or toolchain, and a cold build of the whole + # graph costs seconds next to running the suite. + - if: inputs.restore-only != 'true' + uses: actions/cache@v6 + with: + path: | + .build/checkouts + .build/repositories + .build/workspace-state.json + key: spm-${{ hashFiles('Package.swift') }} + restore-keys: spm- + + - if: inputs.restore-only == 'true' + uses: actions/cache/restore@v6 + with: + path: | + .build/checkouts + .build/repositories + .build/workspace-state.json + key: spm-${{ hashFiles('Package.swift') }} + restore-keys: spm- + + - name: Resolve packages + shell: bash + run: swift package resolve diff --git a/.github/workflows/linux-smoke.yml b/.github/workflows/linux-smoke.yml deleted file mode 100644 index 9472c94f..00000000 --- a/.github/workflows/linux-smoke.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Linux Smoke - -# The same flows PR checks run, on demand: useful for reproducing a Linux-only failure or -# exercising a runner PR checks does not cover. The steps live in .github/actions/mcs-smoke so -# there is one definition of what a smoke run is. -on: - workflow_dispatch: - inputs: - runner: - description: Runner to smoke-test on - required: true - default: ubuntu-22.04 - type: choice - options: - - ubuntu-22.04 - - ubuntu-22.04-arm - -permissions: - contents: read - -env: - # Keep in sync with pr-checks.yml. - SWIFT_VERSION: '6.2.0' - -jobs: - smoke: - name: Smoke (${{ inputs.runner }}) - runs-on: ${{ inputs.runner }} - steps: - - uses: actions/checkout@v7 - - - uses: ./.github/actions/setup-swift-linux - with: - swift-version: ${{ env.SWIFT_VERSION }} - - - name: Build - run: | - swift build -c release - echo "MCS=$(swift build -c release --show-bin-path)/mcs" >> "$GITHUB_ENV" - - - uses: ./.github/actions/mcs-smoke - with: - mcs: ${{ env.MCS }} diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 43fd15b6..126b321f 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -11,8 +11,6 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: - # Keep in sync with release.yml, which pins the same toolchain for the release build. - SWIFT_VERSION: '6.2.0' # Pinned so a lint verdict is a function of the repo: a new rule lands when this is bumped, # not when upstream tags a release mid-review. SWIFTLINT_VERSION: '0.65.1' @@ -21,25 +19,24 @@ env: jobs: lint: name: Lint - runs-on: ubuntu-22.04 + # macOS, not Linux: SwiftLint's static Linux binary cannot load SourceKit, so it silently skips + # every SourceKit-backed rule and CI would enforce less than a local run. + runs-on: macos-latest steps: - uses: actions/checkout@v7 - name: Install linters - # Both projects publish fully static Linux binaries, so linting needs no Swift toolchain. - # SwiftLint's archive carries a dynamically linked `swiftlint` next to `swiftlint-static`; - # only the static one runs without the Swift runtime installed. env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | mkdir -p "$RUNNER_TEMP/linters/bin" cd "$RUNNER_TEMP/linters" - gh release download "$SWIFTLINT_VERSION" --repo realm/SwiftLint --pattern swiftlint_linux_amd64.zip - gh release download "$SWIFTFORMAT_VERSION" --repo nicklockwood/SwiftFormat --pattern swiftformat_linux.zip - unzip -q swiftlint_linux_amd64.zip -d swiftlint-zip - unzip -q swiftformat_linux.zip -d swiftformat-zip - install -m 0755 swiftlint-zip/swiftlint-static bin/swiftlint - install -m 0755 swiftformat-zip/swiftformat_linux bin/swiftformat + gh release download "$SWIFTLINT_VERSION" --repo realm/SwiftLint --pattern portable_swiftlint.zip + gh release download "$SWIFTFORMAT_VERSION" --repo nicklockwood/SwiftFormat --pattern swiftformat.zip + unzip -q portable_swiftlint.zip -d swiftlint-zip + unzip -q swiftformat.zip -d swiftformat-zip + install -m 0755 swiftlint-zip/swiftlint bin/swiftlint + install -m 0755 swiftformat-zip/swiftformat bin/swiftformat echo "$RUNNER_TEMP/linters/bin" >> "$GITHUB_PATH" # Asserted, not just printed: a pin that silently resolved to something else would make every @@ -55,145 +52,80 @@ jobs: - name: SwiftLint run: swiftlint --strict --reporter github-actions-logging - build-and-test: - name: Build & Test (${{ matrix.os }}) - needs: lint - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [macos-15, macos-26] - steps: - - uses: actions/checkout@v7 - - # Dependency *sources* only, and the key is deliberately platform-independent: these are git - # clones with no absolute paths, so every macOS and Linux job shares one entry. Compiled - # output is not cached — it is not portable across runner or toolchain, and a cold build of - # the whole graph costs seconds next to running the suite. - - uses: actions/cache@v6 - with: - path: | - .build/checkouts - .build/repositories - .build/workspace-state.json - key: spm-${{ hashFiles('Package.swift') }} - restore-keys: spm- - - - name: Resolve packages - run: swift package resolve - - - name: Test - run: swift test - - # Split from the release build below rather than run before it: the two are independent, and - # Linux is the longest leg of these checks, so serialising them set the critical path. - test-linux: - name: Test (${{ matrix.runner }}) + # Each entry's name is a required status check, so it is spelled out rather than derived. + test: + name: ${{ matrix.name }} needs: lint - # 22.04 on both arches so PR checks build against the same glibc floor the release does. runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: - runner: [ubuntu-22.04, ubuntu-22.04-arm] + include: + - name: Build & Test (macos-15) + runner: macos-15 + - name: Build & Test (macos-26) + runner: macos-26 + # 22.04 on both arches so PR checks build against the same glibc floor the release does. + # --no-parallel: see "Known limitations" in docs/linux-support.md. + - name: Test (ubuntu-22.04) + runner: ubuntu-22.04 + test-flags: --no-parallel + - name: Test (ubuntu-22.04-arm) + runner: ubuntu-22.04-arm + test-flags: --no-parallel steps: - uses: actions/checkout@v7 # Not a `container:` job: the official swift images run as root, and # UpdateCheckerTests.registryWriteFailureContract asserts that a write into a 0555 # directory fails, which root ignores. - - uses: ./.github/actions/setup-swift-linux - with: - swift-version: ${{ env.SWIFT_VERSION }} + - if: runner.os == 'Linux' + uses: ./.github/actions/setup-swift-linux + + - uses: ./.github/actions/spm-deps - - uses: actions/cache@v6 - with: - path: | - .build/checkouts - .build/repositories - .build/workspace-state.json - key: spm-${{ hashFiles('Package.swift') }} - restore-keys: spm- - - - name: Resolve packages - run: swift package resolve - - # --no-parallel: corelibs Foundation opens files for writing without O_CLOEXEC, on both its - # atomic and non-atomic paths, so a test that forks while another is mid-write leaks that - # write descriptor into the child and a later exec of the same file fails with ETXTBSY. - # Measured at roughly one failure in four full runs. mcs itself is unaffected — it never - # executes a file it has just written — so this is a test-runner constraint, not a product - # limitation, and the macOS jobs stay parallel. - name: Test - run: swift test --no-parallel + env: + TEST_FLAGS: ${{ matrix.test-flags }} + run: | + read -ra FLAGS <<< "$TEST_FLAGS" + swift test "${FLAGS[@]}" - build-linux: - name: Build & Smoke (${{ matrix.runner }}) + # The shapes release.yml ships, each followed by whole command flows against a real pack — the + # unit suite cannot reach them, and here they cost seconds rather than a build. + build: + name: ${{ matrix.name }} needs: lint runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: - runner: [ubuntu-22.04, ubuntu-22.04-arm] + include: + - name: Universal Binary + runner: macos-latest + build-flags: --arch arm64 --arch x86_64 + - name: Build & Smoke (ubuntu-22.04) + runner: ubuntu-22.04 + build-flags: --static-swift-stdlib + - name: Build & Smoke (ubuntu-22.04-arm) + runner: ubuntu-22.04-arm + build-flags: --static-swift-stdlib steps: - uses: actions/checkout@v7 - # Not a `container:` job: the official swift images run as root, and - # UpdateCheckerTests.registryWriteFailureContract asserts that a write into a 0555 - # directory fails, which root ignores. - - uses: ./.github/actions/setup-swift-linux - with: - swift-version: ${{ env.SWIFT_VERSION }} + - if: runner.os == 'Linux' + uses: ./.github/actions/setup-swift-linux - - uses: actions/cache@v6 - with: - path: | - .build/checkouts - .build/repositories - .build/workspace-state.json - key: spm-${{ hashFiles('Package.swift') }} - restore-keys: spm- + - uses: ./.github/actions/spm-deps - - name: Resolve packages - run: swift package resolve - - # The shape release.yml ships. Without this, a break in it first shows up on a tag. - name: Build release binary + env: + BUILD_FLAGS: ${{ matrix.build-flags }} run: | - swift build -c release --static-swift-stdlib - echo "MCS=$(swift build -c release --static-swift-stdlib --show-bin-path)/mcs" >> "$GITHUB_ENV" - - # Whole command flows against a real pack, riding on the binary just built — the unit suite - # cannot reach them, and here they cost seconds rather than a build. - - uses: ./.github/actions/mcs-smoke - with: - mcs: ${{ env.MCS }} - - build-universal: - name: Universal Binary - needs: lint - runs-on: macos-latest - steps: - - uses: actions/checkout@v7 - - - uses: actions/cache@v6 - with: - path: | - .build/checkouts - .build/repositories - .build/workspace-state.json - key: spm-${{ hashFiles('Package.swift') }} - restore-keys: spm- - - - name: Resolve packages - run: swift package resolve - - - name: Build universal binary - run: | - swift build -c release --arch arm64 --arch x86_64 - echo "MCS=$(swift build -c release --arch arm64 --arch x86_64 --show-bin-path)/mcs" >> "$GITHUB_ENV" + read -ra FLAGS <<< "$BUILD_FLAGS" + swift build -c release "${FLAGS[@]}" + echo "MCS=$(swift build -c release "${FLAGS[@]}" --show-bin-path)/mcs" >> "$GITHUB_ENV" - # The same flows the Linux jobs run, against the universal binary users actually install. - uses: ./.github/actions/mcs-smoke with: mcs: ${{ env.MCS }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b90ca42a..7f75ffae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,12 +5,10 @@ on: tags: - '[0-9]*.[0-9]*.[0-9]*' +# Read-only by default; only the job that publishes gets write access, so a build step that +# downloads and runs a toolchain never holds a token that can push tags or releases. permissions: - contents: write - -env: - # Keep in sync with pr-checks.yml. - SWIFT_VERSION: '6.2.0' + contents: read concurrency: group: release @@ -68,175 +66,112 @@ jobs: steps: - uses: actions/checkout@v7 - - uses: actions/cache@v6 - with: - path: | - .build/checkouts - .build/repositories - .build/workspace-state.json - key: spm-${{ hashFiles('Package.swift') }} - restore-keys: spm- - - - name: Resolve packages - run: swift package resolve + - uses: ./.github/actions/spm-deps test: - name: Test - needs: resolve - runs-on: macos-latest - steps: - - uses: actions/checkout@v7 - - - uses: actions/cache/restore@v6 - with: - path: | - .build/checkouts - .build/repositories - .build/workspace-state.json - key: spm-${{ hashFiles('Package.swift') }} - restore-keys: spm- - - - name: Test - run: swift test - - test-linux: name: Test (${{ matrix.runner }}) - needs: verify-version + needs: resolve runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: - runner: [ubuntu-22.04, ubuntu-22.04-arm] + include: + - runner: macos-latest + # --no-parallel: see "Known limitations" in docs/linux-support.md. + - runner: ubuntu-22.04 + test-flags: --no-parallel + - runner: ubuntu-22.04-arm + test-flags: --no-parallel steps: - uses: actions/checkout@v7 - - uses: ./.github/actions/setup-swift-linux - with: - swift-version: ${{ env.SWIFT_VERSION }} + - if: runner.os == 'Linux' + uses: ./.github/actions/setup-swift-linux - - uses: actions/cache@v6 + - uses: ./.github/actions/spm-deps with: - path: | - .build/checkouts - .build/repositories - .build/workspace-state.json - key: spm-${{ hashFiles('Package.swift') }} - restore-keys: spm- + restore-only: 'true' - # --no-parallel for the reason in docs/linux-support.md: corelibs Foundation leaks write - # descriptors into concurrently forked children, surfacing as a spurious ETXTBSY. - name: Test - run: swift test --no-parallel - - build-macos: - name: Build macOS Universal Binary - needs: [test] - runs-on: macos-latest - steps: - - uses: actions/checkout@v7 - - - uses: actions/cache/restore@v6 - with: - path: | - .build/checkouts - .build/repositories - .build/workspace-state.json - key: spm-${{ hashFiles('Package.swift') }} - restore-keys: spm- - - - name: Build universal binary - run: | - swift build -c release --arch arm64 --arch x86_64 - echo "BIN_DIR=$(swift build -c release --arch arm64 --arch x86_64 --show-bin-path)" >> "$GITHUB_ENV" - - - name: Verify binary env: - TAG_NAME: ${{ github.ref_name }} + TEST_FLAGS: ${{ matrix.test-flags }} run: | - BUILT_VERSION=$("${BIN_DIR}/mcs" --version) - if [ "$BUILT_VERSION" != "$TAG_NAME" ]; then - echo "::error::Built binary reports version '${BUILT_VERSION}' but tag is '${TAG_NAME}'" - exit 1 - fi - echo "Binary verified: ${BUILT_VERSION}" + read -ra FLAGS <<< "$TEST_FLAGS" + swift test "${FLAGS[@]}" - - name: Prepare artifact - env: - TAG_NAME: ${{ github.ref_name }} - run: tar -czf "mcs-${TAG_NAME}-macos-universal.tar.gz" -C "${BIN_DIR}" mcs - - - uses: actions/upload-artifact@v7 - with: - name: mcs-macos-universal - path: mcs-${{ github.ref_name }}-macos-universal.tar.gz - if-no-files-found: error - - build-linux: - name: Build Linux ${{ matrix.arch }} Binary - needs: [test, test-linux] - # 22.04 on purpose, on both arches: it sets the glibc floor of the published binaries at 2.35. + build: + name: Build ${{ matrix.asset }} Binary + needs: test runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: include: + - runner: macos-latest + asset: macos-universal + build-flags: --arch arm64 --arch x86_64 + # 22.04 on purpose, on both arches: it sets the glibc floor of the published binaries at 2.35. - runner: ubuntu-22.04 + asset: linux-x86_64 arch: x86_64 + build-flags: --static-swift-stdlib - runner: ubuntu-22.04-arm + asset: linux-aarch64 arch: aarch64 + build-flags: --static-swift-stdlib steps: - uses: actions/checkout@v7 - - uses: ./.github/actions/setup-swift-linux - with: - swift-version: ${{ env.SWIFT_VERSION }} + - if: runner.os == 'Linux' + uses: ./.github/actions/setup-swift-linux - - uses: actions/cache/restore@v6 + - uses: ./.github/actions/spm-deps with: - path: | - .build/checkouts - .build/repositories - .build/workspace-state.json - key: spm-${{ hashFiles('Package.swift') }} - restore-keys: spm- + restore-only: 'true' - name: Build release binary + env: + BUILD_FLAGS: ${{ matrix.build-flags }} run: | - swift build -c release --static-swift-stdlib - echo "BIN_DIR=$(swift build -c release --static-swift-stdlib --show-bin-path)" >> "$GITHUB_ENV" + read -ra FLAGS <<< "$BUILD_FLAGS" + swift build -c release "${FLAGS[@]}" + echo "BIN_DIR=$(swift build -c release "${FLAGS[@]}" --show-bin-path)" >> "$GITHUB_ENV" - name: Verify binary env: TAG_NAME: ${{ github.ref_name }} + EXPECTED_ARCH: ${{ matrix.arch }} run: | BUILT_VERSION=$("${BIN_DIR}/mcs" --version) if [ "$BUILT_VERSION" != "$TAG_NAME" ]; then echo "::error::Built binary reports version '${BUILT_VERSION}' but tag is '${TAG_NAME}'" exit 1 fi - # The asset name encodes the arch, so a mismatch would ship a binary nobody can run. - BUILT_ARCH=$(uname -m) - if [ "$BUILT_ARCH" != "${{ matrix.arch }}" ]; then - echo "::error::Runner is ${BUILT_ARCH} but the asset is named ${{ matrix.arch }}" + # A Linux asset name encodes the arch, so a mismatch would ship a binary nobody can run. + if [ -n "$EXPECTED_ARCH" ] && [ "$(uname -m)" != "$EXPECTED_ARCH" ]; then + echo "::error::Runner is $(uname -m) but the asset is named ${EXPECTED_ARCH}" exit 1 fi - echo "Binary verified: ${BUILT_VERSION} (${BUILT_ARCH})" + echo "Binary verified: ${BUILT_VERSION}" - name: Prepare artifact env: TAG_NAME: ${{ github.ref_name }} - run: tar -czf "mcs-${TAG_NAME}-linux-${{ matrix.arch }}.tar.gz" -C "${BIN_DIR}" mcs + ASSET: ${{ matrix.asset }} + run: tar -czf "mcs-${TAG_NAME}-${ASSET}.tar.gz" -C "${BIN_DIR}" mcs - uses: actions/upload-artifact@v7 with: - name: mcs-linux-${{ matrix.arch }} - path: mcs-${{ github.ref_name }}-linux-${{ matrix.arch }}.tar.gz + name: mcs-${{ matrix.asset }} + path: mcs-${{ github.ref_name }}-${{ matrix.asset }}.tar.gz if-no-files-found: error release: name: Release - needs: [build-macos, build-linux, release-notes] + needs: [build, release-notes] runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/download-artifact@v8 with: @@ -287,6 +222,9 @@ jobs: sha256 "${SHA}" version "${TAG_NAME}" license "MIT" + # A Mach-O universal binary: on Linuxbrew this fails clearly instead of installing + # something that cannot run. Linux installs from the release tarballs. + depends_on :macos def install libexec.install "mcs" From e3f37eb470dc59dc854a3f5e9ef5468543b89ce8 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Wed, 30 Sep 2026 01:24:41 +0200 Subject: [PATCH 24/28] Point to the native Claude Code installer and trim test-only seams - A missing Claude CLI prints the self-updating native installer on every platform; mcs never installs it itself - Allocation checks before fork collapse into one guard; the termios reader and Homebrew test seams leave production code - Tests mirror their sources and drop cases that only restated language behavior --- Sources/mcs/Commands/BootstrapCommand.swift | 2 +- Sources/mcs/Commands/SyncCommand.swift | 2 +- Sources/mcs/Commands/UpdateCommand.swift | 2 +- Sources/mcs/Core/ClaudePrerequisite.swift | 65 ++----------------- Sources/mcs/Core/PTYBridge.swift | 4 +- Sources/mcs/Core/ShellRunner.swift | 48 ++++---------- Sources/mcs/Core/TerminalAttributes.swift | 12 +--- Sources/mcs/Doctor/DoctorRunner.swift | 2 +- Sources/mcs/Sync/ComponentExecutor.swift | 14 ++-- Tests/MCSTests/ClaudePrerequisiteTests.swift | 55 ++-------------- Tests/MCSTests/CoreDoctorCheckTests.swift | 25 +++++++ .../DoctorRunnerIntegrationTests.swift | 42 ------------ Tests/MCSTests/EnvironmentTests.swift | 41 ------------ Tests/MCSTests/HomebrewTests.swift | 11 ++++ .../MCSTests/LifecycleIntegrationTests.swift | 12 ++-- Tests/MCSTests/LockedTests.swift | 12 ---- Tests/MCSTests/ProjectDetectorTests.swift | 19 +++--- Tests/MCSTests/TerminalAttributesTests.swift | 20 +++--- docs/troubleshooting.md | 11 +--- 19 files changed, 105 insertions(+), 294 deletions(-) diff --git a/Sources/mcs/Commands/BootstrapCommand.swift b/Sources/mcs/Commands/BootstrapCommand.swift index 43af9df2..fea79632 100644 --- a/Sources/mcs/Commands/BootstrapCommand.swift +++ b/Sources/mcs/Commands/BootstrapCommand.swift @@ -34,7 +34,7 @@ struct BootstrapCommand: LockedCommand { // Dry-run must not trigger the Homebrew install prompt for Claude Code — that // would violate the no-changes contract on a preview. if !dryRun { - guard try ensureClaudeCLI(shell: ctx.shell, environment: ctx.env, output: ctx.output) else { + guard ensureClaudeCLI(shell: ctx.shell, output: ctx.output) else { throw ExitCode.failure } } diff --git a/Sources/mcs/Commands/SyncCommand.swift b/Sources/mcs/Commands/SyncCommand.swift index 0064cdf9..7ee63241 100644 --- a/Sources/mcs/Commands/SyncCommand.swift +++ b/Sources/mcs/Commands/SyncCommand.swift @@ -57,7 +57,7 @@ struct SyncCommand: LockedCommand { let output = CLIOutput() let shell = ShellRunner(environment: env) - guard try ensureClaudeCLI(shell: shell, environment: env, output: output) else { + guard ensureClaudeCLI(shell: shell, output: output) else { throw ExitCode.failure } diff --git a/Sources/mcs/Commands/UpdateCommand.swift b/Sources/mcs/Commands/UpdateCommand.swift index 714fb9c3..eec4f4a3 100644 --- a/Sources/mcs/Commands/UpdateCommand.swift +++ b/Sources/mcs/Commands/UpdateCommand.swift @@ -39,7 +39,7 @@ struct UpdateCommand: LockedCommand { let output = CLIOutput() let shell = ShellRunner(environment: env) - guard try ensureClaudeCLI(shell: shell, environment: env, output: output) else { + guard ensureClaudeCLI(shell: shell, output: output) else { throw ExitCode.failure } diff --git a/Sources/mcs/Core/ClaudePrerequisite.swift b/Sources/mcs/Core/ClaudePrerequisite.swift index 4f79e8dd..a75da3b4 100644 --- a/Sources/mcs/Core/ClaudePrerequisite.swift +++ b/Sources/mcs/Core/ClaudePrerequisite.swift @@ -1,69 +1,18 @@ import Foundation -/// Verify that the Claude Code CLI is available, offering an install where mcs can perform one. +/// Verify that the Claude Code CLI is on PATH, printing how to install it when it is not. /// -/// Returns `true` if Claude CLI is available (either already installed or successfully installed). -/// Returns `false` if the user declines installation or installation fails. -/// -/// `brewInstalled` and `confirmInstall` default to the real filesystem check and the real prompt; -/// tests pass both so every macOS branch runs without a Homebrew on the host or a terminal on -/// stdin. +/// mcs never installs it itself. The recommended native installer, the only install that keeps +/// itself updated, pipes a remote script into a shell, and that trust decision belongs to the user. @discardableResult -func ensureClaudeCLI( - shell: any ShellRunning, - environment: Environment, - output: CLIOutput, - brewInstalled: Bool? = nil, - confirmInstall: (() throws -> Bool)? = nil -) throws -> Bool { +func ensureClaudeCLI(shell: any ShellRunning, output: CLIOutput) -> Bool { if shell.commandExists(Constants.CLI.claudeCommand) { return true } output.error("Claude Code CLI not found.") - output.plain(" mcs requires the Claude Code CLI to function.") - - #if canImport(Darwin) - let brew = Homebrew(shell: shell, environment: environment) - guard brewInstalled ?? brew.isInstalled else { - printManualClaudeInstallInstructions(output) - return false - } - - let confirm = confirmInstall ?? { try output.askYesNo("Install Claude Code via Homebrew?", default: true) } - guard try confirm() else { - printManualClaudeInstallInstructions(output) - return false - } - - output.dimmed("Installing Claude Code...") - let result = brew.install("claude-code") - if result.succeeded, shell.commandExists(Constants.CLI.claudeCommand) { - output.success("Claude Code installed") - return true - } - - output.error("Failed to install Claude Code.") - if !result.stderr.isEmpty { - output.dimmed(String(result.stderr.prefix(200))) - } - printManualClaudeInstallInstructions(output) - return false - #else - // `claude-code` is a Homebrew *cask*, and Linuxbrew has no casks, so offering the brew install - // would fail on exactly the machines that have brew. Print what does work instead. - printManualClaudeInstallInstructions(output) - return false - #endif -} - -/// How to install Claude Code by hand. mcs never runs these itself — piping an installer into a -/// shell, or writing into npm's global prefix, is a trust decision that belongs to the user. -private func printManualClaudeInstallInstructions(_ output: CLIOutput) { - output.plain(" Install it manually: https://docs.anthropic.com/en/docs/claude-code") - #if !canImport(Darwin) - // On macOS the caller owns the Homebrew route; here the manual ones are all there is. + output.plain(" mcs requires the Claude Code CLI. Install it with the native installer, which keeps it updated:") output.plain(" curl -fsSL https://claude.ai/install.sh | bash") - output.plain(" npm install -g @anthropic-ai/claude-code") - #endif + output.plain(" Other methods (Homebrew, npm, apt, dnf): https://code.claude.com/docs/en/setup") + return false } diff --git a/Sources/mcs/Core/PTYBridge.swift b/Sources/mcs/Core/PTYBridge.swift index 1d38e00a..69872654 100644 --- a/Sources/mcs/Core/PTYBridge.swift +++ b/Sources/mcs/Core/PTYBridge.swift @@ -15,7 +15,7 @@ import Glibc /// readable data is always drained before the descriptor is dropped. enum PTYBridge { /// What to do with the user's terminal this iteration. - enum StdinAction: Equatable { + enum StdinAction { /// Bytes are waiting; forward them to the child. case forward /// Nothing to read and nothing coming; stop polling the descriptor. @@ -24,7 +24,7 @@ enum PTYBridge { } /// What to do with the child's PTY this iteration. - enum PTYAction: Equatable { + enum PTYAction { /// Output is waiting, or the child hung up with output possibly still buffered. case read /// The descriptor errored or is invalid, with nothing left to read; the bridge ends. diff --git a/Sources/mcs/Core/ShellRunner.swift b/Sources/mcs/Core/ShellRunner.swift index 5849b4cf..42f15be5 100644 --- a/Sources/mcs/Core/ShellRunner.swift +++ b/Sources/mcs/Core/ShellRunner.swift @@ -206,51 +206,27 @@ struct ShellRunner: ShellRunning { environment env: [String: String], workingDirectory: String? ) -> ShellResult { - // Prepare environment and argv as C strings for execve(). + // After fork() only async-signal-safe calls are legal, so every C string the child needs is + // allocated and checked here, in the parent: a NULL mid-vector would silently truncate it at + // execve. Glibc imports `execve`/`chdir` with non-optional parameters, hence the unwrap. let envp: [UnsafeMutablePointer?] = env.map { key, value in strdup("\(key)=\(value)") } + [nil] defer { envp.compactMap(\.self).forEach { free($0) } } - - // After fork() only async-signal-safe calls are legal, so the child has no way to report - // an allocation failure, and a force-unwrap there would trap through the Swift runtime. - // Every C string the child needs is therefore allocated and checked here, in the parent — - // a nil in the middle of argv or envp would otherwise truncate the vector at execve, which - // stops at the first NULL. (Glibc marks the `execve`/`chdir` parameters `__nonnull`, so - // Swift imports them non-optional there and rejects the `Optional` Darwin's unannotated - // signatures accept.) - guard let executablePath = strdup(executable) else { - return ShellResult( - exitCode: 1, stdout: "", stderr: "Could not allocate the command path for \(executable)" - ) - } - let argv: [UnsafeMutablePointer?] = [executablePath] + arguments.map { strdup($0) } + [nil] - defer { argv.compactMap(\.self).forEach { free($0) } } // frees executablePath too — it is argv[0] + let argv: [UnsafeMutablePointer?] = ([executable] + arguments).map { strdup($0) } + [nil] + defer { argv.compactMap(\.self).forEach { free($0) } } + let workingDirectoryPath = workingDirectory.flatMap { strdup($0) } + defer { workingDirectoryPath.map { free($0) } } // dropLast() skips the NULL terminator each vector deliberately ends with. - guard !argv.dropLast().contains(where: { $0 == nil }), - !envp.dropLast().contains(where: { $0 == nil }) + guard let executablePath = argv[0], + !argv.dropLast().contains(nil), + !envp.dropLast().contains(nil), + workingDirectory == nil || workingDirectoryPath != nil else { - return ShellResult( - exitCode: 1, stdout: "", stderr: "Could not allocate the command line for \(executable)" - ) + return ShellResult(exitCode: 1, stdout: "", stderr: "Could not allocate the command line for \(executable)") } - // Pre-convert workingDirectory to a C string before fork so the child - // doesn't need to invoke Swift's String-to-CString bridge (not fork-safe). - let workingDirectoryPath: UnsafeMutablePointer? - if let workingDirectory { - guard let copy = strdup(workingDirectory) else { - return ShellResult( - exitCode: 1, stdout: "", stderr: "Could not allocate the working directory path" - ) - } - workingDirectoryPath = copy - } else { - workingDirectoryPath = nil - } - defer { workingDirectoryPath.map { free($0) } } - // Save the terminal's current attributes so we can restore them after. var originalTermios = termios() let hasTerminal = tcgetattr(STDIN_FILENO, &originalTermios) == 0 diff --git a/Sources/mcs/Core/TerminalAttributes.swift b/Sources/mcs/Core/TerminalAttributes.swift index 24df4882..a1f2a5b1 100644 --- a/Sources/mcs/Core/TerminalAttributes.swift +++ b/Sources/mcs/Core/TerminalAttributes.swift @@ -4,7 +4,7 @@ import Darwin import Glibc #endif -/// termios helpers shared by the raw-mode picker. +/// termios helpers for the raw-mode pickers. /// /// `c_cc` imports as a fixed-size tuple whose length and element order are platform-defined /// (`NCCS` is 20 on Darwin and 32 on Glibc; `VMIN` is index 16 on Darwin and 6 on Glibc), so the @@ -12,15 +12,7 @@ import Glibc /// differs — `UInt` on Darwin, `UInt32` on Glibc — so every flag mask is converted through /// `tcflag_t(...)`. enum TerminalAttributes { - /// Reads one control character. Exists for `TerminalAttributesTests`: asserting raw mode - /// without a TTY is the only way to cover this on both platforms. - static func controlCharacter(_ attributes: termios, _ index: Int32) -> cc_t { - withUnsafeBytes(of: attributes.c_cc) { raw in - raw.bindMemory(to: cc_t.self)[Int(index)] - } - } - - static func setControlCharacter(_ attributes: inout termios, _ index: Int32, to value: cc_t) { + private static func setControlCharacter(_ attributes: inout termios, _ index: Int32, to value: cc_t) { withUnsafeMutableBytes(of: &attributes.c_cc) { raw in raw.bindMemory(to: cc_t.self)[Int(index)] = value } diff --git a/Sources/mcs/Doctor/DoctorRunner.swift b/Sources/mcs/Doctor/DoctorRunner.swift index f8f2b411..53037909 100644 --- a/Sources/mcs/Doctor/DoctorRunner.swift +++ b/Sources/mcs/Doctor/DoctorRunner.swift @@ -730,7 +730,7 @@ struct DoctorRunner { } guard !resyncs.isEmpty else { return } // An injected CLI stands in for the real binary; only the real one needs installing. - if claudeCLI == nil, try !ensureClaudeCLI(shell: shell, environment: environment, output: output) { + if claudeCLI == nil, !ensureClaudeCLI(shell: shell, output: output) { for check in resyncs.flatMap(\.checks) { docFixFailed(check.name, "re-sync needs the Claude Code CLI") } diff --git a/Sources/mcs/Sync/ComponentExecutor.swift b/Sources/mcs/Sync/ComponentExecutor.swift index 624c314c..7c41477e 100644 --- a/Sources/mcs/Sync/ComponentExecutor.swift +++ b/Sources/mcs/Sync/ComponentExecutor.swift @@ -234,8 +234,7 @@ struct ComponentExecutor { withIntermediateDirectories: true ) - var isDir: ObjCBool = false - let sourceIsDirectory = fm.fileExists(atPath: source.path, isDirectory: &isDir) && isDir.boolValue + let sourceIsDirectory = Self.isDirectory(source) var shippedFiles: [String] = [] var installedHashes: [String: String] = [:] @@ -317,8 +316,7 @@ struct ComponentExecutor { withIntermediateDirectories: true ) - var isDir: ObjCBool = false - let sourceIsDirectory = fm.fileExists(atPath: source.path, isDirectory: &isDir) && isDir.boolValue + let sourceIsDirectory = Self.isDirectory(source) var installedPaths: [String] = [] var shippedFiles: [String] = [] var installedHashes: [String: String] = [:] @@ -401,8 +399,7 @@ struct ComponentExecutor { values: [String: String] ) throws { let fm = FileManager.default - var isDir: ObjCBool = false - let sourceIsDirectory = fm.fileExists(atPath: source.path, isDirectory: &isDir) && isDir.boolValue + let sourceIsDirectory = Self.isDirectory(source) if sourceIsDirectory { try fm.createDirectory(at: destination, withIntermediateDirectories: true) @@ -496,4 +493,9 @@ struct ComponentExecutor { return false } + + private static func isDirectory(_ url: URL) -> Bool { + var isDir: ObjCBool = false + return FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir) && isDir.boolValue + } } diff --git a/Tests/MCSTests/ClaudePrerequisiteTests.swift b/Tests/MCSTests/ClaudePrerequisiteTests.swift index 7a2c04d5..4c004ad6 100644 --- a/Tests/MCSTests/ClaudePrerequisiteTests.swift +++ b/Tests/MCSTests/ClaudePrerequisiteTests.swift @@ -8,66 +8,21 @@ struct ClaudePrerequisiteTests { } @Test("A claude already on PATH is accepted without spawning anything") - func acceptsInstalledCLI() throws { + func acceptsInstalledCLI() { let shell = MockShellRunner() shell.commandExistsResult = true - #expect(try ensureClaudeCLI(shell: shell, environment: shell.environment, output: silentOutput())) + #expect(ensureClaudeCLI(shell: shell, output: silentOutput())) #expect(shell.commandExistsCalls.contains(Constants.CLI.claudeCommand)) #expect(shell.runCalls.isEmpty) } - @Test("A missing claude without brew is reported and nothing is installed") - func missingCLIWithoutHomebrew() throws { + @Test("A missing claude is reported and mcs installs nothing itself") + func missingCLIIsNeverInstalled() { let shell = MockShellRunner() shell.commandExistsResult = false - // The same on both platforms: on Linux `claude-code` is a cask Linuxbrew does not have. - #expect(try !ensureClaudeCLI( - shell: shell, environment: shell.environment, output: silentOutput(), - brewInstalled: false, confirmInstall: { Issue.record("no prompt without brew"); return true } - )) - #expect(shell.runCalls.isEmpty, "mcs must not attempt an install it cannot perform") - } - - #if canImport(Darwin) - @Test("Declining the Homebrew offer installs nothing") - func declinedHomebrewInstall() throws { - let shell = MockShellRunner() - shell.commandExistsResult = false - - #expect(try !ensureClaudeCLI( - shell: shell, environment: shell.environment, output: silentOutput(), - brewInstalled: true, confirmInstall: { false } - )) - #expect(shell.runCalls.isEmpty) - } - - @Test("Accepting the Homebrew offer runs brew install, then re-probes for claude") - func acceptedHomebrewInstall() throws { - let shell = MockShellRunner() - shell.commandExistsResult = false - - // The install "succeeds" but claude still is not on PATH, so the outcome is a failure — - // the assertion that matters is which command was run. - #expect(try !ensureClaudeCLI( - shell: shell, environment: shell.environment, output: silentOutput(), - brewInstalled: true, confirmInstall: { true } - )) - #expect(shell.runCalls.map(\.arguments) == [["install", "claude-code"]]) - #expect(shell.runCalls.first?.executable == shell.environment.brewPath) - } - #else - @Test("Linux never offers the Homebrew cask, even with brew present and a willing user") - func linuxNeverOffersHomebrew() throws { - let shell = MockShellRunner() - shell.commandExistsResult = false - - #expect(try !ensureClaudeCLI( - shell: shell, environment: shell.environment, output: silentOutput(), - brewInstalled: true, confirmInstall: { Issue.record("no prompt on Linux"); return true } - )) + #expect(!ensureClaudeCLI(shell: shell, output: silentOutput())) #expect(shell.runCalls.isEmpty) } - #endif } diff --git a/Tests/MCSTests/CoreDoctorCheckTests.swift b/Tests/MCSTests/CoreDoctorCheckTests.swift index 11d7ff18..96f560d0 100644 --- a/Tests/MCSTests/CoreDoctorCheckTests.swift +++ b/Tests/MCSTests/CoreDoctorCheckTests.swift @@ -810,3 +810,28 @@ struct SettingsDriftCheckTests { #expect(check.section == "Settings") } } + +struct BrewPackageCheckGuidanceTests { + @Test("The fix for a missing package never advertises a command that cannot work") + func fixDoesNotPointAtSyncWhenBrewIsAbsent() { + let package = "mcs-nonexistent-formula-for-tests" + let check = BrewPackageCheck(name: package, section: "Dependencies", package: package) + guard case .fail = check.check() else { + Issue.record("Expected .fail for a formula nothing provides, got \(check.check())") + return + } + + guard case let .notFixable(message) = check.fix() else { + Issue.record("Installing a package is sync's job, so the fix must be .notFixable") + return + } + + // Asserted against whichever machine runs this: with brew present `mcs sync` really can + // install the package; without it the hint is the manual advice instead. + if FileManager.default.fileExists(atPath: Environment().brewPath) { + #expect(message.contains("mcs sync")) + } else { + #expect(message == Homebrew.manualInstallAdvice(for: package)) + } + } +} diff --git a/Tests/MCSTests/DoctorRunnerIntegrationTests.swift b/Tests/MCSTests/DoctorRunnerIntegrationTests.swift index 337b94d3..23a84e16 100644 --- a/Tests/MCSTests/DoctorRunnerIntegrationTests.swift +++ b/Tests/MCSTests/DoctorRunnerIntegrationTests.swift @@ -527,45 +527,3 @@ struct DoctorSummaryWarningCountTests { #expect(withGhost.isHealthy) } } - -// MARK: - Brew Guidance - -struct BrewPackageCheckGuidanceTests { - @Test("A brew package whose formula name is also a command on PATH passes") - func formulaNameMatchingCommandPasses() { - // `git` stands in for that whole class: the PATH probe is provenance-blind, so any - // installer satisfies the declaration. This is the only shape that works on a Linux box - // with no Homebrew. - let check = BrewPackageCheck(name: "git", section: "Dependencies", package: "git") - guard case .pass = check.check() else { - Issue.record("Expected .pass for a formula name that is also on PATH, got \(check.check())") - return - } - } - - @Test("The fix for a missing package never advertises a command that cannot work") - func fixDoesNotPointAtSyncWhenBrewIsAbsent() { - let package = "mcs-nonexistent-formula-for-tests" - let check = BrewPackageCheck(name: package, section: "Dependencies", package: package) - guard case .fail = check.check() else { - Issue.record("Expected .fail for a formula nothing provides, got \(check.check())") - return - } - - guard case let .notFixable(message) = check.fix() else { - Issue.record("Installing a package is sync's job, so the fix must be .notFixable") - return - } - - // Asserted against whichever machine runs this: with brew present `mcs sync` really can - // install the package; without it the hint must name the package and where to get brew or - // the package (the Darwin text still ends in `mcs sync`, after installing brew). - if FileManager.default.fileExists(atPath: Environment().brewPath) { - #expect(message.contains("mcs sync")) - } else { - #expect(message == Homebrew.manualInstallAdvice(for: package)) - #expect(message.contains(package)) - #expect(message.contains("brew.sh") || message.contains("system package manager")) - } - } -} diff --git a/Tests/MCSTests/EnvironmentTests.swift b/Tests/MCSTests/EnvironmentTests.swift index 632bff50..df1be0cd 100644 --- a/Tests/MCSTests/EnvironmentTests.swift +++ b/Tests/MCSTests/EnvironmentTests.swift @@ -121,16 +121,6 @@ struct EnvironmentTests { #expect(Environment.defaultHomeDirectory(environment: ["HOME": "relative/home"]) == NSHomeDirectory()) } - @Test("Environment derives every path from the resolved home") - func environmentUsesResolvedHome() { - let home = Environment.defaultHomeDirectory(environment: ["HOME": "/sandbox/home"]) - let env = Environment(home: URL(fileURLWithPath: home)) - - #expect(env.homeDirectory.path == "/sandbox/home") - #expect(env.claudeDirectory.path == "/sandbox/home/.claude") - #expect(env.mcsDirectory.path == "/sandbox/home/.mcs") - } - @Test("The default initializer wires the resolved home through") func defaultInitUsesDefaultHomeDirectory() { #expect(Environment().homeDirectory.path == Environment.defaultHomeDirectory()) @@ -197,35 +187,4 @@ struct EnvironmentTests { func brewPrefixForRealFile() { #expect(Environment.brewPrefix(forBrewPath: "/opt/homebrew/bin/brew") == "/opt/homebrew") } - - @Test("The fallback prefix is the platform's own default") - func defaultBrewPrefixPerPlatform() { - #if canImport(Darwin) && arch(arm64) - #expect(Homebrew.defaultPrefix == "/opt/homebrew") - #elseif canImport(Darwin) - #expect(Homebrew.defaultPrefix == "/usr/local") - #else - #expect(Homebrew.defaultPrefix == "/home/linuxbrew/.linuxbrew") - #endif - } - - @Test("pathWithBrew prepends the prefix bin directory exactly once") - func pathWithBrewPrependsOnce() throws { - let home = try makeTmpHome() - defer { try? FileManager.default.removeItem(at: home) } - let env = Environment(home: home) - - let brewBin = "\(env.brewPrefix)/bin" - let currentPath = ProcessInfo.processInfo.environment["PATH"] ?? "/usr/bin:/bin" - let path = env.pathWithBrew - - // Asserted against the ambient PATH rather than a fixed shape: a developer whose profile - // is sourced twice genuinely has the brew bin in there more than once, and that is not a - // failure of this code. - if currentPath.contains(brewBin) { - #expect(path == currentPath, "an already-present brew bin must not be prepended again") - } else { - #expect(path.hasPrefix("\(brewBin):")) - } - } } diff --git a/Tests/MCSTests/HomebrewTests.swift b/Tests/MCSTests/HomebrewTests.swift index eb588d68..6826c0a3 100644 --- a/Tests/MCSTests/HomebrewTests.swift +++ b/Tests/MCSTests/HomebrewTests.swift @@ -17,6 +17,17 @@ struct HomebrewTests { // MARK: - allPrefixes + @Test("The fallback prefix is the platform's own default") + func defaultPrefixPerPlatform() { + #if canImport(Darwin) && arch(arm64) + #expect(Homebrew.defaultPrefix == "/opt/homebrew") + #elseif canImport(Darwin) + #expect(Homebrew.defaultPrefix == "/usr/local") + #else + #expect(Homebrew.defaultPrefix == "/home/linuxbrew/.linuxbrew") + #endif + } + @Test("allPrefixes lists this platform's Homebrew locations") func allPrefixesPerPlatform() { let home = URL(fileURLWithPath: "/tmp/mcs-home") diff --git a/Tests/MCSTests/LifecycleIntegrationTests.swift b/Tests/MCSTests/LifecycleIntegrationTests.swift index e4073430..d83e2db9 100644 --- a/Tests/MCSTests/LifecycleIntegrationTests.swift +++ b/Tests/MCSTests/LifecycleIntegrationTests.swift @@ -23,13 +23,12 @@ private struct LifecycleTestBed { func makeConfigurator( registry: TechPackRegistry = TechPackRegistry(), - warningCounter: WarningCounter? = nil, - shell: (any ShellRunning)? = nil + warningCounter: WarningCounter? = nil ) -> Configurator { Configurator( environment: env, output: CLIOutput(colorsEnabled: false, warningCounter: warningCounter, interactiveStdin: false), - shell: shell ?? ShellRunner(environment: env), + shell: ShellRunner(environment: env), registry: registry, strategy: ProjectSyncStrategy(projectPath: project, environment: env), claudeCLI: mockCLI @@ -3306,11 +3305,8 @@ struct BrewPackageDoctorTests { #expect(summary.issues == 0) } - /// The Linux happy path in one assertion: a `brew:` component whose command is on PATH is - /// satisfied without Homebrew existing at all, which is what makes `brew:` usable on a machine - /// that has no brew. Global scope, because that is where `configure` installs brew packages - /// inline. The package name is deliberately one no machine has, so the mocked PATH hit — not - /// the real machine — is what decides the outcome. + /// A `brew:` component whose command is on PATH needs no Homebrew at all — what makes `brew:` + /// usable on Linux. The package name is one no machine has, so the mocked PATH hit decides it. @Test("A brew package satisfied on PATH spawns no brew subprocess") func pathSatisfiedBrewPackageSpawnsNoSubprocess() throws { let bed = try LifecycleTestBed() diff --git a/Tests/MCSTests/LockedTests.swift b/Tests/MCSTests/LockedTests.swift index 5acbf0e7..33bb088d 100644 --- a/Tests/MCSTests/LockedTests.swift +++ b/Tests/MCSTests/LockedTests.swift @@ -15,16 +15,4 @@ struct LockedTests { #expect(doubled == 2) #expect(locked.withLock { $0 } == 2) } - - @Test("A box captured by a closure is the same box the caller reads") - func mutationThroughACaptureIsVisibleToTheOwner() { - // The shape ScriptRunner relies on: a flag set inside a DispatchWorkItem and read back on - // the calling thread after the work item has finished. - let flag = Locked(false) - let setter = { flag.withLock { $0 = true } } - - setter() - - #expect(flag.withLock { $0 }) - } } diff --git a/Tests/MCSTests/ProjectDetectorTests.swift b/Tests/MCSTests/ProjectDetectorTests.swift index a966db81..1356d1ff 100644 --- a/Tests/MCSTests/ProjectDetectorTests.swift +++ b/Tests/MCSTests/ProjectDetectorTests.swift @@ -3,6 +3,13 @@ import Foundation import Testing struct ProjectDetectorTests { + /// Compared as paths, not URLs: corelibs Foundation flags a directory URL produced by + /// deletingLastPathComponent() with a trailing slash that standardizedFileURL does not strip, + /// so two URLs for the same directory are unequal on Linux. + private func samePath(_ found: URL?, _ expected: URL) -> Bool { + found?.standardizedFileURL.path == expected.standardizedFileURL.path + } + private func makeTmpDir() throws -> URL { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("mcs-projdetect-test-\(UUID().uuidString)") @@ -24,11 +31,7 @@ struct ProjectDetectorTests { try FileManager.default.createDirectory(at: sourcesDir, withIntermediateDirectories: true) let root = ProjectDetector.findProjectRoot(from: sourcesDir) - // Compared as paths, not URLs: corelibs Foundation flags a directory URL produced by - // deletingLastPathComponent() with a trailing slash that standardizedFileURL does not - // strip, so two URLs for the same directory are unequal on Linux. The product compares - // paths for the same reason (PathContainment, Environment.isInsideClaudeHome). - #expect(root?.standardizedFileURL.path == tmpDir.standardizedFileURL.path) + #expect(samePath(root, tmpDir)) } @Test("Finds project root via CLAUDE.local.md") @@ -45,7 +48,7 @@ struct ProjectDetectorTests { try FileManager.default.createDirectory(at: subDir, withIntermediateDirectories: true) let root = ProjectDetector.findProjectRoot(from: subDir) - #expect(root?.standardizedFileURL.path == tmpDir.standardizedFileURL.path) + #expect(samePath(root, tmpDir)) } @Test("Finds project root via .claude/.mcs-project") @@ -64,7 +67,7 @@ struct ProjectDetectorTests { try FileManager.default.createDirectory(at: subDir, withIntermediateDirectories: true) let root = ProjectDetector.findProjectRoot(from: subDir) - #expect(root?.standardizedFileURL.path == tmpDir.standardizedFileURL.path) + #expect(samePath(root, tmpDir)) } @Test("Prefers .git over CLAUDE.local.md at same level") @@ -82,7 +85,7 @@ struct ProjectDetectorTests { ) let root = ProjectDetector.findProjectRoot(from: tmpDir) - #expect(root?.standardizedFileURL.path == tmpDir.standardizedFileURL.path) + #expect(samePath(root, tmpDir)) } } diff --git a/Tests/MCSTests/TerminalAttributesTests.swift b/Tests/MCSTests/TerminalAttributesTests.swift index db93011b..0a734101 100644 --- a/Tests/MCSTests/TerminalAttributesTests.swift +++ b/Tests/MCSTests/TerminalAttributesTests.swift @@ -7,26 +7,28 @@ import Glibc import Testing struct TerminalAttributesTests { - @Test("rawMode clears ICANON and ECHO, keeps ISIG, and sets VMIN/VTIME") + @Test("rawMode clears ICANON and ECHO, keeps ISIG, and sets VMIN/VTIME at the platform's indices") func rawModeFlagsAndControlCharacters() { var original = termios() original.c_lflag = tcflag_t(ICANON | ECHO | ISIG) + // Seeded opposite to what raw mode wants, so a write to the wrong index cannot pass. + setControlCharacter(&original, VMIN, to: 0) + setControlCharacter(&original, VTIME, to: 5) let raw = TerminalAttributes.rawMode(from: original) #expect(raw.c_lflag & tcflag_t(ICANON) == 0) #expect(raw.c_lflag & tcflag_t(ECHO) == 0) #expect(raw.c_lflag & tcflag_t(ISIG) != 0, "Ctrl-C must still generate SIGINT") - #expect(TerminalAttributes.controlCharacter(raw, VMIN) == 1) - #expect(TerminalAttributes.controlCharacter(raw, VTIME) == 0) + #expect(controlCharacter(raw, VMIN) == 1) + #expect(controlCharacter(raw, VTIME) == 0) } - @Test("setControlCharacter writes only the requested index") - func setControlCharacterIsIndexed() { - var attributes = termios() - TerminalAttributes.setControlCharacter(&attributes, VMIN, to: 7) + private func controlCharacter(_ attributes: termios, _ index: Int32) -> cc_t { + withUnsafeBytes(of: attributes.c_cc) { $0.bindMemory(to: cc_t.self)[Int(index)] } + } - #expect(TerminalAttributes.controlCharacter(attributes, VMIN) == 7) - #expect(TerminalAttributes.controlCharacter(attributes, VTIME) == 0) + private func setControlCharacter(_ attributes: inout termios, _ index: Int32, to value: cc_t) { + withUnsafeMutableBytes(of: &attributes.c_cc) { $0.bindMemory(to: cc_t.self)[Int(index)] = value } } } diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 14cee233..76e35f4c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -47,18 +47,13 @@ If you manage Node.js through nvm or similar, make sure it's available in your P **Symptom**: MCP servers and plugins can't be registered. -**Fix on macOS**: -```bash -brew install claude-code -``` - -**Fix on Linux**: `claude-code` is a Homebrew cask, and Linuxbrew has no casks, so mcs does not offer a brew install there. Use the native installer or npm: +**Fix**: install it with the native installer, the one install that keeps itself updated. mcs prints this command but never runs it for you: ```bash curl -fsSL https://claude.ai/install.sh | bash -# or -npm install -g @anthropic-ai/claude-code ``` +Homebrew, npm, apt and dnf also work, but they don't update themselves. See the [Claude Code setup guide](https://code.claude.com/docs/en/setup). + Verify: ```bash claude --version From ba03f39fa188f19bbad095b1e04ae4362c9ac5bd Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Wed, 30 Sep 2026 01:32:18 +0200 Subject: [PATCH 25/28] Bring comments and docs in line with the Linux port - Comments state their reason once, without narration; helper comments move inside bodies so they stay plain comments - CLAUDE.md and the Linux doc list five platform files, describe the upgrade and Claude CLI behavior, and mark smoke-backed rows - Docs cover shellInteractive off a terminal and how to recover a tag whose release failed --- CLAUDE.md | 14 +- Sources/mcs/Core/Environment.swift | 9 +- Sources/mcs/Core/Homebrew.swift | 8 +- Sources/mcs/Core/Locked.swift | 10 +- Sources/mcs/Core/PTYBridge.swift | 7 +- .../ExternalPack/ExternalDoctorCheck.swift | 4 +- Tests/MCSTests/CLIOutputTests.swift | 2 +- .../MCSTests/LifecycleIntegrationTests.swift | 4 +- Tests/MCSTests/ProjectDetectorTests.swift | 6 +- Tests/MCSTests/UpdateCheckerTests.swift | 2 +- docs/architecture.md | 8 +- docs/creating-tech-packs.md | 2 +- docs/linux-support.md | 200 ++++++++---------- docs/techpack-schema.md | 4 +- 14 files changed, 125 insertions(+), 155 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ab11517e..9cfc42de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,12 +85,12 @@ mcs config set # Set a configuration value (true/false) - `Backup.swift` — timestamped backups for mixed-ownership files (CLAUDE.local.md), backup discovery and deletion - `GitignoreManager.swift` — global gitignore management, core entry list - `ClaudeIntegration.swift` — `claude mcp add/remove` (with scope support), `claude plugin install/uninstall/list` (scope + working directory, like MCP) and `marketplace add/list` -- `ClaudePrerequisite.swift` — Claude Code CLI availability check; macOS offers a Homebrew install, Linux prints the native-installer and npm commands -- `Homebrew.swift` — brew detection, package install/uninstall, and `provides(_:)` — the one availability predicate shared by `ComponentExecutor` and `BrewPackageCheck` (PATH under `bareName(of:)`, falling back to `brew list`) +- `ClaudePrerequisite.swift` — Claude Code CLI availability check; when it is missing, prints the native installer (the self-updating install) and never installs it itself +- `Homebrew.swift` — brew detection, the platform's prefixes (`defaultPrefix`, `allPrefixes(home:)`), package install/uninstall, and `provides(_:)` — the one availability predicate shared by `ComponentExecutor` and `BrewPackageCheck` (PATH under `bareName(of:)`, falling back to `brew list`) - `FileHasher.swift` — SHA-256 file and directory hashing via swift-crypto's `Crypto`, which re-exports CryptoKit on Darwin (used by `PackTrustManager` and `ComponentExecutor`) - `FileLock.swift` — POSIX `flock()` process lock and `LockedCommand` protocol for mutually exclusive CLI commands -- `Locked.swift` — `NSLock`-backed value box with the `withLock { $0 }` shape of the Darwin-only `OSAllocatedUnfairLock` (`Synchronization.Mutex` is macOS 15+, above the macOS 13 floor) -- `TerminalAttributes.swift` — termios helpers for the raw-mode picker; `c_cc` is indexed through the platform's own `VMIN`/`VTIME` and flag masks are converted through `tcflag_t` +- `Locked.swift` — `NSLock`-backed value box; `OSAllocatedUnfairLock` is Darwin-only and `Synchronization.Mutex` needs macOS 15 +- `TerminalAttributes.swift` — termios helpers for the raw-mode pickers; `c_cc` is indexed through the platform's own `VMIN`/`VTIME` and flag masks are converted through `tcflag_t` - `PathContainment.swift` — centralized path-boundary checks and relative-path utilities (symlink-safe containment, traversal prevention) - `PluginRef.swift` — parsed `name@repo` plugin references; resolves the `name@` id Claude Code keys plugins by (`@org/repo` needs a `marketplace list` lookup), plus the `plugin list` / `marketplace list` JSON models - `ProjectDetector.swift` — walk-up project root detection (`.git/` or `CLAUDE.local.md`) @@ -167,8 +167,8 @@ mcs config set # Set a configuration value (true/false) ## Code Style -SwiftFormat and SwiftLint enforce consistent code style. CI downloads a **pinned** version of each -as a static Linux binary (`SWIFTLINT_VERSION` / `SWIFTFORMAT_VERSION` in `pr-checks.yml`) and asserts +SwiftFormat and SwiftLint enforce consistent code style. CI's lint job runs on macOS and downloads a +**pinned** release of each (`SWIFTLINT_VERSION` / `SWIFTFORMAT_VERSION` in `pr-checks.yml`), asserting the binary reports it, so a lint verdict depends on the repo alone — new rules arrive when someone bumps the pin. Keep the local install on those versions or CI will disagree with you; a local install may also come from a different manager (Mint, for instance) and sit earlier on `PATH`, so @@ -201,7 +201,7 @@ import Darwin import Glibc #endif ``` - Platform-dependent **values and control flow** live only in `Core/TerminalAttributes.swift`, `Core/Environment.swift`, `Core/Homebrew.swift`, `Core/Constants.swift`, `Core/ClaudePrerequisite.swift` and `Core/UpdateChecker.swift` — everything else calls into them. `TerminalAttributes` is on the list without a single `#if`: it owns the termios layout, which differs per platform through `VMIN`/`VTIME`/`tcflag_t` rather than through a branch. `ClaudePrerequisite` and `UpdateChecker` are there because what differs is control flow, not a value: macOS offers a Homebrew install of Claude Code and a `brew upgrade` of mcs itself, and Linux can do neither, so there is no constant to move. The `#if canImport(Darwin) / #elseif canImport(Glibc)` import chain at the top of a file that calls libc directly (`ShellRunner`, `PTYBridge`, `CLIOutput`, `FileLock`, `GlobMatcher`) is not platform knowledge in this sense — it selects the same API from a different module — and is allowed wherever it is needed. SHA-256 comes from swift-crypto's `Crypto` on both platforms; it re-exports CryptoKit on Darwin, so no `#if` is needed to hash. A `#if` is for a value or API that genuinely differs, never to make a diagnostic go away; the same goes for `_ =`. Several Foundation methods are `@discardableResult` on Darwin and not on Linux — use the result, it always means something. A test gated out on one platform is a coverage regression: give the `#else` branch the equivalent assertion. Rationale and the compatibility matrix are in `docs/linux-support.md` + Platform-dependent **values and control flow** live only in `Core/TerminalAttributes.swift`, `Core/Environment.swift`, `Core/Homebrew.swift`, `Core/Constants.swift` and `Core/UpdateChecker.swift` — everything else calls into them. `TerminalAttributes` has no branch beyond its import chain: it owns the termios layout, which differs per platform through `VMIN`/`VTIME`/`tcflag_t`. `UpdateChecker` is there because what differs is control flow, not a value: macOS upgrades mcs through `brew upgrade`, Linux through a staged tarball swap. The `#if canImport(Darwin) / #elseif canImport(Glibc)` import chain at the top of a file that calls libc directly (`ShellRunner`, `PTYBridge`, `CLIOutput`, `FileLock`, `GlobMatcher`, `TerminalAttributes`) is not platform knowledge in this sense — it selects the same API from a different module — and is allowed wherever it is needed. SHA-256 comes from swift-crypto's `Crypto` on both platforms; it re-exports CryptoKit on Darwin, so no `#if` is needed to hash. A `#if` is for a value or API that genuinely differs, never to make a diagnostic go away; the same goes for `_ =`. Several Foundation methods are `@discardableResult` on Darwin and not on Linux — use the result, it always means something. A test gated out on one platform is a coverage regression: give the `#else` branch the equivalent assertion. Rationale and the compatibility matrix are in `docs/linux-support.md` - **Comments carry the non-obvious "why", not a narration of the code** — don't restate the line below, don't describe what the code used to do. If the code already says it, delete the comment; if the rationale needs more than a line or two, it belongs in the issue or a memory. State a given rationale once, at the site that owns it, rather than repeating it at every call site ## Testing diff --git a/Sources/mcs/Core/Environment.swift b/Sources/mcs/Core/Environment.swift index a76d67ee..980b1570 100644 --- a/Sources/mcs/Core/Environment.swift +++ b/Sources/mcs/Core/Environment.swift @@ -77,13 +77,8 @@ struct Environment { return prefix.path } - /// The user's home directory, `$HOME` first. - /// - /// Foundation's `NSHomeDirectory()` resolves the passwd entry on Darwin and corelibs alike and - /// consults `$HOME` only when there is none, so `HOME=… mcs …` used to be ignored on every - /// platform. Preferring a non-empty `$HOME` is what makes containers, `sudo -H`-style launchers - /// and test sandboxes work. Takes the environment as a parameter so it can be tested as a pure - /// function. + /// The user's home directory, an absolute `$HOME` first: `NSHomeDirectory()` reads the passwd + /// entry even when `$HOME` is set, so `HOME= mcs …` and sandboxed runs would be ignored. static func defaultHomeDirectory( environment: [String: String] = ProcessInfo.processInfo.environment ) -> String { diff --git a/Sources/mcs/Core/Homebrew.swift b/Sources/mcs/Core/Homebrew.swift index 582a7668..5d3c6236 100644 --- a/Sources/mcs/Core/Homebrew.swift +++ b/Sources/mcs/Core/Homebrew.swift @@ -75,11 +75,9 @@ struct Homebrew { shell.run(environment.brewPath, arguments: ["uninstall", name]) } - /// What to tell the user about `package` when Homebrew is not installed. - /// - /// With no `brew` there is nothing `mcs sync` can do, so the advice must not point back at it - /// — that loop has no exit. On Linux, Homebrew is the unusual case: the package almost - /// certainly comes from the distribution's own package manager, so that is what is named. + /// What to tell the user about `package` when Homebrew is not installed. It names a step outside + /// mcs first, because advice that only says to re-run `mcs sync` would loop. On Linux the package + /// almost certainly comes from the distribution, so that is what is named. static func manualInstallAdvice(for package: String) -> String { #if canImport(Darwin) "Homebrew not found — install it from https://brew.sh, then re-run 'mcs sync' to get \(package)" diff --git a/Sources/mcs/Core/Locked.swift b/Sources/mcs/Core/Locked.swift index e1843cb1..a0f3b6e9 100644 --- a/Sources/mcs/Core/Locked.swift +++ b/Sources/mcs/Core/Locked.swift @@ -1,12 +1,8 @@ import Foundation -/// A mutable value guarded by a lock, with the `withLock { $0 }` shape of `OSAllocatedUnfairLock`, -/// which is Darwin-only. `Synchronization.Mutex` would be the modern answer but is macOS 15+, above -/// this package's macOS 13 deployment target. -/// -/// `@unchecked Sendable` is the point of the type rather than a way to quiet the checker: the -/// invariant — every access to `value` happens under `lock` — is real and cannot be expressed to -/// the compiler. +/// A mutable value guarded by a lock. `OSAllocatedUnfairLock` is Darwin-only and +/// `Synchronization.Mutex` needs macOS 15, above the macOS 13 floor. `@unchecked` because every +/// access to `value` goes through `lock`, which the compiler cannot verify. final class Locked: @unchecked Sendable { private let lock = NSLock() private var value: Value diff --git a/Sources/mcs/Core/PTYBridge.swift b/Sources/mcs/Core/PTYBridge.swift index 69872654..868e1d0f 100644 --- a/Sources/mcs/Core/PTYBridge.swift +++ b/Sources/mcs/Core/PTYBridge.swift @@ -9,10 +9,9 @@ import Glibc /// /// Two facts drive the shapes below. `poll(2)` reports `POLLHUP`, `POLLERR` and `POLLNVAL` /// whether or not they were requested, so a descriptor that keeps reporting one of them without -/// `POLLIN` makes `poll` return immediately, forever, unless the loop stops watching it — macOS -/// returns `POLLNVAL` for a `/dev/null` stdin, which is what a hook or `mcs sync --all FixResult { guard let fixCommand else { - // The pack declares no fix and nothing here knows which component — if any — provides - // this command: it may be a brew: package, a shell: installer, or nothing at all. So - // the hint names both routes rather than guessing from what Homebrew can do. + // Nothing here knows which component, if any, provides this command, so both routes are named. return .notFixable( "Run 'mcs sync' if a pack component installs '\(command)'; otherwise install it and make sure it is on PATH" ) diff --git a/Tests/MCSTests/CLIOutputTests.swift b/Tests/MCSTests/CLIOutputTests.swift index 267d352a..1a6eb137 100644 --- a/Tests/MCSTests/CLIOutputTests.swift +++ b/Tests/MCSTests/CLIOutputTests.swift @@ -40,8 +40,8 @@ struct CLIOutputTests { #expect(CLIOutput(colorsEnabled: false, input: writeEnd).readByte() == nil) } - /// A picker reading from a pipe whose writer has closed: the hangup a raw-mode prompt sees. private func outputWithClosedInput() -> (CLIOutput, Int32) { + // A pipe whose writer has closed: the hangup a raw-mode prompt sees. let (readEnd, writeEnd) = makePipe() close(writeEnd) return (CLIOutput(colorsEnabled: false, input: readEnd), readEnd) diff --git a/Tests/MCSTests/LifecycleIntegrationTests.swift b/Tests/MCSTests/LifecycleIntegrationTests.swift index d83e2db9..fa9c0f7d 100644 --- a/Tests/MCSTests/LifecycleIntegrationTests.swift +++ b/Tests/MCSTests/LifecycleIntegrationTests.swift @@ -3305,10 +3305,10 @@ struct BrewPackageDoctorTests { #expect(summary.issues == 0) } - /// A `brew:` component whose command is on PATH needs no Homebrew at all — what makes `brew:` - /// usable on Linux. The package name is one no machine has, so the mocked PATH hit decides it. @Test("A brew package satisfied on PATH spawns no brew subprocess") func pathSatisfiedBrewPackageSpawnsNoSubprocess() throws { + // What makes `brew:` usable on Linux: a command on PATH needs no Homebrew at all. The package + // name is one no machine has, so the mocked PATH hit decides it. let bed = try LifecycleTestBed() defer { bed.cleanup() } diff --git a/Tests/MCSTests/ProjectDetectorTests.swift b/Tests/MCSTests/ProjectDetectorTests.swift index 1356d1ff..737d34d2 100644 --- a/Tests/MCSTests/ProjectDetectorTests.swift +++ b/Tests/MCSTests/ProjectDetectorTests.swift @@ -3,10 +3,10 @@ import Foundation import Testing struct ProjectDetectorTests { - /// Compared as paths, not URLs: corelibs Foundation flags a directory URL produced by - /// deletingLastPathComponent() with a trailing slash that standardizedFileURL does not strip, - /// so two URLs for the same directory are unequal on Linux. private func samePath(_ found: URL?, _ expected: URL) -> Bool { + // Paths, not URLs: corelibs Foundation keeps a trailing slash on a directory URL from + // deletingLastPathComponent() that standardizedFileURL does not strip, so on Linux two URLs + // for the same directory compare unequal. found?.standardizedFileURL.path == expected.standardizedFileURL.path } diff --git a/Tests/MCSTests/UpdateCheckerTests.swift b/Tests/MCSTests/UpdateCheckerTests.swift index bb480f53..fb810940 100644 --- a/Tests/MCSTests/UpdateCheckerTests.swift +++ b/Tests/MCSTests/UpdateCheckerTests.swift @@ -1304,8 +1304,8 @@ struct LinuxUpgradeCommandTests { UpdateChecker.linuxUpgrade(toVersion: version, binaryPath: path, directoryWritable: writable, arch: "x86_64") } - /// `sh -n` parses without running, so a quoting mistake fails here instead of in a user's shell. private func shellParses(_ script: String) throws -> Bool { + // `sh -n` parses without running, so a quoting mistake fails here instead of in a user's shell. let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/sh") process.arguments = ["-n", "-c", script] diff --git a/docs/architecture.md b/docs/architecture.md index e1492003..b94dcda9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -32,7 +32,7 @@ The primary command is **`mcs sync`**, which handles both global and per-project ### Environment (`Core/Environment.swift`) -Central path resolution for all file locations. Detects architecture (arm64/x86_64), resolves the Homebrew path, and locates the user's shell RC file. The Homebrew prefix is platform-dependent — `/opt/homebrew` or `/usr/local` on macOS, `/home/linuxbrew/.linuxbrew` on Linux — and is derived from a `brew` on `PATH` when there is one, resolving symlinks and stripping a trailing `Homebrew` component (see [Linux support, D7](linux-support.md#d7--homebrew-on-linux)). Key paths: +Central path resolution for all file locations. Detects architecture (arm64/x86_64), resolves the Homebrew path, and locates the user's shell RC file. The Homebrew prefix is derived from a `brew` on `PATH` when there is one, resolving symlinks and stripping a trailing `Homebrew` component, and falls back to `Homebrew.defaultPrefix` otherwise (see [Linux support, D7](linux-support.md#d7--homebrew-on-linux)). Key paths: - `~/.claude/` — Claude Code configuration directory - `~/.claude/settings.json` — user settings (global) @@ -97,11 +97,11 @@ Written by `mcs sync` after convergence. ### Locked (`Core/Locked.swift`) -A value guarded by an `NSLock`, with the `withLock { $0 }` shape of `OSAllocatedUnfairLock`, which is Darwin-only. Used by `WarningCounter` and by `ScriptRunner`'s timeout flag. `Synchronization.Mutex` would be the modern answer but is macOS 15+, above this package's deployment target. +A value guarded by an `NSLock`, used by `WarningCounter` and by `ScriptRunner`'s timeout flag. See [Linux support, D2](linux-support.md#d2--lockedvalue-replaces-osallocatedunfairlock) for why it is not `OSAllocatedUnfairLock` or `Synchronization.Mutex`. ### TerminalAttributes (`Core/TerminalAttributes.swift`) -termios helpers for the raw-mode picker. `c_cc` is a fixed-size tuple whose length and element order are platform-defined and `tcflag_t` differs in width, so the control-character index comes from the platform's own `VMIN`/`VTIME` and every flag mask is widened through `tcflag_t`. +termios helpers for the raw-mode pickers. `c_cc` is a fixed-size tuple whose length and element order are platform-defined and `tcflag_t` differs in type, so the control-character index comes from the platform's own `VMIN`/`VTIME` and every flag mask is converted through `tcflag_t`. ### Backup (`Core/Backup.swift`) @@ -383,7 +383,7 @@ The codebase uses Swift 6's strict concurrency. All core types conform to `Senda ## Platform Support -`mcs` builds for macOS 13+ and for Linux (glibc, x86_64 and aarch64). Platform-dependent knowledge is confined to `Core/TerminalAttributes.swift`, `Core/Environment.swift`, `Core/Homebrew.swift`, `Core/Constants.swift`, `Core/ClaudePrerequisite.swift` and `Core/UpdateChecker.swift`; everything else calls into them. `swift-crypto` supplies SHA-256 on both platforms — it re-exports CryptoKit on Darwin, so no conditional import is needed. See [Linux support](linux-support.md) for the compatibility matrix, the decisions behind each platform branch, and the rules for adding a new one. +`mcs` builds for macOS 13+ and for Linux (glibc, x86_64 and aarch64). Which files may hold platform-dependent code, and how to add a new platform branch, is in [Linux support, section 7](linux-support.md#7-how-to-add-a-platform-specific-path); the rest of that document covers the compatibility matrix and the decisions behind each branch. --- diff --git a/docs/creating-tech-packs.md b/docs/creating-tech-packs.md index c3bded5f..81453db4 100644 --- a/docs/creating-tech-packs.md +++ b/docs/creating-tech-packs.md @@ -140,7 +140,7 @@ Need to depend on Homebrew itself? That's a special case — Homebrew can't inst command: brew ``` -If the install script may need `sudo` (e.g. creating symlinks in `/usr/local/bin`), add `shellInteractive: true` to allocate a real terminal so password prompts work correctly: +If the install script may need `sudo` (e.g. creating symlinks in `/usr/local/bin`), add `shellInteractive: true` to allocate a real terminal so password prompts work correctly. When mcs itself runs without a terminal (CI, a hook, ` .test-output/results.txt 2>&1 **Do not run the suite as root.** `UpdateCheckerTests.registryWriteFailureContract` makes a directory read-only and asserts that a write into it fails; root ignores directory permissions, so -that one test fails under `sudo` or in a root container. This is why the CI job runs on the -`ubuntu-latest` runner rather than in a `swift:*` container image. +that one test fails under `sudo` or in a root container. This is why the CI jobs run on the +`ubuntu-22.04` runners rather than in a `swift:*` container image. -**Run it with `--no-parallel` on Linux.** CI does, for the reason in the known limitations below; -without it roughly one full run in four fails with a spurious `ETXTBSY`. Serial costs about a -quarter more wall-clock time (42s against 34s here). +**Run it with `--no-parallel` on Linux**, as CI does; the reason is under known limitations. ## 4. Compatibility matrix Legend: `verified` — run on Linux and observed; `verified (with a difference)` — works, but not -identically to macOS; the Notes column says how. The rows for whole command flows come from -`.github/actions/mcs-smoke`, which PR checks run against a real pack on `ubuntu-22.04`, -`ubuntu-22.04-arm` and macOS, so they are re-verified on every pull request rather than at the -date of this table. +identically to macOS; the Notes column says how. Rows marked † are re-run on every pull request by +`.github/actions/mcs-smoke`, against a real pack on `ubuntu-22.04`, `ubuntu-22.04-arm` and macOS. The +others were run by hand on Ubuntu 24.04 when the port landed. | Feature | macOS | Linux | Notes | |---|---|---|---| | `mcs sync` (project) | supported | verified | `mcs sync --pack linux-probe` in a git project: components installed, `settings.local.json` composed, `CLAUDE.local.md` generated, `.mcs-project` written. | | `mcs sync --global` | supported | verified | `mcs sync --global --pack git-probe`: artifacts under `~/.claude/`, `settings.json` composed, `global-state.json` written. | -| Raw-mode pickers | supported | verified | Driven under a real PTY: `↓` moved the cursor, `Space` toggled, `Enter` applied; in-place redraw and cursor hide/show correct. A hangup mid-prompt answers No and warns rather than spinning. | +| Raw-mode pickers | supported | verified | Driven under a real PTY: `↓` moved the cursor, `Space` toggled, `Enter` applied; in-place redraw and cursor hide/show correct. A hangup mid-prompt throws, so the command exits non-zero instead of acting on an unconfirmed choice. | | Non-TTY fallback picker | supported | verified | stdin a PTY, stdout a pipe: numeric toggle + `Enter`, colours disabled. | -| `shell:` components | supported | verified | `shell: touch ` created the marker. | -| `shellInteractive: true` (PTY/sudo) | supported | verified | `forkpty` path ran the command; marker file created, with stdin from `/dev/null` and under a real controlling terminal. Also asserted by `LifecycleIntegrationTests`. The PTY is allocated with a default 0×0 window size on both platforms. | -| `brew:` components | supported | verified (with a difference) | Same predicate on both platforms. Without Linuxbrew a `brew:` component is satisfied only when the formula name is also the command name — `brew: node` passes with `node` on PATH, `brew: ripgrep` does not because the command is `rg`. Doctor then reports it missing, and `--fix` names the system package manager instead of pointing back at `mcs sync`. Installing a formula still needs Linuxbrew. | +| `shell:` components † | supported | verified | `shell: touch ` created the marker. | +| `shellInteractive: true` (PTY/sudo) † | supported | verified | Under a real terminal the `forkpty` path ran the command. Off a terminal (stdin `/dev/null`, CI) it runs without a PTY, so a prompt sees end of input instead of hanging; smoke asserts this under a time limit. The PTY is allocated with a default 0×0 window size on both platforms. | +| `brew:` components † | supported | verified (with a difference) | Same predicate on both platforms. Without Linuxbrew a `brew:` component is satisfied only when the formula name is also the command name — `brew: node` passes with `node` on PATH, `brew: ripgrep` does not because the command is `rg`. Doctor then reports it missing, and `--fix` names the system package manager instead of pointing back at `mcs sync`. Installing a formula still needs Linuxbrew. | | `mcp:` (`claude mcp add`) | supported | verified | `demo-server` registered via `claude mcp add -s local`; `claude mcp list` shows it; removal deregisters it. | | `plugin:` | supported | verified | `hookify@claude-code-plugins` installed through `claude plugin install`; `claude plugin list` shows it enabled. | -| `hook:` / `command:` / `skill:` / `agent:` copies | supported | verified | All four installed; hooks namespaced under `/`; interpreter inferred (`bash` for `.sh`, `python3` for `.py`). | +| `hook:` / `command:` / `skill:` / `agent:` copies † | supported | verified | All four installed; hooks namespaced under `/`; interpreter inferred (`bash` for `.sh`, `python3` for `.py`). | | `settingsFile:` | supported | verified | Deep-merged into `settings.local.json` with `__GREETING__` substituted. | | `gitignore:` | supported | verified | Entry added to `~/.config/git/ignore`; reference counting kept it when one of two scopes was removed. | -| `mcs update` | supported | verified | `mcs update --project --trust-all` re-fetched the pack and re-applied the scope. | -| `mcs doctor` | supported | verified | 20 checks across every section; `✓`/`⚠` rendering correct. | -| `mcs doctor --fix` | supported | verified | Deleting `.claude` from the global gitignore then `doctor --fix` re-added it (`GitignoreCheck.fix`). | +| `mcs update` / `--trust-all` † | supported | verified | `mcs update --project --trust-all` re-applied the scope with no trust prompt; a git pack was re-fetched by hand. | +| `mcs doctor` † | supported | verified | 20 checks across every section; `✓`/`⚠` rendering correct. | +| `mcs doctor --fix` / `--yes` † | supported | verified | Deleting `.claude` from the global gitignore then `doctor --fix` re-added it (`GitignoreCheck.fix`); with `--yes` the re-sync path ran without prompting and is idempotent. | | `mcs pack add` (git) | supported | verified | `mcs pack add git://…/gitprobe` cloned, prompted for trust, registered. | -| `mcs pack add` (local) | supported | verified | `mcs pack add ` registered in place with `commitSHA: local`. | -| `mcs pack remove` / `list` / `update` / `validate` | supported | verified | `remove` unconfigured every affected scope; `update` reported "already up to date"; `validate` produced the python3 heuristic warning. | +| `mcs pack add` (local) † | supported | verified | `mcs pack add '~/fixture-pack'` expanded the tilde against `$HOME` and registered the pack in place with `commitSHA: local`. | +| `mcs pack remove` / `list` / `update` / `validate` † | supported | verified | `remove` unconfigured every affected scope; `update` reported "already up to date"; `validate` produced the python3 heuristic warning. | | Pack trust hashing | supported | verified | Trust prompt shown on add for both a local and a git pack; re-validated on `pack update` (SHA-256 now from swift-crypto). | -| `mcs export` (incl. brew formula hints) | supported | verified (with a difference) | Exported `techpack.yaml`, hooks, skill, command, agent, settings and template from a live project. The brew formula hints are empty without Linuxbrew — `detectFormula` reads symlinks under the Linuxbrew prefixes. | -| `mcs cleanup` | supported | verified | Found and deleted a `CLAUDE.local.md.backup.*` file, with and without `--force`. | -| `mcs check-updates` + SessionStart hook | supported | verified (with a difference) | `check-updates`, `--json` and `--hook` all run; `mcs config set update-check true` registered `mcs check-updates --hook` in `~/.claude/settings.json` and the cooldown file was written. The upgrade command differs: `brew upgrade` on macOS, a staged tarball swap on Linux — see ADR D12. | -| `mcs bootstrap` | supported | verified | `mcs.yaml` with a seeded `values:` entry: the pack installed and the skill body carried the seeded value, with "Reusing 1 previously configured value(s)". | -| `mcs bootstrap --trust-all` | supported | verified | Ran with no TTY; the pack's executable content was approved without a prompt. | -| `mcs sync --pack --prune --yes` | supported | verified | Made the named pack the exact set and skipped the removal confirmation. | -| `mcs doctor --fix --yes` (re-sync) | supported | verified | `6 passed 0 warnings 0 issues` before and after, so the re-sync path is idempotent. | -| `mcs update --trust-all` | supported | verified | `mcs update --project --trust-all` re-applied the scope with no trust prompt. | -| `mcs pack list --json` | supported | verified | Machine-readable output including `isLocal` and the `local` commit sentinel. | -| Non-interactive prompt resolution | supported | verified | `mcs sync --all < /dev/null` resolved every key from the stored prior instead of blocking on stdin. | +| `mcs export` (incl. brew formula hints) † | supported | verified (with a difference) | Exported `techpack.yaml`, hooks, skill, command, agent, settings and template from a live project. The brew formula hints are empty without Linuxbrew — `detectFormula` reads symlinks under the Linuxbrew prefixes. | +| `mcs cleanup` † | supported | verified | Found and deleted a `CLAUDE.local.md.backup.*` file, with and without `--force`. | +| `mcs check-updates` + SessionStart hook † | supported | verified (with a difference) | `check-updates`, `--json` and `--hook` all run; `mcs config set update-check true` registered `mcs check-updates --hook` in `~/.claude/settings.json` and the cooldown file was written. The upgrade command differs: `brew upgrade` on macOS, a staged tarball swap on Linux — see ADR D12. | +| `mcs bootstrap` / `--trust-all` † | supported | verified | `mcs.yaml` with a `~/…` source and a seeded `values:` entry: with no TTY the pack's executable content was approved without a prompt, and the skill body carried the seeded value. | +| `mcs sync --pack --prune --yes` † | supported | verified | Made the named pack the exact set and skipped the removal confirmation. | +| `mcs pack list --json` † | supported | verified | Machine-readable output including `isLocal` and the `local` commit sentinel. | +| Non-interactive prompt resolution † | supported | verified | `mcs sync --all < /dev/null` resolved every key from the stored prior instead of blocking on stdin. | | `mcs config` | supported | verified | `list` / `get` / `set` against `~/.mcs/config.yaml`. | -| `$HOME` override | supported (behavior change) | verified | `mcs` resolves its home from `$HOME`, falling back to the passwd entry, on both platforms — including a `~` typed in a pack path or a pack's doctor `path:`. Previously `$HOME` was ignored everywhere (ADR D11). | +| `$HOME` override † | supported (behavior change) | verified | `mcs` resolves its home from `$HOME`, falling back to the passwd entry, on both platforms — including a `~` typed in a pack path or a pack's doctor `path:`. A relative `$HOME` is ignored. The smoke run executes entirely under a sandboxed `$HOME` (ADR D11). | | File lock (`flock`) | supported | verified | Two concurrent syncs: the second exited 1 with "Another mcs process is running". | | Terminal colours / width | supported | verified | ANSI colour and the wrapped/re-rendered picker observed under a PTY; colours suppressed when stdout is a pipe. | -| Claude Code prerequisite | supported | verified (with a difference) | With `claude` off PATH, Linux prints the native-installer and npm commands and returns false. macOS still offers the Homebrew install; `claude-code` is a cask and Linuxbrew has no casks. | +| Claude Code prerequisite | supported | verified | With `claude` off PATH, mcs prints the native installer and the setup guide and returns false. It never installs Claude Code, on either platform (ADR D7). | | Release artifact | `.tar.gz` (universal) | verified (with a difference) | `swift build -c release --static-swift-stdlib` produces one ~95 MB binary per architecture; each tarball holds exactly one file and `./mcs --version` prints the version. `ldd` still shows `libstdc++.so.6`, `libgcc_s.so.1`, `libm`, `libc` and `ld-linux`. | ## 5. Decisions (ADR entries) @@ -169,7 +161,7 @@ On Apple platforms swift-crypto compiles its API surface down to nothing and re- `import Crypto` *is* CryptoKit there: same implementation, bit-identical digests for the hashes persisted in `.mcs-project`. Its manifest gates `CCryptoBoringSSL` and friends on non-Darwin platforms, so the 13 MB C library is not compiled on macOS either. What (a) buys over (b) is that -four files lose a conditional-import block and the platform rule loses a clause. +three files lose a conditional-import block and the platform rule loses a clause. **Why not (c)/(d).** Hand-rolled crypto is a maintenance liability even for a hash; shelling out costs a process per file and makes `FileHasher` untestable offline. @@ -197,20 +189,17 @@ lines. **Why not (c):** `Mutex` is macOS 15+, and this package's floor is macOS drop macOS 13 and 14 users for a lock. **Why not (e):** `ScriptRunner` reads its flag synchronously after `waitUntilExit()`; an actor forces `await` into a synchronous path. -**Consequences.** `NSLock` is marginally slower than an unfair lock. Both sites are cold. The -`@unchecked Sendable` on `Locked` is the type's purpose rather than a way to quiet the checker: the -invariant — every access happens under the lock — is real and cannot be expressed to the compiler, -and the doc comment says so at the one site that owns it. +**Consequences.** `NSLock` is marginally slower than an unfair lock. Both sites are cold. ### D3 — Platform imports are an explicit `#if canImport` chain, per file -**Context.** Five files call libc directly. On macOS they got those symbols from Foundation's Darwin +**Context.** Six files call libc directly. On macOS they got those symbols from Foundation's Darwin re-export or an outright `import Darwin`. **Options considered.** (a) A chain in each file. (b) A `Platform.swift` with `@_exported import`. (c) A C shim target. -**Decision: (a)**, verbatim in each of the five files: +**Decision: (a)**, verbatim in each of them: ```swift #if canImport(Darwin) @@ -255,7 +244,7 @@ Swift runtime after `fork()`, where only async-signal-safe calls are legal, so e child needs is allocated and checked in the parent. If a future toolchain drops `pty.h` the build fails immediately and the C shim remains a ~15-line fallback. -### D5 — termios: index `c_cc` through `VMIN`/`VTIME`, widen masks through `tcflag_t` +### D5 — termios: index `c_cc` through `VMIN`/`VTIME`, convert masks through `tcflag_t` **Context.** The raw-mode picker set `raw.c_cc.16` and `raw.c_cc.17` with `// VMIN` / `// VTIME` comments, and masked with `~UInt(ICANON | ECHO)`. Both are Darwin facts: `tcflag_t` is `UInt` there @@ -269,8 +258,7 @@ platform's own constant. **Decision: (b).** (a) is a `#if` hiding a real difference behind two magic numbers; indexing by `VMIN` has no second place to get wrong. `withUnsafeMutableBytes` + `bindMemory(to: cc_t.self)` is sound — `c_cc` is already a tuple of `cc_t` — and nothing in Foundation or the stdlib does it for -you. `TerminalAttributesTests` asserts the same things on both platforms without needing a TTY, -which is why the read accessor exists; its doc comment says so, so it is not deleted as dead code. +you. `TerminalAttributesTests` asserts the same things on both platforms without needing a TTY. ### D6 — `TIOCGWINSZ` widens to `UInt` @@ -296,8 +284,9 @@ users have no Homebrew at all. at the real install with `$PREFIX/bin` off PATH — by reporting the shim's directory as the prefix. Resolve-then-strip handles arm64, Intel, Linuxbrew and shims alike. `brewPrefix` feeds only `pathWithBrew`; formula detection walks `Homebrew.allPrefixes`. -2. **The fallback prefix and `allPrefixes` are platform-aware.** Linux gets - `/home/linuxbrew/.linuxbrew` and `~/.linuxbrew`. No `brew --prefix` subprocess is spawned. +2. **`Homebrew.defaultPrefix` and `allPrefixes(home:)` are platform-aware.** Linux gets + `/home/linuxbrew/.linuxbrew` and `~/.linuxbrew`, under the injected home. No `brew --prefix` + subprocess is spawned. 3. **`Homebrew.provides` does not change.** It probes PATH under the name with any tap qualifier stripped, then asks `brew list`. **The consequence, stated plainly: on Linux without Linuxbrew a `brew:` component is satisfied only when the formula name is also the command name.** `brew: node` @@ -306,12 +295,13 @@ users have no Homebrew at all. heuristics would trade a clear rule for a list that is always incomplete. 4. **The messages say what actually works.** When Homebrew is absent, telling the user to run `mcs sync` is a loop with no exit: sync prints "Homebrew not found" and sends them back to doctor. - `ComponentExecutor` (install and uninstall) and `BrewPackageCheck.fix()` now name the package and + `ComponentExecutor`'s install path and `BrewPackageCheck.fix()` name the package and the system package manager. The command-exists check drops its `mcs sync` hint entirely, because nothing there knows which component — if any — would install that command. -5. **`ensureClaudeCLI` never offers Homebrew on Linux.** `claude-code` is a Homebrew *cask*, and - Linuxbrew has no casks, so the offer would have failed on exactly the machines that have brew. - Linux prints the native-installer and npm commands and returns `false`. +5. **`ensureClaudeCLI` never installs Claude Code, on either platform.** It prints the native + installer, the one install that keeps itself updated, and the setup guide. Homebrew, npm, apt and + dnf installs don't update themselves, and running the native installer means piping a remote + script into a shell, which is the user's call. **Rejected.** Auto-installing Linuxbrew (a package manager installing a package manager, unprompted) and auto-installing Claude Code via `curl | bash` or npm (a trust decision mcs cannot make for the @@ -334,19 +324,13 @@ check would then print a permanently-passing line in every `mcs doctor` run on * touching doctor mandates an integration test. Three files and a test, for a condition that does not occur on the platforms mcs ships for. -**Consequences.** The failure mode is documented in section 2 rather than detected, and it is worse -than "reports an empty machine": `mcs sync` and `mcs update` refuse to run and blame a missing Claude -Code CLI that is in fact installed. That is a bad failure, and it is why the decision is documented -prominently rather than left implicit — but it does not change the arithmetic, because the doctor -check considered in (c) would not have helped either. `mcs doctor` is the one command that still -completes, so a user who runs it sees a `✗ which` line; a user who runs `sync` gets the misleading -error and never reaches doctor. Detecting it where it actually bites means a check in `sync`'s -prerequisite path, which is a different change from the one (c) proposed. +**Consequences.** The failure mode is documented in section 2 rather than detected. (c) would not +have helped: `sync` fails on the Claude Code prerequisite before any doctor check could run, so +detecting it where it bites means a check in `sync`'s prerequisite path, a different change. ### D9 — No Subprocess 1.0, no tools-version bump, macOS 13 floor stays -swift-tools-version 6.0 already supports `.product(…, condition: .when(platforms:))`, and -swift-crypto is tools-version 5.9 / macOS 10.15, so nothing forces a bump. Adopting Subprocess 1.0 +swift-crypto is tools-version 5.9 / macOS 10.15, so nothing forces a tools-version bump. Adopting Subprocess 1.0 would rewrite `ShellRunner`, `ScriptRunner`, `PackFetcher`, `ClaudeIntegration` and `UpdateChecker` against an async API, push `async` through `ParsableCommand.run()`, and require re-verifying the PTY story — a rewrite of the process layer in the middle of a port, for no benefit to this goal. The @@ -362,36 +346,39 @@ is updated once. The tap formula keeps pointing at the macOS tarball; Linux ship assets, because Linuxbrew is not how a Linux user is expected to install mcs. **Consequence: the release is now all-or-nothing across platforms** — a Linux build or test failure blocks the macOS tarball, the GitHub release and the tap update, which could not happen before. That -is the intended trade (a half-published release is worse than a late one), and it is why `test-linux` -runs before `build-linux` rather than after the release is cut. +is the intended trade (a half-published release is worse than a late one), and it is why every test +leg runs before any build leg rather than after the release is cut. + +**Recovery.** `auto-release.yml` pushes the tag before `release.yml` runs, and update checks read +tags, not releases. So while a release is building, and indefinitely if a leg fails, every install +sees a version whose assets do not exist yet: the Linux upgrade command fails its download and +removes its staged file, and `brew upgrade` finds nothing. Re-run the failed jobs +(`gh run rerun --failed`); the next nightly release comes only when `Sources/` changes. ### D11 — The home directory comes from `$HOME`, falling back to the passwd entry **Context.** Every path mcs owns hangs off one home directory. Foundation's `NSHomeDirectory()` resolves the passwd entry first on Darwin and corelibs alike, and consults `$HOME` only when there is no passwd entry (`CFFIXED_USER_HOME`, when set, replaces both). So on *every* platform a -`HOME=… mcs …` invocation — what a container, a CI runner and `sudo -H` all set up — silently wrote +`HOME= mcs …` invocation, a `sudo` that keeps `HOME`, or a test sandbox silently wrote to the real user's `~/.claude`, `~/.mcs` and global gitignore. Verified on macOS with a compiled probe: `HOME=/tmp/x` still returned the passwd home. -**Options considered.** (a) Leave it and document it. (b) Prefer a non-empty `$HOME`, falling back +**Options considered.** (a) Leave it and document it. (b) Prefer an absolute `$HOME`, falling back to `NSHomeDirectory()`. (c) Also route the two tilde-expansion sites through the same home. **Decision: (b) and (c).** This is a deliberate behavior change on macOS as much as on Linux: an invocation whose `$HOME` differs from the passwd home — `sudo` with `env_keep`, a launchd agent, a -sandboxed test — now reads and writes under `$HOME`. `Environment.defaultHomeDirectory(environment:)` -takes the environment as a parameter so it can be tested as a pure function — swift-testing runs in -parallel, so a test that called `setenv` would leak into every other test in flight. -`Homebrew.allPrefixes` uses the same helper, so the single-user Linuxbrew prefix cannot drift from -it. The two places that expand a tilde a *user* typed — a path given to `mcs pack add`, and a pack's +sandboxed test — now reads and writes under `$HOME`. `Homebrew.allPrefixes(home:)` takes the +injected home, so the single-user Linuxbrew prefix cannot drift from it. The two places that expand a tilde a *user* typed — a path given to `mcs pack add`, and a pack's doctor `path:` — go through `Environment.expandingTilde(_:)` rather than Foundation's `expandingTildeInPath`, so `~/pack` and `~/.mcs` can never name different homes. -**Consequences.** `HOME= mcs …` is now a working sandbox for the whole binary. A `$HOME` that -points at a missing directory is not validated; `mcs sync --global` creates the tree, `mcs doctor` -reports everything under it as missing with the path shown. `~user/…` is not expanded — neither -site ever accepted it. - +**Consequences.** `HOME= mcs …` is now a working sandbox for the whole binary; the smoke run +relies on it. A relative `$HOME` is ignored. One that points at a missing directory is not validated: +`mcs sync --global` creates the tree, and `mcs doctor` reports everything under it as missing with +the path shown. Only `~` and `~/…` are expanded. `mcs pack add ~user/…` used to work through +Foundation's expansion and now resolves as a relative path. ### D12 — The Linux upgrade command is a staged, verified tarball swap @@ -403,23 +390,27 @@ there is no package manager to delegate to, and a CLI-only update would otherwis **Options considered.** (a) Emit no command and describe the tarball in prose. (b) Emit a command built from the binary's own path. (c) Suppress CLI update checks on Linux entirely. -**Decision: (b).** `UpdateChecker.cliUpgradeCommands(toVersion:)` reads `/proc/self/exe` — the -kernel's answer, not `argv[0]`, which a caller controls — and builds one command that downloads the -tagged asset for `releaseArch`, writes the tarball member to a *sibling* temp file, runs `--version` -on it, and only then `mv`s it over the target. The rename is same-directory, so it is atomic and -legal while the old binary is still mapped. `sudo sh -c` wraps it only when the directory is not -writable, which keeps the binary's ownership in the common case. +**Decision: (b).** `UpdateChecker.cliUpgrade(toVersion:)` reads `/proc/self/exe` — the kernel's +answer, not `argv[0]`, which a caller controls — and `linuxUpgrade(…)`, a pure function, builds one +command that downloads the tagged asset for `releaseArch`, writes the tarball member to a *sibling* +temp file, runs `--version` on it, and only then `mv`s it over the target. The rename is +same-directory, so it is atomic and legal while the old binary is still mapped. A failed step removes +the staged file. The version comes from a remote tag, so only a plain `X.Y.Z` produces a command, and +every path is single-quoted. When the directory is not writable the command is wrapped in +`sudo sh -c` and handed to the user to run in a terminal, since Claude's shell has no TTY for a +password prompt; otherwise no sudo is used, so a user-local install never ends up root-owned. **Why not (a).** Claude is the one running these commands; handing it prose where every other update is a command makes the CLI update the odd one out and easy to skip. **Why not (c).** A Linux user would never learn a new version exists. -**Consequences.** When the path cannot be resolved the function returns `[]`, and the hook then names -the releases page and asks no question at all — the ask is gated on having something to run. A -truncated download fails `--version` before anything is replaced. The command is only as correct as -the asset naming in `release.yml`, so `build-linux` fails the job if the runner's `uname -m` and the -asset's architecture disagree. +**Consequences.** When no command can be built — no readable `/proc/self/exe`, a replaced binary, a +tag that is not a plain version — the result is `.manual(reason:)`: the terminal and the hook name the +reason and the releases page, and the hook asks no question. A truncated download fails before +anything is replaced. The command is only as correct as the asset naming in `release.yml`, so the +release build fails if the runner's `uname -m` and the asset's architecture disagree. + ## 6. Known limitations - **glibc ≥ 2.35**, because both release binaries are built on Ubuntu 22.04. musl is untested, and @@ -431,12 +422,8 @@ asset's architecture disagree. chain and a musl verification pass. - **`/usr/bin/which` is required**, with the "everything reports not found" symptom described in section 2. -- **No Linuxbrew auto-install**, and **no Claude Code auto-install** on Linux. +- **No Linuxbrew auto-install**, and **no Claude Code auto-install** on either platform (D7). - **`brew:` components are name-sensitive** without Linuxbrew — see D7 decision 3. -- **A `.zsh` hook's missing interpreter is not reported on Linux.** `HookInterpreter` treats `zsh` - as always present, which is true on macOS and false on Debian, Ubuntu and Fedora by default. Packs - that ship zsh hooks should say so; a per-platform set was not worth a sixth home for platform - knowledge. - **`mcs export`'s brew formula hints are empty** without Linuxbrew: `detectFormula` reads symlinks under the Homebrew prefixes. - **The PTY is allocated with a 0×0 window size** — the same on macOS, so full-screen TUIs run inside @@ -469,18 +456,15 @@ import Glibc Three rules keep this from spreading: -1. **Platform-dependent values and control flow live in six places only** — +1. **Platform-dependent values and control flow live in five places only** — `Core/TerminalAttributes.swift`, `Core/Environment.swift`, `Core/Homebrew.swift`, - `Core/Constants.swift`, `Core/ClaudePrerequisite.swift` and `Core/UpdateChecker.swift`. - Everything else calls into them. If a seventh file needs a `#if` around a *value or a branch*, - that is a sign it belongs in one of these. `TerminalAttributes` is on the list without holding a - single `#if`: it owns the termios layout, which differs through `VMIN`/`VTIME`/`tcflag_t` rather - than through a branch. `ClaudePrerequisite` and `UpdateChecker` are on it because what differs - there is *control flow*, not a value: macOS can offer a Homebrew install of Claude Code and a - `brew upgrade` of mcs itself, Linux can do neither, so there is no constant to move. - The import chain above is the one `#if` this rule does not cover: a file that calls libc - directly (`ShellRunner`, `PTYBridge`, `CLIOutput`, `FileLock`, `GlobMatcher`, and - `FileLockTests`) carries it at the top, per D3. It selects the same API from a different module + `Core/Constants.swift` and `Core/UpdateChecker.swift`. Everything else calls into them. If another + file needs a `#if` around a *value or a branch*, that is a sign it belongs in one of these. + `TerminalAttributes` has no branch beyond its import chain: it owns the termios layout, which + differs through `VMIN`/`VTIME`/`tcflag_t`. `UpdateChecker` is there because what differs is + *control flow*, not a value: macOS upgrades mcs through `brew upgrade`, Linux through the tarball + swap in D12. The import chain above is the one `#if` this rule does not cover: a file that calls + libc directly carries it at the top, per D3. It selects the same API from a different module and encodes no platform behaviour. SHA-256 needs no `#if` at all — swift-crypto's `Crypto` serves both platforms, per D1. 2. **A `#if` is for a value or API that genuinely differs**, never for making a diagnostic go away. diff --git a/docs/techpack-schema.md b/docs/techpack-schema.md index dad838af..e3aea905 100644 --- a/docs/techpack-schema.md +++ b/docs/techpack-schema.md @@ -325,7 +325,7 @@ Infers: `type: configuration`, `installAction: gitignoreEntries` | Field | Type | Description | |-------|------|-------------| | `shell` | `String` | Shell command to execute | -| `shellInteractive` | `Bool` | When `true`, allocates a PTY so commands like `sudo` can prompt for passwords securely. Default: `false`. Works the same on macOS and Linux — `forkpty` on both — with a 0×0 window size on both | +| `shellInteractive` | `Bool` | When `true`, allocates a PTY so commands like `sudo` can prompt for passwords securely. Default: `false`. The PTY is allocated only when mcs itself runs in a terminal; otherwise the command runs without one (with a warning), so a prompt sees end of input instead of hanging. Same on macOS and Linux | **Does not infer `type`** — you must provide `type:` explicitly. This is because a shell command could install anything (a brew package, a skill, a tool). @@ -586,7 +586,7 @@ Most components get free doctor checks from their install action — no need to | `brew: node` | Command on `PATH`, else `brew list` for the package. The `PATH` probe uses the last path component, so `owner/tap/formula` looks for `formula` | | `mcp: {command: npx, ...}` | MCP server registered in `~/.claude.json` | | `plugin: "name@org"` | Plugin enabled in settings | -| `hook: {source, dest}` | File exists at destination, plus the interpreter binary resolves (skipped for `bash`/`sh`/`zsh`) | +| `hook: {source, dest}` | File exists at destination, plus the interpreter binary resolves (skipped for `bash`/`sh`) | | `skill: {source, dest}` | Directory exists at destination | | `command: {source, dest}` | File exists at destination | | `agent: {source, dest}` | File exists at destination | From 552a303189be323ad9a86cec27dfdc9b8b1e3d40 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Fri, 9 Oct 2026 19:23:38 +0200 Subject: [PATCH 26/28] Cancel cleanly on Ctrl-C, and stop offering a re-sync that cannot work - Raw mode cleared ICANON and ECHO but not ISIG, so Ctrl-C killed the process before the restore ran and left the terminal with no echo and no cursor; the pickers' existing Ctrl-C branch was unreachable and returned the default, which would have read as consent once the byte arrived - A failing brew check without Homebrew is no longer planned for a re-sync, so doctor --fix shows the manual hint instead of repairing and failing again - A hook-interpreter test names `node` when it is missing; the Swift 6.4 link failure is recorded as a known limitation Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu --- Sources/mcs/Core/CLIOutput.swift | 32 +++++++++++--- Sources/mcs/Core/TerminalAttributes.swift | 8 +++- Sources/mcs/Doctor/CoreDoctorChecks.swift | 6 +++ Sources/mcs/Doctor/DoctorRunner.swift | 2 +- Sources/mcs/TechPack/TechPack.swift | 10 +++++ Tests/MCSTests/CLIOutputTests.swift | 44 +++++++++++++++++++ Tests/MCSTests/CoreDoctorCheckTests.swift | 10 +++++ .../MCSTests/LifecycleIntegrationTests.swift | 11 ++++- Tests/MCSTests/TerminalAttributesTests.swift | 6 ++- docs/linux-support.md | 6 ++- 10 files changed, 122 insertions(+), 13 deletions(-) diff --git a/Sources/mcs/Core/CLIOutput.swift b/Sources/mcs/Core/CLIOutput.swift index 0d22851f..fad5d64c 100644 --- a/Sources/mcs/Core/CLIOutput.swift +++ b/Sources/mcs/Core/CLIOutput.swift @@ -35,6 +35,14 @@ struct InputClosedError: Error, LocalizedError { } } +/// Ctrl-C at a picker. Distinct from `InputClosedError` because the user did answer — the answer +/// is "stop" — and because raw mode clears `ISIG`, so nothing else turns this into an exit. +struct PromptCancelledError: Error, LocalizedError { + var errorDescription: String? { + "Cancelled." + } +} + /// Terminal output with ANSI color support and structured logging. struct CLIOutput { let colorsEnabled: Bool @@ -317,9 +325,13 @@ struct CLIOutput { write("\n") return false - case 0x03, 0x04: // Ctrl+C, Ctrl+D + case 0x03: // Ctrl-C + write("\n") + throw PromptCancelledError() + + case 0x04: // Ctrl-D — end of input, same as the stream closing write("\n") - return defaultValue + throw InputClosedError() default: break @@ -446,9 +458,13 @@ struct CLIOutput { } } - case 0x03, 0x04: // Ctrl+C, Ctrl+D + case 0x03: // Ctrl-C write("\n") - return cursor + throw PromptCancelledError() + + case 0x04: // Ctrl-D — end of input, same as the stream closing + write("\n") + throw InputClosedError() default: break @@ -600,9 +616,13 @@ struct CLIOutput { } } - case 0x03, 0x04: // Ctrl+C, Ctrl+D + case 0x03: // Ctrl-C write("\n") - return collectSelected(from: groups) + throw PromptCancelledError() + + case 0x04: // Ctrl-D — end of input, same as the stream closing + write("\n") + throw InputClosedError() default: break diff --git a/Sources/mcs/Core/TerminalAttributes.swift b/Sources/mcs/Core/TerminalAttributes.swift index a1f2a5b1..558e9b45 100644 --- a/Sources/mcs/Core/TerminalAttributes.swift +++ b/Sources/mcs/Core/TerminalAttributes.swift @@ -20,9 +20,15 @@ enum TerminalAttributes { /// Non-canonical, no-echo attributes: one byte satisfies a read and nothing is echoed, so arrow /// keys reach the picker as they are typed. + /// + /// `ISIG` is cleared too, so Ctrl-C arrives as a byte the picker can act on instead of a + /// SIGINT that kills the process mid-prompt — which would skip the `defer` that restores + /// these attributes and leave the user at a terminal with no echo and no cursor. Ctrl-Z and + /// Ctrl-\ stop being signals for the same reason; the pickers ignore them rather than + /// suspending into a raw terminal. static func rawMode(from attributes: termios) -> termios { var raw = attributes - raw.c_lflag &= ~tcflag_t(ICANON | ECHO) + raw.c_lflag &= ~tcflag_t(ICANON | ECHO | ISIG) setControlCharacter(&raw, VMIN, to: 1) setControlCharacter(&raw, VTIME, to: 0) return raw diff --git a/Sources/mcs/Doctor/CoreDoctorChecks.swift b/Sources/mcs/Doctor/CoreDoctorChecks.swift index 5f724543..65c82f47 100644 --- a/Sources/mcs/Doctor/CoreDoctorChecks.swift +++ b/Sources/mcs/Doctor/CoreDoctorChecks.swift @@ -39,6 +39,12 @@ struct BrewPackageCheck: DoctorCheck { return .fail("not found") } + /// Without Homebrew there is nothing for a re-sync to run, so offering one would promise a + /// repair and then report the same failure. The hint names the system package manager instead. + var isRepairableByResync: Bool { + Homebrew(shell: ShellRunner(environment: environment), environment: environment).isInstalled + } + func fix() -> FixResult { let shell = ShellRunner(environment: environment) guard Homebrew(shell: shell, environment: environment).isInstalled else { diff --git a/Sources/mcs/Doctor/DoctorRunner.swift b/Sources/mcs/Doctor/DoctorRunner.swift index 53037909..f1b30d36 100644 --- a/Sources/mcs/Doctor/DoctorRunner.swift +++ b/Sources/mcs/Doctor/DoctorRunner.swift @@ -750,7 +750,7 @@ struct DoctorRunner { var checksByTarget: [SyncTarget: [any DoctorCheck]] = [:] var hintOnly: [any DoctorCheck] = [] for entry in entries { - guard let target = entry.syncTarget else { + guard let target = entry.syncTarget, entry.check.isRepairableByResync else { hintOnly.append(entry.check) continue } diff --git a/Sources/mcs/TechPack/TechPack.swift b/Sources/mcs/TechPack/TechPack.swift index 67b821c4..5b50cd53 100644 --- a/Sources/mcs/TechPack/TechPack.swift +++ b/Sources/mcs/TechPack/TechPack.swift @@ -90,6 +90,12 @@ protocol DoctorCheck: Sendable { /// `nil` means the check has no fix of its own: `doctor --fix` then re-syncs its scope when /// sync can repair it, and otherwise shows the `.notFixable` hint `fix()` returns. var fixCommandPreview: String? { get } + /// Whether re-syncing this check's scope could actually repair it. Defaults to `true`, since a + /// component-derived check usually fails because its artifact is missing and sync installs it. + /// A check that knows the install cannot succeed — a `brew:` package on a machine with no + /// Homebrew — returns `false` so `doctor --fix` shows its hint instead of offering a re-sync + /// that reports the same failure afterwards. + var isRepairableByResync: Bool { get } func check() -> CheckResult func fix() -> FixResult } @@ -98,6 +104,10 @@ extension DoctorCheck { var fixCommandPreview: String? { nil } + + var isRepairableByResync: Bool { + true + } } enum CheckResult: Equatable { diff --git a/Tests/MCSTests/CLIOutputTests.swift b/Tests/MCSTests/CLIOutputTests.swift index 1a6eb137..1a7f3850 100644 --- a/Tests/MCSTests/CLIOutputTests.swift +++ b/Tests/MCSTests/CLIOutputTests.swift @@ -9,6 +9,50 @@ struct CLIOutputTests { return (fds[0], fds[1]) } + /// `withRawTerminal` degrades to a plain read when `input` is not a tty, so a pipe drives the + /// raw-mode pickers directly — which is the only way to deliver a Ctrl-C byte in a test. + private func feed(_ byte: UInt8) -> (output: CLIOutput, close: () -> Void) { + let (readEnd, writeEnd) = makePipe() + var byte = byte + #expect(write(writeEnd, &byte, 1) == 1) + close(writeEnd) + return (CLIOutput(colorsEnabled: false, input: readEnd), { close(readEnd) }) + } + + @Test("Ctrl-C cancels a yes/no prompt instead of answering it") + func ctrlCCancelsYesNo() { + let (output, cleanup) = feed(0x03) + defer { cleanup() } + + // `default: true` is the dangerous case: a Ctrl-C that returned the default would read as + // consent on prompts like "Proceed with removal?". + #expect(throws: PromptCancelledError.self) { + try output.interactiveYesNo("Proceed?", default: true) + } + } + + @Test("Ctrl-D at a yes/no prompt is an end of input, not an answer") + func ctrlDClosesYesNo() { + let (output, cleanup) = feed(0x04) + defer { cleanup() } + + #expect(throws: InputClosedError.self) { + try output.interactiveYesNo("Proceed?", default: true) + } + } + + @Test("Ctrl-C cancels a single-select picker") + func ctrlCCancelsSingleSelect() { + let (output, cleanup) = feed(0x03) + defer { cleanup() } + + #expect(throws: PromptCancelledError.self) { + try output.interactiveSingleSelect( + title: "Pick", items: [(name: "a", description: ""), (name: "b", description: "")], initialIndex: 0 + ) + } + } + @Test("readByte returns the byte that was written") func readByteReturnsWrittenByte() { let (readEnd, writeEnd) = makePipe() diff --git a/Tests/MCSTests/CoreDoctorCheckTests.swift b/Tests/MCSTests/CoreDoctorCheckTests.swift index 96f560d0..3dd8e144 100644 --- a/Tests/MCSTests/CoreDoctorCheckTests.swift +++ b/Tests/MCSTests/CoreDoctorCheckTests.swift @@ -812,6 +812,16 @@ struct SettingsDriftCheckTests { } struct BrewPackageCheckGuidanceTests { + @Test("A brew package counts as re-syncable only where Homebrew can act") + func resyncOfferedOnlyWithHomebrew() { + let package = "mcs-nonexistent-formula-for-tests" + let check = BrewPackageCheck(name: package, section: "Dependencies", package: package) + + // Branches on the host for the same reason the hint test below does: the brew path is + // resolved once per process, so nothing can fake a machine without Homebrew. + #expect(check.isRepairableByResync == FileManager.default.fileExists(atPath: Environment().brewPath)) + } + @Test("The fix for a missing package never advertises a command that cannot work") func fixDoesNotPointAtSyncWhenBrewIsAbsent() { let package = "mcs-nonexistent-formula-for-tests" diff --git a/Tests/MCSTests/LifecycleIntegrationTests.swift b/Tests/MCSTests/LifecycleIntegrationTests.swift index fa9c0f7d..4d882473 100644 --- a/Tests/MCSTests/LifecycleIntegrationTests.swift +++ b/Tests/MCSTests/LifecycleIntegrationTests.swift @@ -2784,8 +2784,15 @@ struct HookInterpreterLifecycleTests { #expect(artifacts.hookCommands.contains(command), "state should record '\(command)'") } - // 4. Doctor joins the recorded commands back to their components without complaint - #expect(try bed.runDoctor(registry: registry).issues == 0) + // 4. Doctor joins the recorded commands back to their components without complaint. + // `HookInterpreterCheck` resolves each interpreter on PATH, so a machine without `node` + // fails here for a reason that has nothing to do with the composition under test — say so + // rather than leaving a bare count. + let summary = try bed.runDoctor(registry: registry) + let nodeHint = ShellRunner(environment: Environment()).commandExists("node") + ? "" + : " ('node' is not on PATH, which the declared and inferred interpreters need)" + #expect(summary.issues == 0, "doctor reported \(summary.issues) issue(s)\(nodeHint)") // 5. Deselecting the pack removes the files and every hook entry, interpreter regardless try configurator.configure(packs: [], confirmRemovals: false) diff --git a/Tests/MCSTests/TerminalAttributesTests.swift b/Tests/MCSTests/TerminalAttributesTests.swift index 0a734101..2f2f7172 100644 --- a/Tests/MCSTests/TerminalAttributesTests.swift +++ b/Tests/MCSTests/TerminalAttributesTests.swift @@ -7,7 +7,7 @@ import Glibc import Testing struct TerminalAttributesTests { - @Test("rawMode clears ICANON and ECHO, keeps ISIG, and sets VMIN/VTIME at the platform's indices") + @Test("rawMode clears ICANON, ECHO and ISIG, and sets VMIN/VTIME at the platform's indices") func rawModeFlagsAndControlCharacters() { var original = termios() original.c_lflag = tcflag_t(ICANON | ECHO | ISIG) @@ -19,7 +19,9 @@ struct TerminalAttributesTests { #expect(raw.c_lflag & tcflag_t(ICANON) == 0) #expect(raw.c_lflag & tcflag_t(ECHO) == 0) - #expect(raw.c_lflag & tcflag_t(ISIG) != 0, "Ctrl-C must still generate SIGINT") + // Ctrl-C has to arrive as a byte: a SIGINT would kill the process before the picker could + // restore these attributes, leaving the terminal with no echo and no cursor. + #expect(raw.c_lflag & tcflag_t(ISIG) == 0, "Ctrl-C must reach the picker instead of signalling") #expect(controlCharacter(raw, VMIN) == 1) #expect(controlCharacter(raw, VTIME) == 0) } diff --git a/docs/linux-support.md b/docs/linux-support.md index 0df75761..4f72ead4 100644 --- a/docs/linux-support.md +++ b/docs/linux-support.md @@ -107,7 +107,7 @@ others were run by hand on Ubuntu 24.04 when the port landed. |---|---|---|---| | `mcs sync` (project) | supported | verified | `mcs sync --pack linux-probe` in a git project: components installed, `settings.local.json` composed, `CLAUDE.local.md` generated, `.mcs-project` written. | | `mcs sync --global` | supported | verified | `mcs sync --global --pack git-probe`: artifacts under `~/.claude/`, `settings.json` composed, `global-state.json` written. | -| Raw-mode pickers | supported | verified | Driven under a real PTY: `↓` moved the cursor, `Space` toggled, `Enter` applied; in-place redraw and cursor hide/show correct. A hangup mid-prompt throws, so the command exits non-zero instead of acting on an unconfirmed choice. | +| Raw-mode pickers | supported | verified | Driven under a real PTY: `↓` moved the cursor, `Space` toggled, `Enter` applied; in-place redraw and cursor hide/show correct. A hangup mid-prompt throws, so the command exits non-zero instead of acting on an unconfirmed choice. Ctrl-C is a byte, not a signal (`ISIG` is cleared), so it cancels the prompt and the terminal is restored on the way out | | Non-TTY fallback picker | supported | verified | stdin a PTY, stdout a pipe: numeric toggle + `Enter`, colours disabled. | | `shell:` components † | supported | verified | `shell: touch ` created the marker. | | `shellInteractive: true` (PTY/sudo) † | supported | verified | Under a real terminal the `forkpty` path ran the command. Off a terminal (stdin `/dev/null`, CI) it runs without a PTY, so a prompt sees end of input instead of hanging; smoke asserts this under a time limit. The PTY is allocated with a default 0×0 window size on both platforms. | @@ -415,6 +415,10 @@ release build fails if the runner's `uname -m` and the asset's architecture disa - **glibc ≥ 2.35**, because both release binaries are built on Ubuntu 22.04. musl is untested, and there is no `canImport(Musl)` branch. +- **Building from source on Swift 6.4 needs `--build-system native`.** With 6.4's default build + system, `swift build -c release --static-swift-stdlib` fails to link against the bundled + Foundation (`undefined reference to 'CFCharacterSetGetPredefined'`, and a `Synchronization` + mutex symbol). CI pins 6.2, so the release artifacts are unaffected until the pin moves. - **The binary is ~95 MB and needs `libstdc++6`.** `--static-swift-stdlib` links the *Swift* runtime statically only; `ldd` still shows `libstdc++.so.6`, `libgcc_s.so.1`, `libm`, `libc` and `ld-linux`. A minimal container needs `apt-get install -y libstdc++6`. A fully static binary would From fcbfcde296be86b3c6100acf9e14074fa609c0f1 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Fri, 9 Oct 2026 19:30:11 +0200 Subject: [PATCH 27/28] Compare merged settings values without the ObjC runtime - AnyObject.isEqual resolves only where Foundation bridges to NSObject, so the per-sub-key merge did not compile on Glibc - Canonical JSON bytes settle it instead, which also stops `true` and `1` counting as the same value; either way an unequal result keeps the user's Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu --- Sources/mcs/Core/Settings.swift | 14 +++++++++++++- Tests/MCSTests/SettingsMergeTests.swift | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/Sources/mcs/Core/Settings.swift b/Sources/mcs/Core/Settings.swift index 446f0f0d..0d1b1b7b 100644 --- a/Sources/mcs/Core/Settings.swift +++ b/Sources/mcs/Core/Settings.swift @@ -318,8 +318,20 @@ struct Settings: Codable { return dict.keys.sorted().map { "\(key).\($0)" } } + /// Structural JSON equality without the ObjC runtime: `AnyObject.isEqual` resolves only where + /// Foundation bridges to `NSObject`, so it does not compile on Glibc. Both sides come from + /// `JSONSerialization`, so canonical bytes settle it — `.sortedKeys` makes key order + /// irrelevant, and nesting is covered for free. A value that cannot be re-serialized counts as + /// unequal, which is the conservative direction at both call sites: the existing value wins + /// and nothing the user wrote is overwritten. private static func jsonEqual(_ lhs: Any, _ rhs: Any) -> Bool { - (lhs as AnyObject).isEqual(rhs) + let canonical: JSONSerialization.WritingOptions = [.sortedKeys, .fragmentsAllowed] + guard let left = try? JSONSerialization.data(withJSONObject: lhs, options: canonical), + let right = try? JSONSerialization.data(withJSONObject: rhs, options: canonical) + else { + return false + } + return left == right } private static func jsonFragmentsEqual(_ lhs: Data, _ rhs: Data) -> Bool { diff --git a/Tests/MCSTests/SettingsMergeTests.swift b/Tests/MCSTests/SettingsMergeTests.swift index 889cdea4..5d9d83d4 100644 --- a/Tests/MCSTests/SettingsMergeTests.swift +++ b/Tests/MCSTests/SettingsMergeTests.swift @@ -202,6 +202,29 @@ struct SettingsMergeTests { #expect(report.skippedKeyPaths == ["env.CLASH", "model"]) } + @Test("Sub-key equality is structural: key order is irrelevant, a nested difference is not") + func subKeyEqualityIsStructural() throws { + var base = Settings() + base.extraJSON["env"] = try JSONSerialization.data(withJSONObject: [ + // Same contents as the pack's, written in the other order. + "REORDERED": ["b": 2, "a": 1], + "NESTED": ["outer": ["inner": "user"]], + "TYPED": true, + ]) + var other = Settings() + other.extraJSON["env"] = try JSONSerialization.data(withJSONObject: [ + "REORDERED": ["a": 1, "b": 2], + "NESTED": ["outer": ["inner": "pack"]], + "TYPED": 1, + ]) + + let report = base.merge(with: other) + + // Only the two that genuinely differ are conflicts; a reordered object is the same value. + #expect(report.skippedKeyPaths == ["env.NESTED", "env.TYPED"]) + #expect(report.landedKeyPaths.isEmpty) + } + @Test("Hooks merge across different events") func hooksMergeDifferentEvents() { var base = Settings(hooks: [ From e64aec74a708f637b0e4985aa5caa68485d12388 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Fri, 9 Oct 2026 19:37:17 +0200 Subject: [PATCH 28/28] Name the ObjC-bridging trap in the platform rule - AnyObject.isEqual compiled on macOS and failed only on Linux, so the macOS jobs could not catch it; the rule now says where that class of call goes wrong and what replaces it for JSON values Claude-Session: https://claude.ai/code/session_014tZGxbvJUwXqd6fU1S3fXu --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9cfc42de..a28a85fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -201,7 +201,7 @@ import Darwin import Glibc #endif ``` - Platform-dependent **values and control flow** live only in `Core/TerminalAttributes.swift`, `Core/Environment.swift`, `Core/Homebrew.swift`, `Core/Constants.swift` and `Core/UpdateChecker.swift` — everything else calls into them. `TerminalAttributes` has no branch beyond its import chain: it owns the termios layout, which differs per platform through `VMIN`/`VTIME`/`tcflag_t`. `UpdateChecker` is there because what differs is control flow, not a value: macOS upgrades mcs through `brew upgrade`, Linux through a staged tarball swap. The `#if canImport(Darwin) / #elseif canImport(Glibc)` import chain at the top of a file that calls libc directly (`ShellRunner`, `PTYBridge`, `CLIOutput`, `FileLock`, `GlobMatcher`, `TerminalAttributes`) is not platform knowledge in this sense — it selects the same API from a different module — and is allowed wherever it is needed. SHA-256 comes from swift-crypto's `Crypto` on both platforms; it re-exports CryptoKit on Darwin, so no `#if` is needed to hash. A `#if` is for a value or API that genuinely differs, never to make a diagnostic go away; the same goes for `_ =`. Several Foundation methods are `@discardableResult` on Darwin and not on Linux — use the result, it always means something. A test gated out on one platform is a coverage regression: give the `#else` branch the equivalent assertion. Rationale and the compatibility matrix are in `docs/linux-support.md` + Platform-dependent **values and control flow** live only in `Core/TerminalAttributes.swift`, `Core/Environment.swift`, `Core/Homebrew.swift`, `Core/Constants.swift` and `Core/UpdateChecker.swift` — everything else calls into them. `TerminalAttributes` has no branch beyond its import chain: it owns the termios layout, which differs per platform through `VMIN`/`VTIME`/`tcflag_t`. `UpdateChecker` is there because what differs is control flow, not a value: macOS upgrades mcs through `brew upgrade`, Linux through a staged tarball swap. The `#if canImport(Darwin) / #elseif canImport(Glibc)` import chain at the top of a file that calls libc directly (`ShellRunner`, `PTYBridge`, `CLIOutput`, `FileLock`, `GlobMatcher`, `TerminalAttributes`) is not platform knowledge in this sense — it selects the same API from a different module — and is allowed wherever it is needed. SHA-256 comes from swift-crypto's `Crypto` on both platforms; it re-exports CryptoKit on Darwin, so no `#if` is needed to hash. A `#if` is for a value or API that genuinely differs, never to make a diagnostic go away; the same goes for `_ =`. Several Foundation methods are `@discardableResult` on Darwin and not on Linux — use the result, it always means something. Anything that reaches the ObjC runtime resolves only on Darwin and fails to compile on Glibc, so it cannot be caught by the macOS jobs: `(x as AnyObject).isEqual(y)` is the one that has bitten, and the portable replacement for JSON values is comparing canonical bytes (`JSONSerialization` with `.sortedKeys`). A test gated out on one platform is a coverage regression: give the `#else` branch the equivalent assertion. Rationale and the compatibility matrix are in `docs/linux-support.md` - **Comments carry the non-obvious "why", not a narration of the code** — don't restate the line below, don't describe what the code used to do. If the code already says it, delete the comment; if the rationale needs more than a line or two, it belongs in the issue or a memory. State a given rationale once, at the site that owns it, rather than repeating it at every call site ## Testing