diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 10699f64..9dfb908d 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`) +- [ ] `.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..c951eaf4 --- /dev/null +++ b/.github/actions/mcs-smoke/action.yml @@ -0,0 +1,143 @@ +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: + # 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: | + 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. 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: | + mkdir -p "$HOME/fixture-pack/skills/greet" "$RUNNER_TEMP/project" + cat > "$HOME/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 + - 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 > "$HOME/fixture-pack/skills/greet/SKILL.md" <<'SKILL' + --- + name: greet + description: Says __GREETING__ + --- + Says __GREETING__. + SKILL + cat > "$RUNNER_TEMP/project/mcs.yaml" <<'BOOT' + schemaVersion: 1 + packs: + - source: ~/fixture-pack + values: + GREETING: hei + BOOT + + - name: mcs pack add (tilde path), pack list --json, pack validate + shell: bash + working-directory: ${{ runner.temp }}/project + run: | + # 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 + run: | + git init -q . + 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. + 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 + run: | + "$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 + run: | + "$MCS" doctor + "$MCS" doctor --fix --yes + + - name: mcs update --trust-all and check-updates + shell: bash + working-directory: ${{ runner.temp }}/project + run: | + "$MCS" update --project --trust-all + "$MCS" check-updates --json + + # 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 + run: | + "$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 + run: | + "$MCS" export "$RUNNER_TEMP/exported" --identifier exported --non-interactive + test -f "$RUNNER_TEMP/exported/techpack.yaml" + "$MCS" cleanup --force + + - name: mcs pack remove + shell: bash + working-directory: ${{ runner.temp }}/project + run: | + "$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 new file mode 100644 index 00000000..f13615ff --- /dev/null +++ b/.github/actions/setup-swift-linux/action.yml @@ -0,0 +1,99 @@ +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. + 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 + 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-${{ 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. + # 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 + # 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 + + - 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, 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 }} + # 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" + 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" + + 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 + + - 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/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/pr-checks.yml b/.github/workflows/pr-checks.yml index 8273686f..126b321f 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -10,17 +10,41 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +env: + # 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: name: Lint + # 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 + 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 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 + # later verdict untrustworthy. + - name: Verify linter versions run: | - brew install swiftformat swiftlint || true - brew upgrade swiftformat swiftlint || true + swiftformat --version | grep -qx "$SWIFTFORMAT_VERSION" + swiftlint --version | grep -qx "$SWIFTLINT_VERSION" - name: SwiftFormat run: swiftformat --lint --strict . @@ -28,53 +52,80 @@ jobs: - name: SwiftLint run: swiftlint --strict --reporter github-actions-logging - build-and-test: - name: Build & Test (${{ matrix.os }}) + # Each entry's name is a required status check, so it is spelled out rather than derived. + test: + name: ${{ matrix.name }} needs: lint - runs-on: ${{ matrix.os }} + runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: - os: [macos-15, macos-26] + 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 - - 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 }}- + # 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. + - if: runner.os == 'Linux' + uses: ./.github/actions/setup-swift-linux - - name: Swift version - run: swift --version - - - name: Resolve packages - run: swift package resolve + - uses: ./.github/actions/spm-deps - name: Test - run: swift test + env: + TEST_FLAGS: ${{ matrix.test-flags }} + run: | + read -ra FLAGS <<< "$TEST_FLAGS" + swift test "${FLAGS[@]}" - build-universal: - name: Universal Binary + # 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: macos-latest + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + 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 - - uses: actions/cache@v6 + - if: runner.os == 'Linux' + uses: ./.github/actions/setup-swift-linux + + - uses: ./.github/actions/spm-deps + + - name: Build release binary + env: + BUILD_FLAGS: ${{ matrix.build-flags }} + run: | + read -ra FLAGS <<< "$BUILD_FLAGS" + swift build -c release "${FLAGS[@]}" + echo "MCS=$(swift build -c release "${FLAGS[@]}" --show-bin-path)/mcs" >> "$GITHUB_ENV" + + - uses: ./.github/actions/mcs-smoke with: - path: | - .build/checkouts - .build/repositories - .build/workspace-state.json - key: spm-${{ runner.os }}-${{ hashFiles('Package.swift') }} - restore-keys: spm-${{ runner.os }}- - - - name: Resolve packages - run: swift package resolve - - - name: Build universal binary - run: swift build -c release --arch arm64 --arch x86_64 + mcs: ${{ env.MCS }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5f92c2eb..7f75ffae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,8 +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 + contents: read concurrency: group: release @@ -64,89 +66,137 @@ jobs: steps: - uses: actions/checkout@v7 - - uses: actions/cache@v6 - with: - path: | - .build/checkouts - .build/repositories - .build/workspace-state.json - key: spm-${{ runner.os }}-${{ hashFiles('Package.swift') }} - restore-keys: spm-${{ runner.os }}- - - - name: Resolve packages - run: swift package resolve + - uses: ./.github/actions/spm-deps test: - name: Test + name: Test (${{ matrix.runner }}) needs: resolve - runs-on: macos-latest + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + 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: actions/cache/restore@v6 + - if: runner.os == 'Linux' + uses: ./.github/actions/setup-swift-linux + + - uses: ./.github/actions/spm-deps with: - path: | - .build/checkouts - .build/repositories - .build/workspace-state.json - key: spm-${{ runner.os }}-${{ hashFiles('Package.swift') }} - restore-keys: spm-${{ runner.os }}- + restore-only: 'true' - name: Test - run: swift test - - build-and-release: - name: Build and Release - needs: [test, release-notes] - runs-on: macos-latest + env: + TEST_FLAGS: ${{ matrix.test-flags }} + run: | + read -ra FLAGS <<< "$TEST_FLAGS" + swift test "${FLAGS[@]}" + + 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: actions/cache/restore@v6 + - if: runner.os == 'Linux' + uses: ./.github/actions/setup-swift-linux + + - uses: ./.github/actions/spm-deps with: - path: | - .build/checkouts - .build/repositories - .build/workspace-state.json - key: spm-${{ runner.os }}-${{ hashFiles('Package.swift') }} - restore-keys: spm-${{ runner.os }}- - - - name: Build universal binary + restore-only: 'true' + + - name: Build release binary + env: + BUILD_FLAGS: ${{ matrix.build-flags }} 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" + 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 + # 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}" - - name: Prepare artifacts + - name: Prepare artifact + env: + TAG_NAME: ${{ github.ref_name }} + ASSET: ${{ matrix.asset }} + run: tar -czf "mcs-${TAG_NAME}-${ASSET}.tar.gz" -C "${BIN_DIR}" mcs + + - uses: actions/upload-artifact@v7 + with: + name: mcs-${{ matrix.asset }} + path: mcs-${{ github.ref_name }}-${{ matrix.asset }}.tar.gz + if-no-files-found: error + + release: + name: Release + needs: [build, release-notes] + runs-on: ubuntu-latest + permissions: + contents: write + 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 +205,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" @@ -167,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" diff --git a/CLAUDE.md b/CLAUDE.md index 04a08dc1..a28a85fb 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 -- `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`) +- `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; `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`) @@ -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) @@ -163,9 +167,12 @@ 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'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 +`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. @@ -186,6 +193,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` 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 @@ -196,6 +212,7 @@ swiftlint --fix - **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 `.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/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/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/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..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 ensureClaudeCLI(shell: ctx.shell, environment: ctx.env, output: ctx.output) else { + guard ensureClaudeCLI(shell: ctx.shell, 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 6227cd2b..57be52d3 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) @@ -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..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 ensureClaudeCLI(shell: shell, environment: env, output: output) else { + guard ensureClaudeCLI(shell: shell, 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..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 ensureClaudeCLI(shell: shell, environment: env, output: output) else { + guard ensureClaudeCLI(shell: shell, 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 c53c6ebc..fad5d64c 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 } @@ -22,6 +26,23 @@ 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." + } +} + +/// 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 @@ -36,11 +57,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 @@ -51,6 +75,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`) @@ -91,7 +116,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 @@ -232,9 +257,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) } @@ -260,14 +285,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 { - let byte = readByte() + let byte = try nextKey() switch byte { case 0x0A, 0x0D, 0x20: // Enter or Space — confirm @@ -300,9 +325,13 @@ struct CLIOutput { write("\n") return false - case 0x03, 0x04: // Ctrl+C, Ctrl+D + case 0x03: // Ctrl-C write("\n") - return defaultValue + throw PromptCancelledError() + + case 0x04: // Ctrl-D — end of input, same as the stream closing + write("\n") + throw InputClosedError() default: break @@ -368,9 +397,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) } @@ -387,28 +416,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 { - let byte = readByte() + let byte = try nextKey() switch byte { case 0x0A, 0x0D, 0x20: // Enter or Space — confirm selection @@ -429,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 @@ -523,7 +556,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 { @@ -535,13 +568,13 @@ struct CLIOutput { return collectSelected(from: groups) } - return withRawTerminal { + return try withRawTerminal { var cursor = 0 renderInteractiveList(groups: groups, cursor: cursor) while true { - let byte = readByte() + let byte = try nextKey() switch byte { case 0x0A, 0x0D: // Enter @@ -583,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 @@ -685,12 +722,27 @@ struct CLIOutput { write(output) } - private func readByte() -> UInt8 { - var byte: UInt8 = 0 - _ = Darwin.read(STDIN_FILENO, &byte, 1) + /// 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 `input`, or `nil` at end of input or on a read error. + func readByte() -> UInt8? { + var byte: UInt8 = 0 + 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. /// Returns the arrow byte (0x41=Up, 0x42=Down, 0x43=Right, 0x44=Left), or nil if not a CSI sequence. private func readCSIArrowKey() -> UInt8? { @@ -700,20 +752,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) - var raw = original - raw.c_lflag &= ~UInt(ICANON | ECHO) - raw.c_cc.16 = 1 // VMIN = 1 - raw.c_cc.17 = 0 // VTIME = 0 - tcsetattr(STDIN_FILENO, TCSANOW, &raw) + guard tcgetattr(input, &original) == 0 else { return try body() } + var raw = TerminalAttributes.rawMode(from: original) + 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 ae803dc3..a75da3b4 100644 --- a/Sources/mcs/Core/ClaudePrerequisite.swift +++ b/Sources/mcs/Core/ClaudePrerequisite.swift @@ -1,44 +1,18 @@ import Foundation -/// Verify that the Claude Code CLI is available, offering to install via Homebrew if missing. +/// 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. +/// 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 -) -> 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.") - - let brew = Homebrew(shell: shell, environment: environment) - guard brew.isInstalled else { - output.plain(" Install it manually: https://docs.anthropic.com/en/docs/claude-code") - 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") - 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))) - } - output.plain(" Install it manually: https://docs.anthropic.com/en/docs/claude-code") + 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(" Other methods (Homebrew, npm, apt, dnf): https://code.claude.com/docs/en/setup") return false } 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/Environment.swift b/Sources/mcs/Core/Environment.swift index f1b12c37..980b1570 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,54 @@ 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 = Homebrew.defaultPrefix 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, 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 { + // A relative `$HOME` would resolve against whatever directory mcs was launched from. + guard let home = environment["HOME"], home.hasPrefix("/") 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 == "~" || path == "~/" { + return homeDirectory.path + } + guard path.hasPrefix("~/") else { return path } + return homeDirectory.appendingPathComponent(String(path.dropFirst(2))).path + } + /// Directory where external tech pack checkouts live (`~/.mcs/packs/`). var packsDirectory: URL { mcsDirectory.appendingPathComponent(Constants.ExternalPacks.packsDirectory) 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/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/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/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/Homebrew.swift b/Sources/mcs/Core/Homebrew.swift index c471ff23..5d3c6236 100644 --- a/Sources/mcs/Core/Homebrew.swift +++ b/Sources/mcs/Core/Homebrew.swift @@ -2,8 +2,27 @@ 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"] + /// 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 func allPrefixes(home: URL) -> [String] { + #if canImport(Darwin) + ["/opt/homebrew", "/usr/local"] + #else + [defaultPrefix, home.appendingPathComponent(".linuxbrew").path] + #endif + } let shell: any ShellRunning let environment: Environment @@ -56,6 +75,18 @@ struct Homebrew { shell.run(environment.brewPath, arguments: ["uninstall", name]) } + /// 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)" + #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. @@ -63,10 +94,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/Locked.swift b/Sources/mcs/Core/Locked.swift new file mode 100644 index 00000000..a0f3b6e9 --- /dev/null +++ b/Sources/mcs/Core/Locked.swift @@ -0,0 +1,19 @@ +import Foundation + +/// 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 + + 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/Core/PTYBridge.swift b/Sources/mcs/Core/PTYBridge.swift new file mode 100644 index 00000000..868e1d0f --- /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 +/// reports `POLLNVAL` for a `/dev/null` stdin). And a hung-up descriptor can still carry buffered +/// bytes, so readable data is always drained before a descriptor is dropped or the bridge ends. +enum PTYBridge { + /// What to do with the user's terminal this iteration. + enum StdinAction { + /// Bytes are waiting; forward them to the child. + case forward + /// Nothing to read and nothing coming; stop polling the descriptor. + case drop + case idle + } + + /// What to do with the child's PTY this iteration. + 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. + case close + case idle + } + + static func stdinAction(revents: Int16) -> 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(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/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/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/Core/ShellRunner.swift b/Sources/mcs/Core/ShellRunner.swift index dacb9f51..42f15be5 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,13 @@ extension ShellRunning { /// Runs shell commands and captures output. struct ShellRunner: ShellRunning { + /// 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 /// Check if a command exists on PATH. @@ -194,19 +206,26 @@ 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) } } - let argv: [UnsafeMutablePointer?] = ([executable] + arguments).map { strdup($0) } + [nil] defer { argv.compactMap(\.self).forEach { free($0) } } - - // 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 = workingDirectory.flatMap { strdup($0) } + defer { workingDirectoryPath.map { free($0) } } + + // dropLast() skips the NULL terminator each vector deliberately ends with. + 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)") + } // Save the terminal's current attributes so we can restore them after. var originalTermios = termios() @@ -226,7 +245,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 +254,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,22 +298,35 @@ 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 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 { - // 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 { + 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/TerminalAttributes.swift b/Sources/mcs/Core/TerminalAttributes.swift new file mode 100644 index 00000000..558e9b45 --- /dev/null +++ b/Sources/mcs/Core/TerminalAttributes.swift @@ -0,0 +1,36 @@ +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +/// 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 +/// 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 { + 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 + } + } + + /// 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 | ISIG) + setControlCharacter(&raw, VMIN, to: 1) + setControlCharacter(&raw, VTIME, to: 0) + return raw + } +} diff --git a/Sources/mcs/Core/UpdateChecker.swift b/Sources/mcs/Core/UpdateChecker.swift index d8a700f7..c9bb3eba 100644 --- a/Sources/mcs/Core/UpdateChecker.swift +++ b/Sources/mcs/Core/UpdateChecker.swift @@ -25,7 +25,89 @@ struct UpdateChecker { static let hookTimeout: Int = 30 static let hookStatusMessage = "Checking for updates..." - static let cliUpgradeCommands = ["brew update", "brew upgrade \(Constants.MCSRepo.brewFormula)"] + /// 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) + .commands(["brew update", "brew upgrade \(Constants.MCSRepo.brewFormula)"]) + #else + 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 + 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) + "aarch64" + #else + "x86_64" + #endif + } + + /// Where this process's binary lives, read from the kernel rather than `argv[0]`, which a + /// caller controls. + static func installedBinaryPath() throws -> String { + try FileManager.default.destinationOfSymbolicLink(atPath: "/proc/self/exe") + } + static let manualPackUpdateCommand = "mcs update --all-projects" static let hookPackUpdateCommand = "mcs update -a --trust-all" @@ -581,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, @@ -600,10 +684,15 @@ 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 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 { let noun = result.packUpdates.count == 1 ? "pack update" : "pack updates" @@ -623,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] = [] @@ -636,7 +726,19 @@ struct UpdateChecker { lines.append( "- New mcs version \(cli.latestVersion) is available (installed: \(cli.currentVersion))." ) - commands += cliUpgradeCommands + switch upgrade { + case let .commands(upgradeCommands): + commands += upgradeCommands + case let .runYourself(command): + lines.append( + " 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 + } } let hasPackUpdates = !result.packUpdates.isEmpty if hasPackUpdates { @@ -647,6 +749,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/Sources/mcs/Doctor/CoreDoctorChecks.swift b/Sources/mcs/Doctor/CoreDoctorChecks.swift index 737829cd..65c82f47 100644 --- a/Sources/mcs/Doctor/CoreDoctorChecks.swift +++ b/Sources/mcs/Doctor/CoreDoctorChecks.swift @@ -39,8 +39,18 @@ 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 { - .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/Doctor/DoctorRunner.swift b/Sources/mcs/Doctor/DoctorRunner.swift index 988463ae..f1b30d36 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, !ensureClaudeCLI(shell: shell, output: output) { for check in resyncs.flatMap(\.checks) { docFixFailed(check.name, "re-sync needs the Claude Code CLI") } @@ -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/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/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 0da5b286..58e1dc31 100644 --- a/Sources/mcs/ExternalPack/ExternalDoctorCheck.swift +++ b/Sources/mcs/ExternalPack/ExternalDoctorCheck.swift @@ -81,7 +81,10 @@ struct ExternalCommandExistsCheck: DoctorCheck { func fix() -> FixResult { guard let fixCommand else { - return .notFixable("Run 'mcs sync' to install dependencies") + // 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" + ) } let result = scriptRunner.runCommand(fixCommand) if result.succeeded { @@ -100,6 +103,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 +131,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 +161,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 +193,7 @@ struct ExternalFileNotContainsCheck: ScopedPathCheck { let pattern: String let scope: ExternalDoctorCheckScope let projectRoot: URL? + var environment: Environment = .init() func check() -> CheckResult { let resolved: String @@ -477,7 +484,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 @@ -512,7 +519,8 @@ enum ExternalDoctorCheckFactory { section: section, path: path, scope: scope, - projectRoot: projectRoot + projectRoot: projectRoot, + environment: environment ) case .directoryExists: @@ -527,7 +535,8 @@ enum ExternalDoctorCheckFactory { section: section, path: path, scope: scope, - projectRoot: projectRoot + projectRoot: projectRoot, + environment: environment ) case .fileContains: @@ -545,7 +554,8 @@ enum ExternalDoctorCheckFactory { path: path, pattern: pattern, scope: scope, - projectRoot: projectRoot + projectRoot: projectRoot, + environment: environment ) case .fileNotContains: @@ -563,7 +573,8 @@ enum ExternalDoctorCheckFactory { path: path, pattern: pattern, scope: scope, - projectRoot: projectRoot + projectRoot: projectRoot, + environment: environment ) case .shellScript: @@ -635,6 +646,7 @@ protocol ScopedPathCheck: DoctorCheck { var path: String { get } var scope: ExternalDoctorCheckScope { get } var projectRoot: URL? { get } + var environment: Environment { get } } enum PathResolveResult { @@ -647,7 +659,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 +753,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/ExternalPack/PackSourceResolver.swift b/Sources/mcs/ExternalPack/PackSourceResolver.swift index 82d4cd43..100a2b48 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_.-]*$"# + let environment: Environment + 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/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/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/Sources/mcs/Sync/ComponentExecutor.swift b/Sources/mcs/Sync/ComponentExecutor.swift index f85cd71d..7c41477e 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) @@ -234,12 +234,11 @@ struct ComponentExecutor { withIntermediateDirectories: true ) - var isDir: ObjCBool = false - fm.fileExists(atPath: source.path, isDirectory: &isDir) + let sourceIsDirectory = Self.isDirectory(source) 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) @@ -317,13 +316,12 @@ struct ComponentExecutor { withIntermediateDirectories: true ) - var isDir: ObjCBool = false - fm.fileExists(atPath: source.path, isDirectory: &isDir) + let sourceIsDirectory = Self.isDirectory(source) 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 { @@ -401,10 +399,9 @@ struct ComponentExecutor { values: [String: String] ) throws { let fm = FileManager.default - var isDir: ObjCBool = false - fm.fileExists(atPath: source.path, isDirectory: &isDir) + let sourceIsDirectory = Self.isDirectory(source) - 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 { @@ -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/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 131cac24..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(" \(component.displayName) failed (see output above)") + } 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 180401de..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(" \(component.displayName) failed (see output above)") - } 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 4e19c5e0..5cfcfaa6 100644 --- a/Sources/mcs/TechPack/HookInterpreter.swift +++ b/Sources/mcs/TechPack/HookInterpreter.swift @@ -33,8 +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. - 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/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 new file mode 100644 index 00000000..1a7f3850 --- /dev/null +++ b/Tests/MCSTests/CLIOutputTests.swift @@ -0,0 +1,117 @@ +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]) + } + + /// `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() + defer { close(readEnd) } + + var byte: UInt8 = 0x6A + #expect(write(writeEnd, &byte, 1) == 1) + close(writeEnd) + + #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") + func readByteReturnsNilAtEOF() { + let (readEnd, writeEnd) = makePipe() + defer { close(readEnd) } + close(writeEnd) + + #expect(CLIOutput(colorsEnabled: false, input: readEnd).readByte() == 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, input: writeEnd).readByte() == nil) + } + + 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) + } + + @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 new file mode 100644 index 00000000..4c004ad6 --- /dev/null +++ b/Tests/MCSTests/ClaudePrerequisiteTests.swift @@ -0,0 +1,28 @@ +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, output: silentOutput())) + #expect(shell.commandExistsCalls.contains(Constants.CLI.claudeCommand)) + #expect(shell.runCalls.isEmpty) + } + + @Test("A missing claude is reported and mcs installs nothing itself") + func missingCLIIsNeverInstalled() { + let shell = MockShellRunner() + shell.commandExistsResult = false + + #expect(!ensureClaudeCLI(shell: shell, output: silentOutput())) + #expect(shell.runCalls.isEmpty) + } +} diff --git a/Tests/MCSTests/CoreDoctorCheckTests.swift b/Tests/MCSTests/CoreDoctorCheckTests.swift index 6ed1dc88..3dd8e144 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")) } } @@ -809,3 +810,38 @@ struct SettingsDriftCheckTests { #expect(check.section == "Settings") } } + +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" + 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/EnvironmentTests.swift b/Tests/MCSTests/EnvironmentTests.swift index 1a5ddce9..df1be0cd 100644 --- a/Tests/MCSTests/EnvironmentTests.swift +++ b/Tests/MCSTests/EnvironmentTests.swift @@ -104,4 +104,87 @@ 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()) + #expect(Environment.defaultHomeDirectory(environment: ["HOME": "relative/home"]) == NSHomeDirectory()) + } + + @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("~/") == "/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") + } } diff --git a/Tests/MCSTests/ExternalDoctorCheckTests.swift b/Tests/MCSTests/ExternalDoctorCheckTests.swift index 0dc5badd..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 { @@ -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", @@ -82,14 +82,14 @@ struct ExternalDoctorCheckTests { command: "nonexistent", args: [], fixCommand: nil, - scriptRunner: makeScriptRunner() + scriptRunner: makeScriptRunner(), environment: Environment() ) 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,54 @@ 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("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( @@ -465,7 +513,7 @@ struct ExternalDoctorCheckTests { from: definition, packPath: tmpDir, projectRoot: nil, - scriptRunner: makeScriptRunner() + scriptRunner: makeScriptRunner(), environment: Environment() ) #expect(check.name == "git check") @@ -498,7 +546,7 @@ struct ExternalDoctorCheckTests { from: definition, packPath: tmpDir, projectRoot: nil, - scriptRunner: makeScriptRunner() + scriptRunner: makeScriptRunner(), environment: Environment() ) #expect(check.section == "External Pack") @@ -530,7 +578,7 @@ struct ExternalDoctorCheckTests { from: definition, packPath: tmpDir, projectRoot: nil, - scriptRunner: makeScriptRunner() + scriptRunner: makeScriptRunner(), environment: Environment() ) #expect(check is ExternalHookEventExistsCheck) @@ -556,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 @@ -592,7 +640,7 @@ struct ExternalDoctorCheckTests { from: definition, packPath: tmpDir, projectRoot: nil, - scriptRunner: makeScriptRunner() + scriptRunner: makeScriptRunner(), environment: Environment() ) #expect(check is ExternalSettingsKeyEqualsCheck) @@ -633,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) @@ -671,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, @@ -691,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) @@ -729,7 +777,7 @@ struct ExternalDoctorCheckTests { from: definition, packPath: tmpDir, projectRoot: nil, - scriptRunner: makeScriptRunner() + scriptRunner: makeScriptRunner(), environment: Environment() ) #expect(check is MisconfiguredDoctorCheck) @@ -761,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/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/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/HomebrewTests.swift b/Tests/MCSTests/HomebrewTests.swift index 9c48f6c3..6826c0a3 100644 --- a/Tests/MCSTests/HomebrewTests.swift +++ b/Tests/MCSTests/HomebrewTests.swift @@ -15,6 +15,42 @@ struct HomebrewTests { #expect(Homebrew.bareName(of: package) == expected) } + // 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") + #if canImport(Darwin) + #expect(Homebrew.allPrefixes(home: home) == ["/opt/homebrew", "/usr/local"]) + #else + #expect(Homebrew.allPrefixes(home: home) == ["/home/linuxbrew/.linuxbrew", "/tmp/mcs-home/.linuxbrew"]) + #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") 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 eb3743a0..4d882473 100644 --- a/Tests/MCSTests/LifecycleIntegrationTests.swift +++ b/Tests/MCSTests/LifecycleIntegrationTests.swift @@ -49,12 +49,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,12 +1278,13 @@ struct ShellCommandLifecycleTests { #expect(FileManager.default.fileExists(atPath: markerPath)) } - @Test("shellCommand with interactive flag is accepted and state is recorded") - 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", @@ -1293,18 +1295,17 @@ 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]) - // Configure — interactive commands use forkpty() in real ShellRunner, - // but the test verifies the component is accepted and state is 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")) } @@ -2783,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) @@ -3303,6 +3311,37 @@ struct BrewPackageDoctorTests { #expect(summary.warnings == 0) #expect(summary.issues == 0) } + + @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() } + + 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/LockedTests.swift b/Tests/MCSTests/LockedTests.swift new file mode 100644 index 00000000..33bb088d --- /dev/null +++ b/Tests/MCSTests/LockedTests.swift @@ -0,0 +1,18 @@ +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) + } +} 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 new file mode 100644 index 00000000..aae0a4b3 --- /dev/null +++ b/Tests/MCSTests/PTYBridgeTests.swift @@ -0,0 +1,55 @@ +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 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), .read), + (Int16(0), .idle), + ] + ) + func ptyAction(revents: Int16, expected: PTYBridge.PTYAction) { + #expect(PTYBridge.ptyAction(revents: revents) == expected) + } + + @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) + #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 6acb7c4e..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 @@ -109,12 +109,28 @@ 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() 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 @@ -127,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 @@ -144,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") } } @@ -169,10 +185,10 @@ 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") + let result = try PackSourceResolver(environment: Environment()).resolve("org/pack") guard case let .localPath(url) = result else { Issue.record("Expected .localPath, got \(result)") return @@ -185,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/ProjectDetectorTests.swift b/Tests/MCSTests/ProjectDetectorTests.swift index b34827f0..737d34d2 100644 --- a/Tests/MCSTests/ProjectDetectorTests.swift +++ b/Tests/MCSTests/ProjectDetectorTests.swift @@ -3,6 +3,13 @@ import Foundation import Testing struct ProjectDetectorTests { + 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 + } + private func makeTmpDir() throws -> URL { let dir = FileManager.default.temporaryDirectory .appendingPathComponent("mcs-projdetect-test-\(UUID().uuidString)") @@ -24,7 +31,7 @@ struct ProjectDetectorTests { try FileManager.default.createDirectory(at: sourcesDir, withIntermediateDirectories: true) let root = ProjectDetector.findProjectRoot(from: sourcesDir) - #expect(root?.standardizedFileURL == tmpDir.standardizedFileURL) + #expect(samePath(root, tmpDir)) } @Test("Finds project root via CLAUDE.local.md") @@ -41,7 +48,7 @@ struct ProjectDetectorTests { try FileManager.default.createDirectory(at: subDir, withIntermediateDirectories: true) let root = ProjectDetector.findProjectRoot(from: subDir) - #expect(root?.standardizedFileURL == tmpDir.standardizedFileURL) + #expect(samePath(root, tmpDir)) } @Test("Finds project root via .claude/.mcs-project") @@ -60,7 +67,7 @@ struct ProjectDetectorTests { try FileManager.default.createDirectory(at: subDir, withIntermediateDirectories: true) let root = ProjectDetector.findProjectRoot(from: subDir) - #expect(root?.standardizedFileURL == tmpDir.standardizedFileURL) + #expect(samePath(root, tmpDir)) } @Test("Prefers .git over CLAUDE.local.md at same level") @@ -78,7 +85,7 @@ struct ProjectDetectorTests { ) let root = ProjectDetector.findProjectRoot(from: tmpDir) - #expect(root?.standardizedFileURL == tmpDir.standardizedFileURL) + #expect(samePath(root, tmpDir)) } } 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"]] 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: [ diff --git a/Tests/MCSTests/ShellRunnerTests.swift b/Tests/MCSTests/ShellRunnerTests.swift index 15e2195d..af451912 100644 --- a/Tests/MCSTests/ShellRunnerTests.swift +++ b/Tests/MCSTests/ShellRunnerTests.swift @@ -7,6 +7,23 @@ struct ShellRunnerTests { ShellRunner(environment: Environment()) } + @Test("A failure points at the terminal only when the command ran in one and left no stderr") + func failureMessageShapes() { + #expect( + ShellRunner.failureMessage(name: "Ollama", stderr: "", ranInTerminal: true) + == "Ollama failed (see output above)" + ) + #expect(ShellRunner.failureMessage(name: "Ollama", stderr: "", ranInTerminal: false) == "Ollama failed") + #expect( + 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) + ) + } + @Test("run captures stdout") func capturesStdout() { let result = shell.run("/bin/echo", arguments: ["hello"]) @@ -68,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/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") diff --git a/Tests/MCSTests/TerminalAttributesTests.swift b/Tests/MCSTests/TerminalAttributesTests.swift new file mode 100644 index 00000000..2f2f7172 --- /dev/null +++ b/Tests/MCSTests/TerminalAttributesTests.swift @@ -0,0 +1,36 @@ +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif +@testable import mcs +import Testing + +struct TerminalAttributesTests { + @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) + // 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) + // 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) + } + + private func controlCharacter(_ attributes: termios, _ index: Int32) -> cc_t { + withUnsafeBytes(of: attributes.c_cc) { $0.bindMemory(to: cc_t.self)[Int(index)] } + } + + 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/Tests/MCSTests/UpdateCheckerTests.swift b/Tests/MCSTests/UpdateCheckerTests.swift index 766ec59e..fb810940 100644 --- a/Tests/MCSTests/UpdateCheckerTests.swift +++ b/Tests/MCSTests/UpdateCheckerTests.swift @@ -1213,12 +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 ) - #expect(context.contains("On yes, run: \(UpdateChecker.cliUpgradeCommands.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")) @@ -1230,30 +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)) - #expect(!context.contains(UpdateChecker.cliUpgradeCommands[1])) + #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("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("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: 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 + [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") + } + + 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] + 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 { 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..b94dcda9 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 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) @@ -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`, 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 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`) 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). 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. + --- -**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..81453db4 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: @@ -138,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, `, 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 offers a staged tarball swap instead of `brew upgrade` (ADR D12), and +points at the releases page when it cannot build one. + +### 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 jobs run on the +`ubuntu-22.04` runners rather than in a `swift:*` container image. + +**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. 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 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. | +| `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` / `--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 '~/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` / `--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:`. 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 `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) + +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 +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. + +**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. + +### D3 — Platform imports are an explicit `#if canImport` chain, per file + +**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 them: + +```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`, 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 +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. + +### 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. **`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` + 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`'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 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 +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. (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-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 +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 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, 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 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`. `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; 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 + +**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.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 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 + 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 + 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 either platform (D7). +- **`brew:` components are name-sensitive** without Linuxbrew — see D7 decision 3. +- **`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 five places only** — + `Core/TerminalAttributes.swift`, `Core/Environment.swift`, `Core/Homebrew.swift`, + `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. + 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..e3aea905 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`. 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). @@ -578,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 | diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8c074146..76e35f4c 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,36 @@ 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**: install it with the native installer, the one install that keeps itself updated. mcs prints this command but never runs it for you: ```bash -brew install claude-code +curl -fsSL https://claude.ai/install.sh | bash ``` +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 ``` +### `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