diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..d6c9414 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,111 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: "en-GB" +early_access: false + +reviews: + profile: "assertive" + request_changes_workflow: true + high_level_summary: true + high_level_summary_in_walkthrough: true + high_level_summary_instructions: >- + Summarise user-visible behaviour, security boundaries, and verification in + concise British English. Use ASD-STE100 Simplified Technical English for + documentation. Keep required technical terms, product names, commands, + identifiers, quoted interface text, and standard names. Omit release-note + filler. + review_status: true + review_details: false + commit_status: true + fail_commit_status: false + collapse_walkthrough: true + changed_files_summary: true + sequence_diagrams: false + estimate_code_review_effort: false + assess_linked_issues: true + related_issues: false + related_prs: false + suggested_labels: false + auto_apply_labels: false + suggested_reviewers: false + in_progress_fortune: false + poem: false + enable_prompt_for_ai_agents: false + abort_on_close: true + + auto_review: + enabled: true + auto_incremental_review: false + drafts: false + + finishing_touches: + docstrings: + enabled: false + + pre_merge_checks: + docstrings: + mode: "off" + title: + mode: "off" + description: + mode: "warning" + issue_assessment: + mode: "warning" + + path_instructions: + - path: "**/*" + instructions: | + Report only findings with a concrete correctness, security, or + maintainability consequence. State the consequence and the smallest + suitable remedy. Do not request cosmetic churn, redundant comments, + or documentation for self-explanatory implementation details. Verify + each finding against the current code and repository conventions. + - path: "**/*.swift" + instructions: | + Follow the Google Swift Style Guide and the repository SwiftLint + policy. Prefer idiomatic Swift 6, narrow access control, Sendable-safe + concurrency, typed errors, and explicit failure handling. Treat one + primary responsibility per file as a design goal, not a rigid + one-declaration rule. + - path: "Sources/ClamshellCore/Privilege/**/*.swift" + instructions: | + Treat helper actions, sudoers generation, filesystem replacement, + ownership, permissions, username handling, and process construction as + security boundaries. Check exact allow-lists and failure consistency. + - path: "Sources/ClamshellHelper/**/*.swift" + instructions: | + Treat every accepted argument and privileged command as part of the + root boundary. Reject broader command shapes and require observable + verification of requested state changes. + - path: "Tests/**/*.swift" + instructions: | + Review observable behaviour, failure paths, and privilege constraints. + Tests must not mutate live power settings or privileged system files. + Do not request assertions about private implementation or call order + unless order is part of the contract. + - path: ".github/workflows/**" + instructions: | + Check immutable action pinning, least-privilege permissions, untrusted + pull-request code execution, concurrency, timeouts, and fail-closed + status handling. Request comments only for non-obvious security + constraints or necessary tool suppressions. + - path: "scripts/**" + instructions: | + Check quoting, exit-status preservation, temporary-file cleanup, and + fail-closed behaviour. Prefer portable shell accepted by ShellCheck. + - path: "**/*.md" + instructions: | + Check technical accuracy against current behaviour. Require ASD-STE100 + Simplified Technical English and British English spelling. Permit + required technical terms, product names, commands, identifiers, quoted + interface text, and standard names. Reject stale paths, unsupported + claims, and internal AI process metadata. + + tools: + swiftlint: + enabled: true + config_file: ".swiftlint.yml" + shellcheck: + enabled: true + +chat: + auto_reply: true diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a2fecb8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.swift] +indent_style = space +indent_size = 2 +max_line_length = 100 + +[{*.json,*.yml,*.yaml}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ae5b536 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto eol=lf +*.icns binary +*.png binary +*.dmg binary diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..3575c05 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @LMLiam diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..cfda072 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,103 @@ +# Contributing + +## Prerequisites + +You need macOS, Xcode 26.6 or later, and Swift 6.3 or later. You also need +SwiftLint 0.65.0 and [actionlint](https://github.com/rhysd/actionlint). +Install the development tools with: + +```bash +brew install actionlint swiftlint +``` + +The future companion app also requires +[XcodeGen](https://github.com/yonaskolb/XcodeGen). + +## Setup + +Fork the repository, clone your fork, and create a branch from `main`. Resolve +the package dependencies once after cloning: + +```bash +swift package resolve +``` + +Do not run tests against live privileged paths. Tests must use fake process and +filesystem boundaries; they must never change `pmset`, invoke `sudo`, or write +to `/Library/PrivilegedHelperTools` or `/etc/sudoers.d`. + +## Checks + +Run the complete local gate before each pull request: + +```bash +scripts/check.sh +``` + +The script checks formatting, SwiftLint, tests, debug and release builds, and +GitHub Actions workflows. When native app targets exist, it also generates and +builds the Xcode project without code signing. + +## Swift style + +Follow the [Google Swift Style Guide](https://google.github.io/swift/). +`swift-format` owns formatting; SwiftLint owns semantic and maintainability +rules. Prefer self-describing names. Add comments only for contracts, +constraints, or decisions that the code does not make clear. + +Keep one primary responsibility per file. A private helper may remain beside +its sole consumer. Do not create generic `Utils`, `Common`, or `Models` +directories. + +Fix lint findings in code where practical. A SwiftLint suppression must have a +narrow scope and an adjacent explanation of why the rule does not apply. + +## Documentation style + +Use ASD-STE100 Simplified Technical English for all documentation. Use British +English spelling. Use short sentences and active voice. Give one instruction +in each sentence. Use one term for each item or action. + +You can use an unapproved term when technical accuracy requires it. Examples +include product names, commands, code identifiers, API names, quoted interface +text, and standard names. Do not replace a precise technical term with an +ambiguous word. + +## Commits + +Every commit and pull-request title must use: + +```text +verb(area): description +``` + +Examples include `feat(status): report battery state` and +`fix(setup): preserve an existing sudoers policy`. The accepted verbs are +`feat`, `fix`, `docs`, `test`, `build`, `ci`, `refactor`, `perf`, `style`, +`chore`, and `revert`. + +Dependency updates use `build(deps): ...` so they remain within the accepted +commit vocabulary and are hidden from the public changelog. + +## Tests + +Test observable behaviour through public or internal boundaries. Cover failure +paths and privilege constraints. Avoid assertions about private implementation +details, mock call order unless order is part of the contract, and live system +mutation. + +## Pull requests + +Keep each pull request focused and link the issue it addresses. Describe any +change to command output, exit status, system paths, permissions, sudoers +policy, or the helper allow-list. Include the commands you ran and update user +documentation when behaviour changes. + +Maintainer approval and all required checks are needed before merge. The +repository uses squash merging, so the pull-request title becomes the release +commit subject. + +## Security + +Do not report vulnerabilities in a public issue. Follow the private reporting +instructions in [SECURITY.md](SECURITY.md). diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a99fc87 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Report a security vulnerability + url: https://github.com/LMLiam/clamshellctl/security/advisories/new + about: Send vulnerability details privately to the maintainer. + - name: Support and usage questions + url: https://github.com/LMLiam/clamshellctl/issues/new?template=question.yml + about: Ask about macOS, Homebrew, or clamshellctl usage. diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 0000000..f5ea009 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,50 @@ +name: Support question +description: Ask about macOS, Homebrew, or clamshellctl usage. +title: "question(area): " +labels: + - question +body: + - type: markdown + attributes: + value: | + Do not include passwords, tokens, private keys, complete environment + dumps, or unrelated personal information. + - type: textarea + id: question + attributes: + label: Question + description: Explain what you are trying to do and where you are stuck. + validations: + required: true + - type: input + id: macos-version + attributes: + label: macOS version + placeholder: "26.6" + validations: + required: true + - type: dropdown + id: installation + attributes: + label: Installation method + options: + - Homebrew + - Companion-app DMG + - Source checkout + - Not installed + validations: + required: true + - type: textarea + id: diagnostics + attributes: + label: Safe diagnostic output + description: Include concise command output when it helps explain the question. + validations: + required: false + - type: checkboxes + id: safety + attributes: + label: Safety check + options: + - label: I removed passwords, tokens, private keys, and unrelated personal data. + required: true diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..80b67fc --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,28 @@ +# Security policy + +## Supported versions + +Only the latest published release receives security fixes. The project has not +published its first release yet. + +## Report a vulnerability + +Use [GitHub private vulnerability reporting](https://github.com/LMLiam/clamshellctl/security/advisories/new). +Do not open a public issue for a suspected vulnerability. + +Include the affected version, macOS version, impact, reproduction steps, and a +minimal proof when safe. Do not include live credentials, secrets, personal +data, or destructive commands. We will acknowledge a report within seven days +and keep you informed while we validate and fix it. + +## Security-sensitive surfaces + +Changes to the root-owned helper, sudoers policy, installation paths, ownership +or permissions, process execution, and unsigned app distribution require extra +review. The helper must continue to accept only the exact `enable` and +`disable` actions. The sudoers policy must not grant password-free access to +the public CLI, `pmset`, a shell, or a user-writable executable. + +The planned companion app and DMG will use ad-hoc signing without Apple +notarisation. Release documentation must state that boundary and provide the +specific Gatekeeper approval steps without implying Apple review. diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md new file mode 100644 index 0000000..843bae3 --- /dev/null +++ b/.github/SUPPORT.md @@ -0,0 +1,14 @@ +# Support + +- Report reproducible defects with the [bug form](https://github.com/LMLiam/clamshellctl/issues/new?template=bug.yml). +- Propose a change with the [feature form](https://github.com/LMLiam/clamshellctl/issues/new?template=feature.yml). +- Report vulnerabilities through [private vulnerability reporting](https://github.com/LMLiam/clamshellctl/security/advisories/new). +- Ask macOS, Homebrew, or usage questions with the [support form](https://github.com/LMLiam/clamshellctl/issues/new?template=question.yml). + +This is a community project. Opening an issue does not guarantee individual +support or a response time. + +Safe diagnostics include `clamshellctl --version` and `clamshellctl status`. +Review output before posting it. Do not publish sudoers contents when they +contain unexpected local changes; use private vulnerability reporting if those +changes may expose a security problem. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3ed1fd7 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,27 @@ +version: 2 +updates: + - package-ecosystem: swift + directory: / + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Europe/London + open-pull-requests-limit: 5 + groups: + swift-development: + patterns: + - "*" + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Europe/London + open-pull-requests-limit: 5 + groups: + actions: + patterns: + - "*" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..d237ed6 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,27 @@ +# Summary + + + +## Issues + + + +## Behaviour and security + + + +## Verification + + + +## Screenshots + + + +## Checklist + +- [ ] `scripts/check.sh` passes. +- [ ] User and contributor documentation matches the change. +- [ ] Documentation uses ASD-STE100 Simplified Technical English. +- [ ] Every commit uses `verb(area): description`. +- [ ] Privileged behaviour is covered by safe tests rather than live system mutation. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a3b06c9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,108 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + quality: + name: Quality + runs-on: macos-26 + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install SwiftLint + env: + SWIFTLINT_SHA256: d6cb0aa7a2f5f1ef306fc9e37bcb54dc9a26facc8f7784ac0c3dd3eccf5c6ba6 + SWIFTLINT_VERSION: 0.65.0 + run: | + archive="${RUNNER_TEMP}/portable_swiftlint.zip" + curl --fail --location --silent --show-error \ + --output "${archive}" \ + "https://github.com/realm/SwiftLint/releases/download/${SWIFTLINT_VERSION}/portable_swiftlint.zip" + echo "${SWIFTLINT_SHA256} ${archive}" | shasum -a 256 --check + unzip -j "${archive}" swiftlint -d "${RUNNER_TEMP}/swiftlint" + echo "${RUNNER_TEMP}/swiftlint" >> "${GITHUB_PATH}" + - name: Check Swift formatting + run: swift format lint --recursive --strict Sources Tests Package.swift + - name: Run SwiftLint + run: | + test "$(swiftlint version)" = "0.65.0" + swiftlint lint --strict + + tests: + name: Tests + runs-on: macos-26 + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Restore Swift dependencies + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + .build/artifacts + .build/checkouts + .build/repositories + ~/Library/Caches/org.swift.swiftpm + key: swiftpm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Package.resolved') }} + restore-keys: swiftpm-${{ runner.os }}-${{ runner.arch }}- + - name: Resolve Swift dependencies + run: swift package resolve + - name: Run tests + run: swift test + + build: + name: Build + runs-on: macos-26 + timeout-minutes: 15 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Restore Swift dependencies + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + .build/artifacts + .build/checkouts + .build/repositories + ~/Library/Caches/org.swift.swiftpm + key: swiftpm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Package.resolved') }} + restore-keys: swiftpm-${{ runner.os }}-${{ runner.arch }}- + - name: Resolve Swift dependencies + run: swift package resolve + - name: Build debug configuration + run: swift build + - name: Build release configuration + run: swift build -c release + + workflows: + name: Workflow lint + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Lint workflows + uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0 + with: + version: 1.7.12 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..9ad40a2 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,53 @@ +name: CodeQL + +on: + push: + branches: [main] + schedule: + - cron: "23 4 * * 1" + workflow_dispatch: + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + analyze: + name: Analyze (swift) + runs-on: macos-26 + timeout-minutes: 30 + permissions: + contents: read + security-events: write + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Restore Swift dependencies + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + .build/artifacts + .build/checkouts + .build/repositories + ~/Library/Caches/org.swift.swiftpm + key: swiftpm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Package.resolved') }} + restore-keys: swiftpm-${{ runner.os }}-${{ runner.arch }}- + - name: Resolve Swift dependencies + run: swift package resolve + - name: Initialise CodeQL + uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 + with: + build-mode: manual + languages: swift + queries: security-extended + - name: Build for CodeQL + run: swift build + - name: Analyse with CodeQL + uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 + with: + category: /language:swift diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..afc0271 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,75 @@ +name: PR + +on: + pull_request: + branches: [main] + types: [opened, edited, reopened, synchronize] + +concurrency: + group: pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + title: + name: Title + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out trusted base + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.base.sha }} + - name: Prepare title validator + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + validator="$RUNNER_TEMP/check-conventional-subject.sh" + if git cat-file -e "$BASE_SHA:scripts/check-conventional-subject.sh" 2>/dev/null; then + git show "$BASE_SHA:scripts/check-conventional-subject.sh" > "$validator" + else + # shellcheck disable=SC2016 + printf '%s\n' '#!/bin/bash' 'set -euo pipefail' 'subject="${1:-}"' "verbs='feat|fix|docs|test|build|ci|refactor|perf|style|chore|revert'" 'pattern="^(feat|fix|docs|test|build|ci|refactor|perf|style|chore|revert)[(][a-z0-9][a-z0-9-]*[)]: .*[^.]$"' 'if [[ ! "$subject" =~ $pattern ]]; then' ' echo "Expected verb(area): description without a trailing full stop, received: $subject" >&2' ' exit 1' 'fi' > "$validator" + fi + chmod +x "$validator" + - name: Validate pull request title + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: >- + "$RUNNER_TEMP/check-conventional-subject.sh" "$PR_TITLE" + + commits: + name: Commits + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out pull request + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha }} + - name: Validate commit subjects + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + validator="$RUNNER_TEMP/check-conventional-subject.sh" + if git cat-file -e "$BASE_SHA:scripts/check-conventional-subject.sh" 2>/dev/null; then + git show "$BASE_SHA:scripts/check-conventional-subject.sh" > "$validator" + else + # shellcheck disable=SC2016 + printf '%s\n' '#!/bin/bash' 'set -euo pipefail' 'subject="${1:-}"' "verbs='feat|fix|docs|test|build|ci|refactor|perf|style|chore|revert'" 'pattern="^(feat|fix|docs|test|build|ci|refactor|perf|style|chore|revert)[(][a-z0-9][a-z0-9-]*[)]: .*[^.]$"' 'if [[ ! "$subject" =~ $pattern ]]; then' ' echo "Expected verb(area): description without a trailing full stop, received: $subject" >&2' ' exit 1' 'fi' > "$validator" + fi + chmod +x "$validator" + subjects="$RUNNER_TEMP/commit-subjects.txt" + trap 'rm -f "$subjects"' EXIT + if ! git log --format=%s "$BASE_SHA..HEAD" > "$subjects"; then + echo "Failed to enumerate pull-request commits" >&2 + exit 1 + fi + while IFS= read -r subject; do + "$validator" "$subject" + done < "$subjects" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f61516e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,31 @@ +name: Release + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + release-please: + name: Release Please + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + # release-please opens release PRs, updates release metadata, and creates releases. + contents: write + issues: write + pull-requests: write + steps: + - name: Run release-please + uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5 + with: + token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json diff --git a/.gitignore b/.gitignore index f7c5a9c..b22bad8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ .swiftpm/ .DS_Store xcuserdata/ +Clamshell.xcodeproj/ +DerivedData/ diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..e18ee07 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..5076810 --- /dev/null +++ b/.swift-format @@ -0,0 +1,17 @@ +{ + "indentation": { + "spaces": 2 + }, + "lineLength": 100, + "maximumBlankLines": 1, + "multiElementCollectionTrailingCommas": true, + "rules": { + "BeginDocumentationCommentWithOneLineSummary": true, + "NeverForceUnwrap": true, + "NeverUseForceTry": true, + "NeverUseImplicitlyUnwrappedOptionals": true, + "OrderedImports": true, + "ValidateDocumentationComments": true + }, + "version": 1 +} diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..b24d445 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,71 @@ +strict: true +check_for_updates: false +allow_zero_lintable_files: false + +excluded: + - .build + - .swiftpm + - Clamshell.xcodeproj + - DerivedData + +disabled_rules: + - closing_brace + - colon + - comma + - leading_whitespace + - line_length + - opening_brace + - function_name_whitespace + - return_arrow_whitespace + - statement_position + - force_try + - trailing_comma + - trailing_newline + - trailing_semicolon + - trailing_whitespace + - vertical_whitespace + +opt_in_rules: + - array_init + - closure_body_length + - contains_over_filter_count + - contains_over_filter_is_empty + - discouraged_optional_boolean + - empty_collection_literal + - empty_count + - empty_string + - explicit_init + - fatal_error_message + - first_where + - last_where + - legacy_multiple + - modifier_order + - overridden_super_call + - pattern_matching_keywords + - reduce_into + - redundant_nil_coalescing + - sorted_first_last + - toggle_bool + - unavailable_function + +closure_body_length: + warning: 40 + error: 60 +cyclomatic_complexity: + warning: 10 + error: 15 +file_length: + warning: 400 + error: 500 + ignore_comment_only_lines: true +function_body_length: + warning: 40 + error: 60 +function_parameter_count: + warning: 6 + error: 8 +type_body_length: + warning: 250 + error: 350 + +reporter: xcode diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5aaac2a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +Notable changes to `clamshellctl` are recorded here by release-please. diff --git a/Package.resolved b/Package.resolved index a68d9fb..beba77e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b1c79db6518fc27a00cb062a81a446b419cf3d099840104ce61b1bcc12f22936", + "originHash" : "c76775ae7c565ad46d8e466c810d8e0bcad4db0218bc3765c46a215e2227fe90", "pins" : [ { "identity" : "swift-argument-parser", diff --git a/Package.swift b/Package.swift index c6ad085..c3c2d4a 100644 --- a/Package.swift +++ b/Package.swift @@ -3,31 +3,39 @@ import PackageDescription let package = Package( - name: "clamshellctl", - platforms: [.macOS(.v13)], - products: [ - .executable(name: "clamshellctl", targets: ["ClamshellCLI"]), - .executable(name: "clamshellctl-helper", targets: ["ClamshellHelper"]), - ], - dependencies: [ - .package( - url: "https://github.com/apple/swift-argument-parser", - from: "1.8.0" - ), - ], - targets: [ - .target(name: "ClamshellCore"), - .executableTarget( - name: "ClamshellCLI", - dependencies: [ - "ClamshellCore", - .product(name: "ArgumentParser", package: "swift-argument-parser"), - ] - ), - .executableTarget( - name: "ClamshellHelper", - dependencies: ["ClamshellCore"] - ), - .testTarget(name: "ClamshellCoreTests", dependencies: ["ClamshellCore"]), - ] + name: "clamshellctl", + platforms: [.macOS(.v13)], + products: [ + .executable(name: "clamshellctl", targets: ["ClamshellCLI"]), + .executable(name: "clamshellctl-helper", targets: ["ClamshellHelper"]), + ], + dependencies: [ + .package( + url: "https://github.com/apple/swift-argument-parser", + from: "1.8.0" + ) + ], + targets: [ + .target(name: "ClamshellCore"), + .executableTarget( + name: "ClamshellCLI", + dependencies: [ + "ClamshellCore", + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ] + ), + .executableTarget( + name: "ClamshellHelper", + dependencies: ["ClamshellCore"] + ), + .testTarget(name: "ClamshellCoreTests", dependencies: ["ClamshellCore"]), + .testTarget( + name: "ClamshellCLITests", + dependencies: [ + "ClamshellCLI", + "ClamshellCore", + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ] + ), + ] ) diff --git a/README.md b/README.md index 7d2af8c..8543f80 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,9 @@ # clamshellctl +[![CI](https://github.com/LMLiam/clamshellctl/actions/workflows/ci.yml/badge.svg)](https://github.com/LMLiam/clamshellctl/actions/workflows/ci.yml) +[![CodeQL](https://github.com/LMLiam/clamshellctl/actions/workflows/codeql.yml/badge.svg)](https://github.com/LMLiam/clamshellctl/actions/workflows/codeql.yml) + Control battery clamshell mode on macOS. > [!IMPORTANT] @@ -55,10 +58,21 @@ Requirements: Run the current checks with: ```bash -swift test -swift build -c release +scripts/check.sh ``` +See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for setup, style, testing, and +commit conventions. + +## Community + +- Use the [issue forms](https://github.com/LMLiam/clamshellctl/issues/new/choose) + for bugs and feature proposals. +- Read [SUPPORT.md](.github/SUPPORT.md) before asking a usage question. +- Report vulnerabilities through the private process in + [SECURITY.md](.github/SECURITY.md). +- Follow the [Code of Conduct](.github/CODE_OF_CONDUCT.md) when participating. + ## Licence `clamshellctl` is available under the [MIT License](LICENSE). diff --git a/Sources/ClamshellCLI/ClamshellCommand.swift b/Sources/ClamshellCLI/ClamshellCommand.swift index c0e94a2..5222a51 100644 --- a/Sources/ClamshellCLI/ClamshellCommand.swift +++ b/Sources/ClamshellCLI/ClamshellCommand.swift @@ -3,9 +3,18 @@ import ClamshellCore @main struct ClamshellCommand: ParsableCommand { - static let configuration = CommandConfiguration( - commandName: "clamshellctl", - abstract: "Control battery clamshell mode on macOS.", - version: BuildVersion.current - ) + static let configuration = CommandConfiguration( + commandName: "clamshellctl", + abstract: "Control battery clamshell mode on macOS.", + version: BuildVersion.current, + subcommands: [ + StatusCommand.self, + EnableCommand.self, + DisableCommand.self, + ToggleCommand.self, + SetupCommand.self, + UninstallCommand.self, + ], + defaultSubcommand: StatusCommand.self + ) } diff --git a/Sources/ClamshellCLI/CommandComposition.swift b/Sources/ClamshellCLI/CommandComposition.swift new file mode 100644 index 0000000..9aa39ad --- /dev/null +++ b/Sources/ClamshellCLI/CommandComposition.swift @@ -0,0 +1,23 @@ +import ClamshellCore +import Foundation + +enum CommandComposition { + static func clamshellService() -> ClamshellService { + let runner = FoundationProcessRunner() + return ClamshellService( + stateReader: PowerSettingsClient(runner: runner), + stateWriter: PrivilegedHelperClient(runner: runner) + ) + } + + static func privilegedInstallation() throws -> PrivilegedInstallation { + guard + let executablePath = Bundle.main.executableURL? + .resolvingSymlinksInPath() + .path + else { + throw ClamshellError.executablePathUnavailable + } + return PrivilegedInstallation(executablePath: executablePath) + } +} diff --git a/Sources/ClamshellCLI/Commands/DisableCommand.swift b/Sources/ClamshellCLI/Commands/DisableCommand.swift new file mode 100644 index 0000000..49eb7eb --- /dev/null +++ b/Sources/ClamshellCLI/Commands/DisableCommand.swift @@ -0,0 +1,15 @@ +import ArgumentParser + +struct DisableCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "disable", + abstract: "Prevent clamshell mode while using battery power." + ) + + @OptionGroup var output: OutputOptions + + func run() throws { + let result = try CommandComposition.clamshellService().set(.disabled) + Console(isQuiet: output.quiet).writeTransition(result) + } +} diff --git a/Sources/ClamshellCLI/Commands/EnableCommand.swift b/Sources/ClamshellCLI/Commands/EnableCommand.swift new file mode 100644 index 0000000..5a85d55 --- /dev/null +++ b/Sources/ClamshellCLI/Commands/EnableCommand.swift @@ -0,0 +1,15 @@ +import ArgumentParser + +struct EnableCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "enable", + abstract: "Allow clamshell mode while using battery power." + ) + + @OptionGroup var output: OutputOptions + + func run() throws { + let result = try CommandComposition.clamshellService().set(.enabled) + Console(isQuiet: output.quiet).writeTransition(result) + } +} diff --git a/Sources/ClamshellCLI/Commands/SetupCommand.swift b/Sources/ClamshellCLI/Commands/SetupCommand.swift new file mode 100644 index 0000000..160af00 --- /dev/null +++ b/Sources/ClamshellCLI/Commands/SetupCommand.swift @@ -0,0 +1,28 @@ +import ArgumentParser +import ClamshellCore + +struct SetupCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "setup", + abstract: "Install the restricted privileged helper." + ) + + @OptionGroup var output: OutputOptions + + func run() throws { + try run(installation: CommandComposition.privilegedInstallation()) + } + + func run(installation: PrivilegedInstallation) throws { + let result = try installation.install() + let console = Console(isQuiet: output.quiet) + + if result.didChange { + console.writeLine("Privileged setup installed:") + console.writeLine(result.helperPath) + console.writeLine(result.sudoersPolicyPath) + } else { + console.writeLine("Privileged setup: already configured") + } + } +} diff --git a/Sources/ClamshellCLI/Commands/StatusCommand.swift b/Sources/ClamshellCLI/Commands/StatusCommand.swift new file mode 100644 index 0000000..5806d52 --- /dev/null +++ b/Sources/ClamshellCLI/Commands/StatusCommand.swift @@ -0,0 +1,17 @@ +import ArgumentParser +import ClamshellCore + +struct StatusCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "status", + abstract: "Show the current battery clamshell mode." + ) + + func run() throws { + let client = PowerSettingsClient(runner: FoundationProcessRunner()) + let state = try client.currentState() + Console(isQuiet: false).writeLine( + "Battery clamshell mode: \(state.rawValue)" + ) + } +} diff --git a/Sources/ClamshellCLI/Commands/ToggleCommand.swift b/Sources/ClamshellCLI/Commands/ToggleCommand.swift new file mode 100644 index 0000000..0c66fe0 --- /dev/null +++ b/Sources/ClamshellCLI/Commands/ToggleCommand.swift @@ -0,0 +1,15 @@ +import ArgumentParser + +struct ToggleCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "toggle", + abstract: "Switch the current battery clamshell mode." + ) + + @OptionGroup var output: OutputOptions + + func run() throws { + let result = try CommandComposition.clamshellService().toggle() + Console(isQuiet: output.quiet).writeTransition(result) + } +} diff --git a/Sources/ClamshellCLI/Commands/UninstallCommand.swift b/Sources/ClamshellCLI/Commands/UninstallCommand.swift new file mode 100644 index 0000000..9f451a0 --- /dev/null +++ b/Sources/ClamshellCLI/Commands/UninstallCommand.swift @@ -0,0 +1,30 @@ +import ArgumentParser +import ClamshellCore + +struct UninstallCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "uninstall", + abstract: "Remove the restricted privileged helper." + ) + + @OptionGroup var output: OutputOptions + + func run() throws { + try run(installation: CommandComposition.privilegedInstallation()) + } + + func run(installation: PrivilegedInstallation) throws { + let result = try installation.uninstall() + let console = Console(isQuiet: output.quiet) + + guard !result.removedPaths.isEmpty else { + console.writeLine("Privileged setup: already removed") + return + } + + console.writeLine("Privileged setup removed:") + for path in result.removedPaths { + console.writeLine(path) + } + } +} diff --git a/Sources/ClamshellCLI/Console.swift b/Sources/ClamshellCLI/Console.swift new file mode 100644 index 0000000..a02e6b1 --- /dev/null +++ b/Sources/ClamshellCLI/Console.swift @@ -0,0 +1,24 @@ +import ClamshellCore +import Foundation + +struct Console { + private let standardOutput: FileHandle + private let isQuiet: Bool + + init(isQuiet: Bool, standardOutput: FileHandle = .standardOutput) { + self.isQuiet = isQuiet + self.standardOutput = standardOutput + } + + func writeLine(_ line: String) { + guard !isQuiet else { + return + } + standardOutput.write(Data("\(line)\n".utf8)) + } + + func writeTransition(_ result: TransitionResult) { + let qualifier = result.didChange ? "" : "already " + writeLine("Battery clamshell mode: \(qualifier)\(result.current.rawValue)") + } +} diff --git a/Sources/ClamshellCLI/OutputOptions.swift b/Sources/ClamshellCLI/OutputOptions.swift new file mode 100644 index 0000000..40d8362 --- /dev/null +++ b/Sources/ClamshellCLI/OutputOptions.swift @@ -0,0 +1,6 @@ +import ArgumentParser + +struct OutputOptions: ParsableArguments { + @Flag(name: .long, help: "Suppress successful output.") + var quiet = false +} diff --git a/Sources/ClamshellCore/BuildVersion.swift b/Sources/ClamshellCore/BuildVersion.swift index 3c52678..8d7e96b 100644 --- a/Sources/ClamshellCore/BuildVersion.swift +++ b/Sources/ClamshellCore/BuildVersion.swift @@ -1,4 +1,4 @@ public enum BuildVersion { - // x-release-please-version - public static let current = "0.1.0" + // x-release-please-version + public static let current = "0.1.0" } diff --git a/Sources/ClamshellCore/Errors/ClamshellError.swift b/Sources/ClamshellCore/Errors/ClamshellError.swift new file mode 100644 index 0000000..ee4ffd6 --- /dev/null +++ b/Sources/ClamshellCore/Errors/ClamshellError.swift @@ -0,0 +1,66 @@ +import Foundation + +public enum ClamshellError: Error, Equatable, LocalizedError { + case administratorPrivilegesRequired(command: String) + case executablePathUnavailable + case helperPayloadNotFound + case installationVerificationFailed + case invalidHelperArguments + case invalidProcessOutput(executable: String, stream: ProcessOutputStream) + case invalidUsername(String) + case originalUserUnavailable + case privilegedHelperUnavailable(setupCommand: String) + case processFailed( + executable: String, + terminationStatus: Int32, + standardError: String + ) + case stateVerificationFailed(expected: ClamshellState, actual: ClamshellState) + case sudoersValidationFailed + case unrecognisedPowerSettings + + public var errorDescription: String? { + switch self { + case let .administratorPrivilegesRequired(command): + "Administrator privileges are required. Run: \(command)" + case .executablePathUnavailable: + "Unable to resolve the clamshellctl executable path." + case .helperPayloadNotFound: + "Unable to locate the clamshellctl helper payload." + case .installationVerificationFailed: + "The privileged installation could not be verified." + case .invalidHelperArguments: + "Invalid privileged helper arguments." + case let .invalidProcessOutput(executable, stream): + "Unable to decode \(stream.rawValue) from \(executable)." + case .invalidUsername: + "The original account name is unavailable or unsafe." + case .originalUserUnavailable: + "Unable to identify the account that requested setup." + case let .privilegedHelperUnavailable(setupCommand): + "Privileged helper unavailable. Run: \(setupCommand)" + case let .processFailed(executable, terminationStatus, standardError): + processFailureDescription( + executable: executable, + terminationStatus: terminationStatus, + standardError: standardError + ) + case let .stateVerificationFailed(expected, actual): + "Battery clamshell mode verification failed: expected \(expected.rawValue), found \(actual.rawValue)." + case .sudoersValidationFailed: + "The generated sudoers policy failed validation." + case .unrecognisedPowerSettings: + "Unable to read the Battery Power section from pmset." + } + } + + private func processFailureDescription( + executable: String, + terminationStatus: Int32, + standardError: String + ) -> String { + let detail = standardError.trimmingCharacters(in: .whitespacesAndNewlines) + let prefix = "\(executable) exited with status \(terminationStatus)." + return detail.isEmpty ? prefix : "\(prefix) \(detail)" + } +} diff --git a/Sources/ClamshellCore/Power/PowerMutation.swift b/Sources/ClamshellCore/Power/PowerMutation.swift new file mode 100644 index 0000000..abb70e5 --- /dev/null +++ b/Sources/ClamshellCore/Power/PowerMutation.swift @@ -0,0 +1,15 @@ +public struct PowerMutation: Sendable, Equatable { + public let state: ClamshellState + + /// Accepts exactly one `enable` or `disable` argument. + public init(rawArguments: [String]) throws { + switch rawArguments { + case ["enable"]: + state = .enabled + case ["disable"]: + state = .disabled + default: + throw ClamshellError.invalidHelperArguments + } + } +} diff --git a/Sources/ClamshellCore/Power/PowerSettingsClient.swift b/Sources/ClamshellCore/Power/PowerSettingsClient.swift new file mode 100644 index 0000000..7acb7bd --- /dev/null +++ b/Sources/ClamshellCore/Power/PowerSettingsClient.swift @@ -0,0 +1,38 @@ +public struct PowerSettingsClient: PowerStateReading, PowerStateWriting, Sendable { + private let runner: any ProcessRunning + private let parser: PowerSettingsParser + + public init( + runner: any ProcessRunning, + parser: PowerSettingsParser = PowerSettingsParser() + ) { + self.runner = runner + self.parser = parser + } + + public func currentState() throws -> ClamshellState { + let result = try runPmset(arguments: ["-g", "custom"]) + + return try parser.batteryState(from: result.standardOutput) + } + + public func setState(_ state: ClamshellState) throws { + let value = state == .enabled ? "1" : "0" + _ = try runPmset(arguments: ["-b", "disablesleep", value]) + } + + private func runPmset(arguments: [String]) throws -> ProcessResult { + let executable = "/usr/bin/pmset" + let result = try runner.run(executable, arguments: arguments) + + guard result.terminationStatus == 0 else { + throw ClamshellError.processFailed( + executable: executable, + terminationStatus: result.terminationStatus, + standardError: result.standardError + ) + } + + return result + } +} diff --git a/Sources/ClamshellCore/Power/PowerSettingsParser.swift b/Sources/ClamshellCore/Power/PowerSettingsParser.swift new file mode 100644 index 0000000..d96e498 --- /dev/null +++ b/Sources/ClamshellCore/Power/PowerSettingsParser.swift @@ -0,0 +1,50 @@ +import Foundation + +public struct PowerSettingsParser: Sendable { + public init() {} + + /// Treats a missing `disablesleep` entry as disabled and rejects malformed values. + public func batteryState(from output: String) throws -> ClamshellState { + let lines = output.split( + omittingEmptySubsequences: false, + whereSeparator: \Character.isNewline + ) + + guard + let batteryHeader = lines.firstIndex(where: { + $0.trimmingCharacters(in: .whitespaces) == "Battery Power:" + }) + else { + throw ClamshellError.unrecognisedPowerSettings + } + + for line in lines[lines.index(after: batteryHeader)...] { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { + continue + } + guard line.first?.isWhitespace == true else { + break + } + + let fields = trimmed.split(whereSeparator: \Character.isWhitespace) + guard fields.first == "disablesleep" else { + continue + } + guard fields.count == 2 else { + throw ClamshellError.unrecognisedPowerSettings + } + + switch fields[1] { + case "1": + return .enabled + case "0": + return .disabled + default: + throw ClamshellError.unrecognisedPowerSettings + } + } + + return .disabled + } +} diff --git a/Sources/ClamshellCore/Privilege/Installation/FoundationInstallationFileSystem.swift b/Sources/ClamshellCore/Privilege/Installation/FoundationInstallationFileSystem.swift new file mode 100644 index 0000000..ded6964 --- /dev/null +++ b/Sources/ClamshellCore/Privilege/Installation/FoundationInstallationFileSystem.swift @@ -0,0 +1,82 @@ +import Darwin +import Foundation + +public struct FoundationInstallationFileSystem: InstallationFileSystem { + public init() {} + + public func itemExists(at path: String) -> Bool { + var information = stat() + return path.withCString { lstat($0, &information) == 0 } + } + + public func isRegularFile(at path: String) -> Bool { + guard + let attributes = try? FileManager.default.attributesOfItem(atPath: path), + let type = attributes[.type] as? FileAttributeType + else { + return false + } + return type == .typeRegular + } + + public func contentsEqual(at firstPath: String, and secondPath: String) -> Bool { + FileManager.default.contentsEqual(atPath: firstPath, andPath: secondPath) + } + + public func readText(at path: String) throws -> String { + try String(contentsOfFile: path, encoding: .utf8) + } + + public func attributes(at path: String) throws -> InstalledFileAttributes { + let attributes = try FileManager.default.attributesOfItem(atPath: path) + guard + let userID = attributes[.ownerAccountID] as? NSNumber, + let groupID = attributes[.groupOwnerAccountID] as? NSNumber, + let permissions = attributes[.posixPermissions] as? NSNumber + else { + throw ClamshellError.installationVerificationFailed + } + return InstalledFileAttributes( + userID: userID.uint32Value, + groupID: groupID.uint32Value, + permissions: permissions.uint16Value + ) + } + + public func copyItem(at source: String, to destination: String) throws { + try FileManager.default.copyItem(atPath: source, toPath: destination) + } + + public func write(_ contents: String, to path: String) throws { + try Data(contents.utf8).write(to: URL(fileURLWithPath: path)) + } + + public func setOwner(userID: UInt32, groupID: UInt32, at path: String) throws { + guard chown(path, userID, groupID) == 0 else { + throw currentPOSIXError() + } + } + + public func setPermissions(_ permissions: UInt16, at path: String) throws { + guard chmod(path, mode_t(permissions)) == 0 else { + throw currentPOSIXError() + } + } + + public func replaceItem(at destination: String, withItemAt replacement: String) throws { + guard rename(replacement, destination) == 0 else { + throw currentPOSIXError() + } + } + + public func removeItem(at path: String) throws { + guard itemExists(at: path) else { + return + } + try FileManager.default.removeItem(atPath: path) + } + + private func currentPOSIXError() -> POSIXError { + POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } +} diff --git a/Sources/ClamshellCore/Privilege/Installation/InstallationFileSystem.swift b/Sources/ClamshellCore/Privilege/Installation/InstallationFileSystem.swift new file mode 100644 index 0000000..5e08a7d --- /dev/null +++ b/Sources/ClamshellCore/Privilege/Installation/InstallationFileSystem.swift @@ -0,0 +1,25 @@ +public protocol InstallationFileSystem: Sendable { + func itemExists(at path: String) -> Bool + func isRegularFile(at path: String) -> Bool + func contentsEqual(at firstPath: String, and secondPath: String) -> Bool + func readText(at path: String) throws -> String + func attributes(at path: String) throws -> InstalledFileAttributes + func copyItem(at source: String, to destination: String) throws + func write(_ contents: String, to path: String) throws + func setOwner(userID: UInt32, groupID: UInt32, at path: String) throws + func setPermissions(_ permissions: UInt16, at path: String) throws + func replaceItem(at destination: String, withItemAt replacement: String) throws + func removeItem(at path: String) throws +} + +public struct InstalledFileAttributes: Sendable, Equatable { + public let userID: UInt32 + public let groupID: UInt32 + public let permissions: UInt16 + + public init(userID: UInt32, groupID: UInt32, permissions: UInt16) { + self.userID = userID + self.groupID = groupID + self.permissions = permissions + } +} diff --git a/Sources/ClamshellCore/Privilege/Installation/InstallationResult.swift b/Sources/ClamshellCore/Privilege/Installation/InstallationResult.swift new file mode 100644 index 0000000..f3138da --- /dev/null +++ b/Sources/ClamshellCore/Privilege/Installation/InstallationResult.swift @@ -0,0 +1,15 @@ +public struct InstallationResult: Sendable, Equatable { + public let helperPath: String + public let sudoersPolicyPath: String + public let didChange: Bool + + public init( + helperPath: String, + sudoersPolicyPath: String, + didChange: Bool = true + ) { + self.helperPath = helperPath + self.sudoersPolicyPath = sudoersPolicyPath + self.didChange = didChange + } +} diff --git a/Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift b/Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift new file mode 100644 index 0000000..9911a01 --- /dev/null +++ b/Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift @@ -0,0 +1,212 @@ +import Darwin +import Foundation + +public struct PrivilegedInstallation: Sendable { + private let fileSystem: any InstallationFileSystem + private let runner: any ProcessRunning + private let effectiveUserID: UInt32 + private let environment: [String: String] + private let executablePath: String + private let temporarySuffix: String + + public init( + fileSystem: any InstallationFileSystem, + runner: any ProcessRunning, + effectiveUserID: UInt32, + environment: [String: String], + executablePath: String, + temporarySuffix: String = UUID().uuidString + ) { + self.fileSystem = fileSystem + self.runner = runner + self.effectiveUserID = effectiveUserID + self.environment = environment + self.executablePath = executablePath + self.temporarySuffix = temporarySuffix + } + + public init(executablePath: String) { + self.init( + fileSystem: FoundationInstallationFileSystem(), + runner: FoundationProcessRunner(), + effectiveUserID: geteuid(), + environment: ProcessInfo.processInfo.environment, + executablePath: executablePath + ) + } + + /// Stages, validates, and verifies root-owned files, skipping an identical installation. + public func install() throws -> InstallationResult { + try requireAdministratorPrivileges() + + guard let originalUser = environment["SUDO_USER"], !originalUser.isEmpty else { + throw ClamshellError.originalUserUnavailable + } + let policy = try SudoersPolicy(username: originalUser) + let payload = try helperPayloadPath() + + if try isConfigured(payload: payload, policy: policy) { + return installationResult(didChange: false) + } + + let helperTemporary = try stageHelper(payload: payload) + var helperTemporaryExists = true + defer { + if helperTemporaryExists { + try? fileSystem.removeItem(at: helperTemporary) + } + } + + let policyTemporary = try stagePolicy(policy) + var policyTemporaryExists = true + defer { + if policyTemporaryExists { + try? fileSystem.removeItem(at: policyTemporary) + } + } + + try commit( + helperTemporary: helperTemporary, + policyTemporary: policyTemporary + ) + policyTemporaryExists = false + helperTemporaryExists = false + + guard try isConfigured(payload: payload, policy: policy) else { + throw ClamshellError.installationVerificationFailed + } + + return installationResult(didChange: true) + } + + /// Removes only the two managed installation paths and is safe to repeat. + public func uninstall() throws -> UninstallationResult { + try requireAdministratorPrivileges(command: PrivilegedHelperClient.uninstallCommand) + + var removedPaths: [String] = [] + for path in [PrivilegedPaths.helper, PrivilegedPaths.sudoersPolicy] + where fileSystem.itemExists(at: path) { + try fileSystem.removeItem(at: path) + removedPaths.append(path) + } + return UninstallationResult(removedPaths: removedPaths) + } + + private func requireAdministratorPrivileges() throws { + try requireAdministratorPrivileges(command: PrivilegedHelperClient.setupCommand) + } + + private func stageHelper(payload: String) throws -> String { + let temporary = temporaryPath(for: PrivilegedPaths.helper) + var temporaryExists = false + defer { + if temporaryExists { + try? fileSystem.removeItem(at: temporary) + } + } + + try fileSystem.copyItem(at: payload, to: temporary) + temporaryExists = true + try fileSystem.setOwner(userID: 0, groupID: 0, at: temporary) + try fileSystem.setPermissions(0o755, at: temporary) + temporaryExists = false + return temporary + } + + private func stagePolicy(_ policy: SudoersPolicy) throws -> String { + let temporary = temporaryPath(for: PrivilegedPaths.sudoersPolicy) + var temporaryExists = false + defer { + if temporaryExists { + try? fileSystem.removeItem(at: temporary) + } + } + + try fileSystem.write(policy.contents, to: temporary) + temporaryExists = true + try fileSystem.setOwner(userID: 0, groupID: 0, at: temporary) + try fileSystem.setPermissions(0o440, at: temporary) + + let validation = try runner.run("/usr/sbin/visudo", arguments: ["-cf", temporary]) + guard validation.terminationStatus == 0 else { + throw ClamshellError.sudoersValidationFailed + } + + temporaryExists = false + return temporary + } + + private func commit(helperTemporary: String, policyTemporary: String) throws { + try fileSystem.replaceItem( + at: PrivilegedPaths.sudoersPolicy, + withItemAt: policyTemporary + ) + try fileSystem.replaceItem( + at: PrivilegedPaths.helper, + withItemAt: helperTemporary + ) + } + + private func installationResult(didChange: Bool) -> InstallationResult { + InstallationResult( + helperPath: PrivilegedPaths.helper, + sudoersPolicyPath: PrivilegedPaths.sudoersPolicy, + didChange: didChange + ) + } + + private func requireAdministratorPrivileges(command: String) throws { + guard effectiveUserID == 0 else { + throw ClamshellError.administratorPrivilegesRequired( + command: command + ) + } + } + + private func helperPayloadPath() throws -> String { + let executable = URL(fileURLWithPath: executablePath).standardizedFileURL + let executableDirectory = executable.deletingLastPathComponent() + var candidates = [ + executableDirectory.appendingPathComponent("clamshellctl-helper").path + ] + + if executableDirectory.lastPathComponent == "bin" { + candidates.append( + executableDirectory + .deletingLastPathComponent() + .appendingPathComponent("libexec/clamshellctl-helper") + .path + ) + } + + guard let payload = candidates.first(where: fileSystem.isRegularFile(at:)) else { + throw ClamshellError.helperPayloadNotFound + } + return payload + } + + private func temporaryPath(for destination: String) -> String { + "\(destination).installing.\(temporarySuffix)" + } + + private func isConfigured(payload: String, policy: SudoersPolicy) throws -> Bool { + guard + fileSystem.isRegularFile(at: PrivilegedPaths.helper), + fileSystem.contentsEqual(at: payload, and: PrivilegedPaths.helper), + try fileSystem.attributes(at: PrivilegedPaths.helper) + == InstalledFileAttributes(userID: 0, groupID: 0, permissions: 0o755), + fileSystem.isRegularFile(at: PrivilegedPaths.sudoersPolicy), + try fileSystem.readText(at: PrivilegedPaths.sudoersPolicy) == policy.contents, + try fileSystem.attributes(at: PrivilegedPaths.sudoersPolicy) + == InstalledFileAttributes(userID: 0, groupID: 0, permissions: 0o440) + else { + return false + } + + let validation = try runner.run( + "/usr/sbin/visudo", + arguments: ["-cf", PrivilegedPaths.sudoersPolicy] + ) + return validation.terminationStatus == 0 + } +} diff --git a/Sources/ClamshellCore/Privilege/Installation/UninstallationResult.swift b/Sources/ClamshellCore/Privilege/Installation/UninstallationResult.swift new file mode 100644 index 0000000..12c96a2 --- /dev/null +++ b/Sources/ClamshellCore/Privilege/Installation/UninstallationResult.swift @@ -0,0 +1,7 @@ +public struct UninstallationResult: Sendable, Equatable { + public let removedPaths: [String] + + public init(removedPaths: [String]) { + self.removedPaths = removedPaths + } +} diff --git a/Sources/ClamshellCore/Privilege/PrivilegedHelperClient.swift b/Sources/ClamshellCore/Privilege/PrivilegedHelperClient.swift new file mode 100644 index 0000000..962fa83 --- /dev/null +++ b/Sources/ClamshellCore/Privilege/PrivilegedHelperClient.swift @@ -0,0 +1,26 @@ +public struct PrivilegedHelperClient: PowerStateWriting, Sendable { + public static let setupCommand = #"sudo "$(brew --prefix)/bin/clamshellctl" setup"# + public static let uninstallCommand = #"sudo "$(brew --prefix)/bin/clamshellctl" uninstall"# + + private static let executable = "/usr/bin/sudo" + private let runner: any ProcessRunning + + public init(runner: any ProcessRunning) { + self.runner = runner + } + + /// Invokes only the installed helper's `enable` or `disable` action without prompting. + public func setState(_ state: ClamshellState) throws { + let action = state == .enabled ? "enable" : "disable" + let result = try runner.run( + Self.executable, + arguments: ["-n", PrivilegedPaths.helper, action] + ) + + guard result.terminationStatus == 0 else { + throw ClamshellError.privilegedHelperUnavailable( + setupCommand: Self.setupCommand + ) + } + } +} diff --git a/Sources/ClamshellCore/Privilege/PrivilegedPaths.swift b/Sources/ClamshellCore/Privilege/PrivilegedPaths.swift new file mode 100644 index 0000000..9036e70 --- /dev/null +++ b/Sources/ClamshellCore/Privilege/PrivilegedPaths.swift @@ -0,0 +1,4 @@ +public enum PrivilegedPaths { + public static let helper = "/Library/PrivilegedHelperTools/clamshellctl-helper" + public static let sudoersPolicy = "/etc/sudoers.d/clamshellctl" +} diff --git a/Sources/ClamshellCore/Privilege/SudoersPolicy.swift b/Sources/ClamshellCore/Privilege/SudoersPolicy.swift new file mode 100644 index 0000000..c660296 --- /dev/null +++ b/Sources/ClamshellCore/Privilege/SudoersPolicy.swift @@ -0,0 +1,34 @@ +public struct SudoersPolicy: Sendable, Equatable { + public let username: String + + /// Rejects root, the sudoers `ALL` alias, empty names, and unsafe characters. + public init(username: String) throws { + guard + username != "root", + username != "ALL", + !username.isEmpty, + username.unicodeScalars.allSatisfy(Self.isSafe) + else { + throw ClamshellError.invalidUsername(username) + } + self.username = username + } + + /// Permits only the installed helper's `enable` and `disable` actions. + public var contents: String { + """ + \(username) ALL=(root) NOPASSWD: \(PrivilegedPaths.helper) enable + \(username) ALL=(root) NOPASSWD: \(PrivilegedPaths.helper) disable + + """ + } + + private static func isSafe(_ scalar: Unicode.Scalar) -> Bool { + switch scalar.value { + case 45, 46, 48...57, 65...90, 95, 97...122: + true + default: + false + } + } +} diff --git a/Sources/ClamshellCore/Process/FoundationProcessRunner.swift b/Sources/ClamshellCore/Process/FoundationProcessRunner.swift new file mode 100644 index 0000000..4763fa9 --- /dev/null +++ b/Sources/ClamshellCore/Process/FoundationProcessRunner.swift @@ -0,0 +1,87 @@ +import Dispatch +import Foundation + +public struct FoundationProcessRunner: ProcessRunning { + public init() {} + + public func run(_ executable: String, arguments: [String]) throws -> ProcessResult { + let process = Process() + let standardOutput = Pipe() + let standardError = Pipe() + + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + process.standardOutput = standardOutput + process.standardError = standardError + + try process.run() + + let collector = ProcessOutputCollector( + standardOutput: standardOutput.fileHandleForReading, + standardError: standardError.fileHandleForReading + ) + collector.start() + process.waitUntilExit() + + let output = collector.waitForOutput() + guard let outputString = String(data: output.standardOutput, encoding: .utf8) else { + throw ClamshellError.invalidProcessOutput( + executable: executable, + stream: .standardOutput + ) + } + guard let errorString = String(data: output.standardError, encoding: .utf8) else { + throw ClamshellError.invalidProcessOutput( + executable: executable, + stream: .standardError + ) + } + + return ProcessResult( + standardOutput: outputString, + standardError: errorString, + terminationStatus: process.terminationStatus + ) + } +} + +private final class ProcessOutputCollector: @unchecked Sendable { + private let standardOutput: FileHandle + private let standardError: FileHandle + private let group = DispatchGroup() + private let lock = NSLock() + private var outputData = Data() + private var errorData = Data() + + init(standardOutput: FileHandle, standardError: FileHandle) { + self.standardOutput = standardOutput + self.standardError = standardError + } + + func start() { + group.enter() + DispatchQueue.global(qos: .utility).async { + let data = self.standardOutput.readDataToEndOfFile() + self.lock.withLock { + self.outputData = data + } + self.group.leave() + } + + group.enter() + DispatchQueue.global(qos: .utility).async { + let data = self.standardError.readDataToEndOfFile() + self.lock.withLock { + self.errorData = data + } + self.group.leave() + } + } + + func waitForOutput() -> (standardOutput: Data, standardError: Data) { + group.wait() + return lock.withLock { + (outputData, errorData) + } + } +} diff --git a/Sources/ClamshellCore/Process/ProcessOutputStream.swift b/Sources/ClamshellCore/Process/ProcessOutputStream.swift new file mode 100644 index 0000000..d2113e3 --- /dev/null +++ b/Sources/ClamshellCore/Process/ProcessOutputStream.swift @@ -0,0 +1,4 @@ +public enum ProcessOutputStream: String, Sendable { + case standardOutput + case standardError +} diff --git a/Sources/ClamshellCore/Process/ProcessResult.swift b/Sources/ClamshellCore/Process/ProcessResult.swift new file mode 100644 index 0000000..2512239 --- /dev/null +++ b/Sources/ClamshellCore/Process/ProcessResult.swift @@ -0,0 +1,15 @@ +public struct ProcessResult: Sendable, Equatable { + public let standardOutput: String + public let standardError: String + public let terminationStatus: Int32 + + public init( + standardOutput: String, + standardError: String, + terminationStatus: Int32 + ) { + self.standardOutput = standardOutput + self.standardError = standardError + self.terminationStatus = terminationStatus + } +} diff --git a/Sources/ClamshellCore/Process/ProcessRunning.swift b/Sources/ClamshellCore/Process/ProcessRunning.swift new file mode 100644 index 0000000..91ca056 --- /dev/null +++ b/Sources/ClamshellCore/Process/ProcessRunning.swift @@ -0,0 +1,3 @@ +public protocol ProcessRunning: Sendable { + func run(_ executable: String, arguments: [String]) throws -> ProcessResult +} diff --git a/Sources/ClamshellCore/State/ClamshellService.swift b/Sources/ClamshellCore/State/ClamshellService.swift new file mode 100644 index 0000000..7fde67c --- /dev/null +++ b/Sources/ClamshellCore/State/ClamshellService.swift @@ -0,0 +1,53 @@ +public struct ClamshellService: Sendable { + private let stateReader: any PowerStateReading + private let stateWriter: any PowerStateWriting + + public init( + stateReader: any PowerStateReading, + stateWriter: any PowerStateWriting + ) { + self.stateReader = stateReader + self.stateWriter = stateWriter + } + + /// Avoids redundant writes and verifies the system state after a change. + public func set(_ requested: ClamshellState) throws -> TransitionResult { + let current = try stateReader.currentState() + return try set(requested, from: current) + } + + /// Reads the current state once, then applies and verifies its opposite. + public func toggle() throws -> TransitionResult { + let current = try stateReader.currentState() + let requested: ClamshellState = current == .enabled ? .disabled : .enabled + return try set(requested, from: current) + } + + private func set( + _ requested: ClamshellState, + from previous: ClamshellState + ) throws -> TransitionResult { + guard previous != requested else { + return TransitionResult( + previous: previous, + current: previous, + didChange: false + ) + } + + try stateWriter.setState(requested) + let current = try stateReader.currentState() + guard current == requested else { + throw ClamshellError.stateVerificationFailed( + expected: requested, + actual: current + ) + } + + return TransitionResult( + previous: previous, + current: current, + didChange: true + ) + } +} diff --git a/Sources/ClamshellCore/State/ClamshellState.swift b/Sources/ClamshellCore/State/ClamshellState.swift new file mode 100644 index 0000000..6f467a6 --- /dev/null +++ b/Sources/ClamshellCore/State/ClamshellState.swift @@ -0,0 +1,4 @@ +public enum ClamshellState: String, Sendable { + case enabled + case disabled +} diff --git a/Sources/ClamshellCore/State/PowerStateReading.swift b/Sources/ClamshellCore/State/PowerStateReading.swift new file mode 100644 index 0000000..4e8d1d8 --- /dev/null +++ b/Sources/ClamshellCore/State/PowerStateReading.swift @@ -0,0 +1,3 @@ +public protocol PowerStateReading: Sendable { + func currentState() throws -> ClamshellState +} diff --git a/Sources/ClamshellCore/State/PowerStateWriting.swift b/Sources/ClamshellCore/State/PowerStateWriting.swift new file mode 100644 index 0000000..ddefa9f --- /dev/null +++ b/Sources/ClamshellCore/State/PowerStateWriting.swift @@ -0,0 +1,3 @@ +public protocol PowerStateWriting: Sendable { + func setState(_ state: ClamshellState) throws +} diff --git a/Sources/ClamshellCore/State/TransitionResult.swift b/Sources/ClamshellCore/State/TransitionResult.swift new file mode 100644 index 0000000..74e0cbe --- /dev/null +++ b/Sources/ClamshellCore/State/TransitionResult.swift @@ -0,0 +1,11 @@ +public struct TransitionResult: Sendable, Equatable { + public let previous: ClamshellState + public let current: ClamshellState + public let didChange: Bool + + public init(previous: ClamshellState, current: ClamshellState, didChange: Bool) { + self.previous = previous + self.current = current + self.didChange = didChange + } +} diff --git a/Sources/ClamshellHelper/ClamshellHelper.swift b/Sources/ClamshellHelper/ClamshellHelper.swift index 4fc3e0a..47884b1 100644 --- a/Sources/ClamshellHelper/ClamshellHelper.swift +++ b/Sources/ClamshellHelper/ClamshellHelper.swift @@ -1,10 +1,33 @@ +import ClamshellCore import Darwin import Foundation @main enum ClamshellHelper { - static func main() { - FileHandle.standardError.write(Data("Invalid helper invocation.\n".utf8)) - exit(EX_USAGE) + static func main() { + guard geteuid() == 0 else { + fail("Administrator privileges are required.", exitCode: EX_NOPERM) } + + do { + let mutation = try PowerMutation( + rawArguments: Array(CommandLine.arguments.dropFirst()) + ) + let powerSettings = PowerSettingsClient(runner: FoundationProcessRunner()) + let service = ClamshellService( + stateReader: powerSettings, + stateWriter: powerSettings + ) + _ = try service.set(mutation.state) + } catch ClamshellError.invalidHelperArguments { + fail("Usage: clamshellctl-helper ", exitCode: EX_USAGE) + } catch { + fail("Unable to update battery clamshell mode.", exitCode: EX_SOFTWARE) + } + } + + private static func fail(_ message: String, exitCode: Int32) -> Never { + FileHandle.standardError.write(Data("\(message)\n".utf8)) + exit(exitCode) + } } diff --git a/Tests/ClamshellCLITests/Commands/SetupCommandTests.swift b/Tests/ClamshellCLITests/Commands/SetupCommandTests.swift new file mode 100644 index 0000000..481835d --- /dev/null +++ b/Tests/ClamshellCLITests/Commands/SetupCommandTests.swift @@ -0,0 +1,78 @@ +import ArgumentParser +import ClamshellCore +import Testing + +@testable import ClamshellCLI + +@Suite("Setup commands") +struct SetupCommandTests { + @Test("setup requires an explicit administrator invocation") + func setupRequiresRoot() throws { + let command = try #require(try ClamshellCommand.parseAsRoot(["setup"]) as? SetupCommand) + + #expect( + throws: ClamshellError.administratorPrivilegesRequired( + command: PrivilegedHelperClient.setupCommand + ) + ) { + try command.run(installation: inertInstallation()) + } + } + + @Test("uninstall requires an explicit administrator invocation") + func uninstallRequiresRoot() throws { + let command = try #require( + try ClamshellCommand.parseAsRoot(["uninstall"]) as? UninstallCommand + ) + + #expect( + throws: ClamshellError.administratorPrivilegesRequired( + command: PrivilegedHelperClient.uninstallCommand + ) + ) { + try command.run(installation: inertInstallation()) + } + } + + private func inertInstallation() -> PrivilegedInstallation { + PrivilegedInstallation( + fileSystem: InertInstallationFileSystem(), + runner: InertProcessRunner(), + effectiveUserID: 501, + environment: [:], + executablePath: "/unused" + ) + } + + private struct InertInstallationFileSystem: InstallationFileSystem { + func itemExists(at path: String) -> Bool { false } + func isRegularFile(at path: String) -> Bool { false } + func contentsEqual(at firstPath: String, and secondPath: String) -> Bool { false } + func readText(at path: String) throws -> String { throw UnexpectedOperation() } + func attributes(at path: String) throws -> InstalledFileAttributes { + throw UnexpectedOperation() + } + func copyItem(at source: String, to destination: String) throws { + throw UnexpectedOperation() + } + func write(_ contents: String, to path: String) throws { throw UnexpectedOperation() } + func setOwner(userID: UInt32, groupID: UInt32, at path: String) throws { + throw UnexpectedOperation() + } + func setPermissions(_ permissions: UInt16, at path: String) throws { + throw UnexpectedOperation() + } + func replaceItem(at destination: String, withItemAt replacement: String) throws { + throw UnexpectedOperation() + } + func removeItem(at path: String) throws { throw UnexpectedOperation() } + } + + private struct InertProcessRunner: ProcessRunning { + func run(_ executable: String, arguments: [String]) throws -> ProcessResult { + throw UnexpectedOperation() + } + } + + private struct UnexpectedOperation: Error {} +} diff --git a/Tests/ClamshellCLITests/Commands/StatusCommandTests.swift b/Tests/ClamshellCLITests/Commands/StatusCommandTests.swift new file mode 100644 index 0000000..63e1da7 --- /dev/null +++ b/Tests/ClamshellCLITests/Commands/StatusCommandTests.swift @@ -0,0 +1,13 @@ +import Testing + +@testable import ClamshellCLI + +@Suite("Status commands") +struct StatusCommandTests { + @Test("status rejects quiet output") + func quietOutputIsRejected() { + #expect(throws: Error.self) { + _ = try ClamshellCommand.parseAsRoot(["status", "--quiet"]) + } + } +} diff --git a/Tests/ClamshellCoreTests/BuildVersionTests.swift b/Tests/ClamshellCoreTests/BuildVersionTests.swift index 3094c58..0b43ab8 100644 --- a/Tests/ClamshellCoreTests/BuildVersionTests.swift +++ b/Tests/ClamshellCoreTests/BuildVersionTests.swift @@ -1,10 +1,17 @@ import Testing + @testable import ClamshellCore @Suite("Build version") struct BuildVersionTests { - @Test("starts at the planned initial version") - func initialVersion() { - #expect(BuildVersion.current == "0.1.0") - } + @Test("uses a three-component semantic version") + func semanticVersion() { + let components = BuildVersion.current.split( + separator: ".", + omittingEmptySubsequences: false + ) + + #expect(components.count == 3) + #expect(components.allSatisfy { UInt($0) != nil }) + } } diff --git a/Tests/ClamshellCoreTests/Power/PowerMutationTests.swift b/Tests/ClamshellCoreTests/Power/PowerMutationTests.swift new file mode 100644 index 0000000..8546aee --- /dev/null +++ b/Tests/ClamshellCoreTests/Power/PowerMutationTests.swift @@ -0,0 +1,112 @@ +import Foundation +import Testing + +@testable import ClamshellCore + +@Suite("Privileged power mutation") +struct PowerMutationTests { + @Test( + "accepts one exact helper action", + arguments: [ + (["enable"], ClamshellState.enabled), + (["disable"], ClamshellState.disabled), + ] + ) + func acceptedArguments(arguments: [String], expectedState: ClamshellState) throws { + #expect(try PowerMutation(rawArguments: arguments).state == expectedState) + } + + @Test("rejects every other helper argument shape") + func rejectedArguments() { + let invalidArguments = [ + [], + ["enable", "disable"], + ["--enable"], + ["Enable"], + ["1"], + ["enable", "extra"], + ] + + for arguments in invalidArguments { + #expect(throws: ClamshellError.invalidHelperArguments) { + try PowerMutation(rawArguments: arguments) + } + } + } + + @Test( + "maps states to battery-only pmset arguments", + arguments: [ + (ClamshellState.enabled, "1"), + (.disabled, "0"), + ] + ) + func pmsetArguments(state: ClamshellState, value: String) throws { + let runner = MutationRecordingRunner( + result: ProcessResult( + standardOutput: "", + standardError: "", + terminationStatus: 0 + ) + ) + + try PowerSettingsClient(runner: runner).setState(state) + + #expect( + runner.invocations == [ + MutationInvocation( + executable: "/usr/bin/pmset", + arguments: ["-b", "disablesleep", value] + ) + ]) + } + + @Test("preserves a failed mutation exit status and stderr") + func mutationFailure() { + let runner = MutationRecordingRunner( + result: ProcessResult( + standardOutput: "", + standardError: "permission denied", + terminationStatus: 77 + ) + ) + + #expect( + throws: ClamshellError.processFailed( + executable: "/usr/bin/pmset", + terminationStatus: 77, + standardError: "permission denied" + ) + ) { + try PowerSettingsClient(runner: runner).setState(.enabled) + } + } +} + +private struct MutationInvocation: Equatable { + let executable: String + let arguments: [String] +} + +private final class MutationRecordingRunner: ProcessRunning, @unchecked Sendable { + private let lock = NSLock() + private let result: ProcessResult + private var recordedInvocations: [MutationInvocation] = [] + + init(result: ProcessResult) { + self.result = result + } + + var invocations: [MutationInvocation] { + lock.withLock { recordedInvocations } + } + + func run(_ executable: String, arguments: [String]) throws -> ProcessResult { + lock.withLock { + recordedInvocations.append( + MutationInvocation(executable: executable, arguments: arguments) + ) + } + return result + } +} diff --git a/Tests/ClamshellCoreTests/Power/PowerSettingsClientTests.swift b/Tests/ClamshellCoreTests/Power/PowerSettingsClientTests.swift new file mode 100644 index 0000000..d993430 --- /dev/null +++ b/Tests/ClamshellCoreTests/Power/PowerSettingsClientTests.swift @@ -0,0 +1,85 @@ +import Foundation +import Testing + +@testable import ClamshellCore + +@Suite("Power settings client") +struct PowerSettingsClientTests { + @Test("reads the battery state with pmset custom settings") + func currentState() throws { + let runner = RecordingProcessRunner( + result: ProcessResult( + standardOutput: """ + Battery Power: + disablesleep 1 + AC Power: + disablesleep 0 + """, + standardError: "", + terminationStatus: 0 + ) + ) + + let state = try PowerSettingsClient(runner: runner).currentState() + + #expect(state == .enabled) + #expect( + runner.invocations == [ + ProcessInvocation( + executable: "/usr/bin/pmset", + arguments: ["-g", "custom"] + ) + ]) + } + + @Test("preserves a failed pmset exit status and stderr") + func processFailure() { + let runner = RecordingProcessRunner( + result: ProcessResult( + standardOutput: "", + standardError: "pmset failed", + terminationStatus: 64 + ) + ) + + #expect( + throws: ClamshellError.processFailed( + executable: "/usr/bin/pmset", + terminationStatus: 64, + standardError: "pmset failed" + ) + ) { + try PowerSettingsClient(runner: runner).currentState() + } + } +} + +private struct ProcessInvocation: Equatable { + let executable: String + let arguments: [String] +} + +private final class RecordingProcessRunner: ProcessRunning, @unchecked Sendable { + private let lock = NSLock() + private let result: ProcessResult + private var recordedInvocations: [ProcessInvocation] = [] + + init(result: ProcessResult) { + self.result = result + } + + var invocations: [ProcessInvocation] { + lock.withLock { + recordedInvocations + } + } + + func run(_ executable: String, arguments: [String]) throws -> ProcessResult { + lock.withLock { + recordedInvocations.append( + ProcessInvocation(executable: executable, arguments: arguments) + ) + } + return result + } +} diff --git a/Tests/ClamshellCoreTests/Power/PowerSettingsParserTests.swift b/Tests/ClamshellCoreTests/Power/PowerSettingsParserTests.swift new file mode 100644 index 0000000..904f4d3 --- /dev/null +++ b/Tests/ClamshellCoreTests/Power/PowerSettingsParserTests.swift @@ -0,0 +1,68 @@ +import Testing + +@testable import ClamshellCore + +@Suite("pmset parser") +struct PowerSettingsParserTests { + @Test("reads enabled from Battery Power") + func enabled() throws { + let output = """ + Battery Power: + sleep 1 + disablesleep 1 + AC Power: + sleep 1 + disablesleep 0 + """ + + #expect(try PowerSettingsParser().batteryState(from: output) == .enabled) + } + + @Test("reads disabled from Battery Power") + func disabled() throws { + let output = """ + Battery Power: + sleep 1 + disablesleep 0 + AC Power: + disablesleep 1 + """ + + #expect(try PowerSettingsParser().batteryState(from: output) == .disabled) + } + + @Test("treats an absent battery disablesleep key as disabled") + func absentMeansDisabled() throws { + let output = """ + Battery Power: + sleep 1 + AC Power: + disablesleep 1 + """ + + #expect(try PowerSettingsParser().batteryState(from: output) == .disabled) + } + + @Test("rejects output without a Battery Power section") + func missingBatterySection() { + #expect(throws: ClamshellError.self) { + try PowerSettingsParser().batteryState( + from: "AC Power:\n disablesleep 1" + ) + } + } + + @Test("rejects an unexpected battery disablesleep value") + func unexpectedValue() { + let output = """ + Battery Power: + disablesleep 2 + AC Power: + disablesleep 0 + """ + + #expect(throws: ClamshellError.self) { + try PowerSettingsParser().batteryState(from: output) + } + } +} diff --git a/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationFailureTests.swift b/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationFailureTests.swift new file mode 100644 index 0000000..3023238 --- /dev/null +++ b/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationFailureTests.swift @@ -0,0 +1,46 @@ +import Testing + +@testable import ClamshellCore + +@Suite("Privileged installation failures") +struct PrivilegedInstallationFailureTests { + @Test("rejects installation when the helper payload is missing") + func missingHelperPayload() { + let log = InstallationOperationLog() + let installation = PrivilegedInstallation( + fileSystem: RecordingInstallationFileSystem(files: [:], log: log), + runner: InstallationRecordingRunner(result: .success, log: log), + effectiveUserID: 0, + environment: ["SUDO_USER": "liam"], + executablePath: "/tmp/build/clamshellctl", + temporarySuffix: "test" + ) + + #expect(throws: ClamshellError.helperPayloadNotFound) { + try installation.install() + } + } + + @Test("rejects an installation that does not verify") + func verificationFailure() { + let log = InstallationOperationLog() + let source = "/tmp/build/clamshellctl-helper" + let fileSystem = RecordingInstallationFileSystem( + files: [source: "helper payload"], + log: log, + reportsMatchingContents: false + ) + let installation = PrivilegedInstallation( + fileSystem: fileSystem, + runner: InstallationRecordingRunner(result: .success, log: log), + effectiveUserID: 0, + environment: ["SUDO_USER": "liam"], + executablePath: "/tmp/build/clamshellctl", + temporarySuffix: "test" + ) + + #expect(throws: ClamshellError.installationVerificationFailed) { + try installation.install() + } + } +} diff --git a/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationTests.swift b/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationTests.swift new file mode 100644 index 0000000..7df88e4 --- /dev/null +++ b/Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationTests.swift @@ -0,0 +1,265 @@ +import Foundation +import Testing + +@testable import ClamshellCore + +@Suite("Privileged installation") +struct PrivilegedInstallationTests { + @Test("installs the helper and validated policy in order") + func install() throws { + let log = InstallationOperationLog() + let source = "/tmp/build/clamshellctl-helper" + let fileSystem = RecordingInstallationFileSystem( + files: [source: "helper payload"], + log: log + ) + let runner = InstallationRecordingRunner(result: .success, log: log) + let installation = PrivilegedInstallation( + fileSystem: fileSystem, + runner: runner, + effectiveUserID: 0, + environment: ["SUDO_USER": "liam"], + executablePath: "/tmp/build/clamshellctl", + temporarySuffix: "test" + ) + + let result = try installation.install() + + let helperTemporary = "\(PrivilegedPaths.helper).installing.test" + let policyTemporary = "\(PrivilegedPaths.sudoersPolicy).installing.test" + #expect( + result + == InstallationResult( + helperPath: PrivilegedPaths.helper, + sudoersPolicyPath: PrivilegedPaths.sudoersPolicy + ) + ) + #expect( + log.operations + == expectedInstallOperations( + source: source, + helperTemporary: helperTemporary, + policyTemporary: policyTemporary, + policyContents: try SudoersPolicy(username: "liam").contents + ) + ) + } + + @Test("finds a Homebrew libexec payload beside the installation prefix") + func homebrewPayload() throws { + let log = InstallationOperationLog() + let source = "/opt/homebrew/Cellar/clamshellctl/0.1.0/libexec/clamshellctl-helper" + let fileSystem = RecordingInstallationFileSystem( + files: [source: "helper payload"], + log: log + ) + let runner = InstallationRecordingRunner(result: .success, log: log) + let installation = PrivilegedInstallation( + fileSystem: fileSystem, + runner: runner, + effectiveUserID: 0, + environment: ["SUDO_USER": "liam"], + executablePath: "/opt/homebrew/Cellar/clamshellctl/0.1.0/bin/clamshellctl", + temporarySuffix: "test" + ) + + _ = try installation.install() + + #expect( + Array(log.operations.prefix(4)) == [ + .isRegularFile( + "/opt/homebrew/Cellar/clamshellctl/0.1.0/bin/clamshellctl-helper" + ), + .isRegularFile(source), + .isRegularFile(PrivilegedPaths.helper), + .copy( + source: source, + destination: "\(PrivilegedPaths.helper).installing.test" + ), + ] + ) + } + + @Test("recognises an already verified installation") + func alreadyInstalled() throws { + let log = InstallationOperationLog() + let source = "/tmp/build/clamshellctl-helper" + let fileSystem = RecordingInstallationFileSystem( + files: [source: "helper payload"], + log: log + ) + let runner = InstallationRecordingRunner(result: .success, log: log) + let installation = PrivilegedInstallation( + fileSystem: fileSystem, + runner: runner, + effectiveUserID: 0, + environment: ["SUDO_USER": "liam"], + executablePath: "/tmp/build/clamshellctl", + temporarySuffix: "test" + ) + + let first = try installation.install() + let operationCount = log.operations.count + let second = try installation.install() + let repeatedOperations = Array(log.operations.dropFirst(operationCount)) + + #expect(first.didChange) + #expect(!second.didChange) + #expect( + !repeatedOperations.contains { operation in + switch operation { + case .copy, .write, .replace: + true + default: + false + } + }) + } + + @Test("rejects setup before file access when not running as root") + func rootRequired() { + let log = InstallationOperationLog() + let installation = PrivilegedInstallation( + fileSystem: RecordingInstallationFileSystem(files: [:], log: log), + runner: InstallationRecordingRunner(result: .success, log: log), + effectiveUserID: 501, + environment: ["SUDO_USER": "liam"], + executablePath: "/tmp/build/clamshellctl", + temporarySuffix: "test" + ) + + #expect( + throws: ClamshellError.administratorPrivilegesRequired( + command: PrivilegedHelperClient.setupCommand + ) + ) { + try installation.install() + } + #expect(log.operations.isEmpty) + } + + @Test("requires the original sudo user") + func originalUserRequired() { + let log = InstallationOperationLog() + let installation = PrivilegedInstallation( + fileSystem: RecordingInstallationFileSystem(files: [:], log: log), + runner: InstallationRecordingRunner(result: .success, log: log), + effectiveUserID: 0, + environment: [:], + executablePath: "/tmp/build/clamshellctl", + temporarySuffix: "test" + ) + + #expect(throws: ClamshellError.originalUserUnavailable) { + try installation.install() + } + #expect(log.operations.isEmpty) + } + + @Test("keeps the existing policy when visudo rejects the replacement") + func failedValidation() { + let log = InstallationOperationLog() + let source = "/tmp/build/clamshellctl-helper" + let fileSystem = RecordingInstallationFileSystem( + files: [ + source: "helper payload", + PrivilegedPaths.helper: "existing helper", + PrivilegedPaths.sudoersPolicy: "existing policy", + ], + log: log + ) + let runner = InstallationRecordingRunner( + result: ProcessResult( + standardOutput: "", + standardError: "syntax error", + terminationStatus: 1 + ), + log: log + ) + let installation = PrivilegedInstallation( + fileSystem: fileSystem, + runner: runner, + effectiveUserID: 0, + environment: ["SUDO_USER": "liam"], + executablePath: "/tmp/build/clamshellctl", + temporarySuffix: "test" + ) + + #expect(throws: ClamshellError.sudoersValidationFailed) { + try installation.install() + } + #expect(fileSystem.contents(at: PrivilegedPaths.helper) == "existing helper") + #expect(fileSystem.contents(at: PrivilegedPaths.sudoersPolicy) == "existing policy") + #expect( + !log.operations.contains( + .replace( + replacement: "\(PrivilegedPaths.sudoersPolicy).installing.test", + destination: PrivilegedPaths.sudoersPolicy + ) + ) + ) + } + + @Test("removes only managed paths and is safe to repeat") + func uninstall() throws { + let log = InstallationOperationLog() + let unrelated = "/usr/local/bin/clamshellctl" + let fileSystem = RecordingInstallationFileSystem( + files: [ + PrivilegedPaths.helper: "helper", + PrivilegedPaths.sudoersPolicy: "policy", + unrelated: "unrelated", + ], + log: log + ) + let installation = PrivilegedInstallation( + fileSystem: fileSystem, + runner: InstallationRecordingRunner(result: .success, log: log), + effectiveUserID: 0, + environment: [:], + executablePath: "/tmp/build/clamshellctl", + temporarySuffix: "test" + ) + + let first = try installation.uninstall() + let second = try installation.uninstall() + + #expect(first.removedPaths == [PrivilegedPaths.helper, PrivilegedPaths.sudoersPolicy]) + #expect(second.removedPaths.isEmpty) + #expect(fileSystem.contents(at: unrelated) == "unrelated") + } + + private func expectedInstallOperations( + source: String, + helperTemporary: String, + policyTemporary: String, + policyContents: String + ) -> [InstallationOperation] { + [ + .isRegularFile(source), + .isRegularFile(PrivilegedPaths.helper), + .copy(source: source, destination: helperTemporary), + .setOwner(path: helperTemporary, userID: 0, groupID: 0), + .setPermissions(path: helperTemporary, permissions: 0o755), + .write(contents: policyContents, path: policyTemporary), + .setOwner(path: policyTemporary, userID: 0, groupID: 0), + .setPermissions(path: policyTemporary, permissions: 0o440), + .run(executable: "/usr/sbin/visudo", arguments: ["-cf", policyTemporary]), + .replace( + replacement: policyTemporary, + destination: PrivilegedPaths.sudoersPolicy + ), + .replace(replacement: helperTemporary, destination: PrivilegedPaths.helper), + .isRegularFile(PrivilegedPaths.helper), + .contentsEqual(firstPath: source, secondPath: PrivilegedPaths.helper), + .attributes(PrivilegedPaths.helper), + .isRegularFile(PrivilegedPaths.sudoersPolicy), + .readText(PrivilegedPaths.sudoersPolicy), + .attributes(PrivilegedPaths.sudoersPolicy), + .run( + executable: "/usr/sbin/visudo", + arguments: ["-cf", PrivilegedPaths.sudoersPolicy] + ), + ] + } +} diff --git a/Tests/ClamshellCoreTests/Privilege/PrivilegedHelperClientTests.swift b/Tests/ClamshellCoreTests/Privilege/PrivilegedHelperClientTests.swift new file mode 100644 index 0000000..fc2cfea --- /dev/null +++ b/Tests/ClamshellCoreTests/Privilege/PrivilegedHelperClientTests.swift @@ -0,0 +1,85 @@ +import Foundation +import Testing + +@testable import ClamshellCore + +@Suite("Privileged helper client") +struct PrivilegedHelperClientTests { + @Test( + "uses non-interactive sudo for one exact helper action", + arguments: [ + (ClamshellState.enabled, "enable"), + (.disabled, "disable"), + ] + ) + func exactInvocation(state: ClamshellState, action: String) throws { + let runner = HelperRecordingRunner( + result: ProcessResult( + standardOutput: "", + standardError: "", + terminationStatus: 0 + ) + ) + + try PrivilegedHelperClient(runner: runner).setState(state) + + #expect( + runner.invocations == [ + HelperInvocation( + executable: "/usr/bin/sudo", + arguments: [ + "-n", + "/Library/PrivilegedHelperTools/clamshellctl-helper", + action, + ] + ) + ]) + } + + @Test("replaces sudo failure details with setup guidance") + func sudoFailure() { + let runner = HelperRecordingRunner( + result: ProcessResult( + standardOutput: "arbitrary output", + standardError: "sensitive arbitrary stderr", + terminationStatus: 1 + ) + ) + + #expect( + throws: ClamshellError.privilegedHelperUnavailable( + setupCommand: #"sudo "$(brew --prefix)/bin/clamshellctl" setup"# + ) + ) { + try PrivilegedHelperClient(runner: runner).setState(.enabled) + } + } +} + +private struct HelperInvocation: Equatable { + let executable: String + let arguments: [String] +} + +private final class HelperRecordingRunner: ProcessRunning, @unchecked Sendable { + private let lock = NSLock() + private let result: ProcessResult + private var recordedInvocations: [HelperInvocation] = [] + + init(result: ProcessResult) { + self.result = result + } + + var invocations: [HelperInvocation] { + lock.withLock { recordedInvocations } + } + + func run(_ executable: String, arguments: [String]) throws -> ProcessResult { + lock.withLock { + recordedInvocations.append( + HelperInvocation(executable: executable, arguments: arguments) + ) + } + return result + } +} diff --git a/Tests/ClamshellCoreTests/Privilege/SudoersPolicyTests.swift b/Tests/ClamshellCoreTests/Privilege/SudoersPolicyTests.swift new file mode 100644 index 0000000..c46772c --- /dev/null +++ b/Tests/ClamshellCoreTests/Privilege/SudoersPolicyTests.swift @@ -0,0 +1,50 @@ +import Testing + +@testable import ClamshellCore + +@Suite("Sudoers policy") +struct SudoersPolicyTests { + @Test( + "accepts safe ASCII account names", + arguments: ["liam", "Liam1", "liam.name", "liam_name", "liam-name"] + ) + func acceptedUsername(username: String) throws { + #expect(try SudoersPolicy(username: username).username == username) + } + + @Test("generates only the two exact helper commands") + func exactPolicy() throws { + let policy = try SudoersPolicy(username: "liam") + + #expect( + policy.contents + == """ + liam ALL=(root) NOPASSWD: /Library/PrivilegedHelperTools/clamshellctl-helper enable + liam ALL=(root) NOPASSWD: /Library/PrivilegedHelperTools/clamshellctl-helper disable + + """ + ) + } + + @Test("rejects unsafe or ambiguous account names") + func rejectedUsername() { + let invalidUsernames = [ + "", + "root", + "ALL", + "liam smith", + "../liam", + "liam/name", + "liam:wheel", + "liam;command", + "liam\nroot ALL=(ALL) ALL", + "líam", + ] + + for username in invalidUsernames { + #expect(throws: ClamshellError.invalidUsername(username)) { + try SudoersPolicy(username: username) + } + } + } +} diff --git a/Tests/ClamshellCoreTests/Process/FoundationProcessRunnerTests.swift b/Tests/ClamshellCoreTests/Process/FoundationProcessRunnerTests.swift new file mode 100644 index 0000000..57b5014 --- /dev/null +++ b/Tests/ClamshellCoreTests/Process/FoundationProcessRunnerTests.swift @@ -0,0 +1,23 @@ +import Testing + +@testable import ClamshellCore + +@Suite("Foundation process runner") +struct FoundationProcessRunnerTests { + @Test("captures separate output streams and the exit status") + func capturesProcessResult() throws { + let result = try FoundationProcessRunner().run( + "/bin/sh", + arguments: ["-c", "printf output; printf error >&2; exit 7"] + ) + + #expect( + result + == ProcessResult( + standardOutput: "output", + standardError: "error", + terminationStatus: 7 + ) + ) + } +} diff --git a/Tests/ClamshellCoreTests/State/ClamshellServiceTests.swift b/Tests/ClamshellCoreTests/State/ClamshellServiceTests.swift new file mode 100644 index 0000000..b98f6be --- /dev/null +++ b/Tests/ClamshellCoreTests/State/ClamshellServiceTests.swift @@ -0,0 +1,113 @@ +import Foundation +import Testing + +@testable import ClamshellCore + +@Suite("Clamshell service") +struct ClamshellServiceTests { + @Test( + "applies only required state transitions", + arguments: [ + (ClamshellState.disabled, ClamshellState.disabled, false), + (.disabled, .enabled, true), + (.enabled, .enabled, false), + (.enabled, .disabled, true), + ] + ) + func transition( + current: ClamshellState, + requested: ClamshellState, + shouldMutate: Bool + ) throws { + let power = RecordingPowerSettings(current: current) + let service = ClamshellService(stateReader: power, stateWriter: power) + + let result = try service.set(requested) + + #expect( + result + == TransitionResult( + previous: current, + current: requested, + didChange: shouldMutate + ) + ) + #expect(power.requestedStates == (shouldMutate ? [requested] : [])) + } + + @Test("rejects a mutation that does not reach the requested state") + func verificationFailure() { + let power = RecordingPowerSettings( + current: .disabled, + stateAfterMutation: .disabled + ) + let service = ClamshellService(stateReader: power, stateWriter: power) + + #expect( + throws: ClamshellError.stateVerificationFailed( + expected: .enabled, + actual: .disabled + ) + ) { + try service.set(.enabled) + } + } + + @Test("toggles from a fresh state and verifies the result") + func toggle() throws { + let power = RecordingPowerSettings(current: .enabled) + let service = ClamshellService(stateReader: power, stateWriter: power) + + let result = try service.toggle() + + #expect( + result + == TransitionResult( + previous: .enabled, + current: .disabled, + didChange: true + ) + ) + #expect(power.requestedStates == [.disabled]) + #expect(power.readCount == 2) + } +} + +private final class RecordingPowerSettings: + PowerStateReading, + PowerStateWriting, + @unchecked Sendable +{ + private let lock = NSLock() + private let stateAfterMutation: ClamshellState? + private var state: ClamshellState + private var recordedStates: [ClamshellState] = [] + private var recordedReadCount = 0 + + init(current: ClamshellState, stateAfterMutation: ClamshellState? = nil) { + state = current + self.stateAfterMutation = stateAfterMutation + } + + var requestedStates: [ClamshellState] { + lock.withLock { recordedStates } + } + + var readCount: Int { + lock.withLock { recordedReadCount } + } + + func currentState() throws -> ClamshellState { + lock.withLock { + recordedReadCount += 1 + return state + } + } + + func setState(_ requested: ClamshellState) throws { + lock.withLock { + recordedStates.append(requested) + state = stateAfterMutation ?? requested + } + } +} diff --git a/Tests/ClamshellCoreTests/Support/InstallationTestSupport.swift b/Tests/ClamshellCoreTests/Support/InstallationTestSupport.swift new file mode 100644 index 0000000..421726c --- /dev/null +++ b/Tests/ClamshellCoreTests/Support/InstallationTestSupport.swift @@ -0,0 +1,191 @@ +import Foundation + +@testable import ClamshellCore + +enum InstallationOperation: Equatable { + case itemExists(String) + case isRegularFile(String) + case contentsEqual(firstPath: String, secondPath: String) + case readText(String) + case attributes(String) + case copy(source: String, destination: String) + case setOwner(path: String, userID: UInt32, groupID: UInt32) + case setPermissions(path: String, permissions: UInt16) + case replace(replacement: String, destination: String) + case write(contents: String, path: String) + case remove(String) + case run(executable: String, arguments: [String]) +} + +final class InstallationOperationLog: @unchecked Sendable { + private let lock = NSLock() + private var recordedOperations: [InstallationOperation] = [] + + var operations: [InstallationOperation] { + lock.withLock { recordedOperations } + } + + func append(_ operation: InstallationOperation) { + lock.withLock { + recordedOperations.append(operation) + } + } +} + +final class RecordingInstallationFileSystem: + InstallationFileSystem, + @unchecked Sendable +{ + private let lock = NSLock() + private let log: InstallationOperationLog + private let reportsMatchingContents: Bool + private var files: [String: String] + private var fileAttributes: [String: InstalledFileAttributes] + + init( + files: [String: String], + log: InstallationOperationLog, + reportsMatchingContents: Bool = true + ) { + self.files = files + fileAttributes = Dictionary( + uniqueKeysWithValues: files.keys.map { + ($0, InstalledFileAttributes(userID: 501, groupID: 20, permissions: 0o755)) + } + ) + self.log = log + self.reportsMatchingContents = reportsMatchingContents + } + + func itemExists(at path: String) -> Bool { + log.append(.itemExists(path)) + return lock.withLock { files[path] != nil } + } + + func isRegularFile(at path: String) -> Bool { + log.append(.isRegularFile(path)) + return lock.withLock { files[path] != nil } + } + + func contentsEqual(at firstPath: String, and secondPath: String) -> Bool { + log.append(.contentsEqual(firstPath: firstPath, secondPath: secondPath)) + return reportsMatchingContents && lock.withLock { files[firstPath] == files[secondPath] } + } + + func readText(at path: String) throws -> String { + log.append(.readText(path)) + return try lock.withLock { + guard let contents = files[path] else { + throw ClamshellError.installationVerificationFailed + } + return contents + } + } + + func attributes(at path: String) throws -> InstalledFileAttributes { + log.append(.attributes(path)) + return try lock.withLock { + guard let attributes = fileAttributes[path] else { + throw ClamshellError.installationVerificationFailed + } + return attributes + } + } + + func copyItem(at source: String, to destination: String) throws { + log.append(.copy(source: source, destination: destination)) + try lock.withLock { + guard let contents = files[source] else { + throw ClamshellError.helperPayloadNotFound + } + files[destination] = contents + fileAttributes[destination] = fileAttributes[source] + } + } + + func write(_ contents: String, to path: String) throws { + log.append(.write(contents: contents, path: path)) + lock.withLock { + files[path] = contents + fileAttributes[path] = InstalledFileAttributes( + userID: 0, + groupID: 0, + permissions: 0o600 + ) + } + } + + func setOwner(userID: UInt32, groupID: UInt32, at path: String) throws { + log.append(.setOwner(path: path, userID: userID, groupID: groupID)) + try lock.withLock { + guard let attributes = fileAttributes[path] else { + throw ClamshellError.installationVerificationFailed + } + fileAttributes[path] = InstalledFileAttributes( + userID: userID, + groupID: groupID, + permissions: attributes.permissions + ) + } + } + + func setPermissions(_ permissions: UInt16, at path: String) throws { + log.append(.setPermissions(path: path, permissions: permissions)) + try lock.withLock { + guard let attributes = fileAttributes[path] else { + throw ClamshellError.installationVerificationFailed + } + fileAttributes[path] = InstalledFileAttributes( + userID: attributes.userID, + groupID: attributes.groupID, + permissions: permissions + ) + } + } + + func replaceItem(at destination: String, withItemAt replacement: String) throws { + log.append(.replace(replacement: replacement, destination: destination)) + try lock.withLock { + guard let contents = files.removeValue(forKey: replacement) else { + throw ClamshellError.helperPayloadNotFound + } + files[destination] = contents + fileAttributes[destination] = fileAttributes.removeValue(forKey: replacement) + } + } + + func removeItem(at path: String) throws { + log.append(.remove(path)) + lock.withLock { + _ = files.removeValue(forKey: path) + _ = fileAttributes.removeValue(forKey: path) + } + } + + func contents(at path: String) -> String? { + lock.withLock { files[path] } + } +} + +final class InstallationRecordingRunner: ProcessRunning, @unchecked Sendable { + private let result: ProcessResult + private let log: InstallationOperationLog + + init(result: ProcessResult, log: InstallationOperationLog) { + self.result = result + self.log = log + } + + func run(_ executable: String, arguments: [String]) throws -> ProcessResult { + log.append(.run(executable: executable, arguments: arguments)) + return result + } +} + +extension ProcessResult { + static let success = ProcessResult( + standardOutput: "", + standardError: "", + terminationStatus: 0 + ) +} diff --git a/docs/clamshellctl-design.md b/docs/clamshellctl-design.md index 2ed29df..229c6bb 100644 --- a/docs/clamshellctl-design.md +++ b/docs/clamshellctl-design.md @@ -103,7 +103,7 @@ Homebrew installs the public CLI and an unprivileged helper payload. The compani The documented setup command runs the installed CLI with `sudo`, using its explicit Homebrew path where the administrator's secure path does not include Homebrew. Setup then: -- copies the helper to `/usr/local/libexec/clamshellctl-helper`; +- copies the helper to `/Library/PrivilegedHelperTools/clamshellctl-helper`; - sets ownership to `root:wheel` and mode `0755`; - writes `/etc/sudoers.d/clamshellctl` with mode `0440`; - permits the current user to run only the helper's exact `enable` and `disable` commands without a password; @@ -160,30 +160,45 @@ Diagnostic command output is included only when it is safe and useful. No operat clamshellctl/ ├── .github/ │ ├── ISSUE_TEMPLATE/ -│ └── workflows/ +│ ├── workflows/ +│ ├── CODEOWNERS +│ ├── CONTRIBUTING.md +│ ├── SECURITY.md +│ └── SUPPORT.md ├── Sources/ │ ├── ClamshellCore/ +│ │ ├── Errors/ +│ │ ├── Power/ +│ │ ├── Privilege/Installation/ +│ │ ├── Process/ +│ │ ├── State/ +│ │ └── Timing/ # Added with timed enablement │ ├── ClamshellCLI/ +│ │ └── Commands/ │ └── ClamshellHelper/ ├── App/ │ ├── ClamshellApp/ │ └── ClamshellControl/ -├── project.yml ├── Tests/ -│ └── ClamshellCoreTests/ +│ ├── ClamshellCoreTests/ +│ │ ├── Power/ +│ │ ├── Privilege/Installation/ +│ │ ├── Process/ +│ │ ├── State/ +│ │ └── Support/ +│ └── ClamshellCLITests/Commands/ ├── docs/ │ ├── assets/ │ └── clamshellctl-design.md +├── scripts/ ├── .release-please-manifest.json ├── CHANGELOG.md -├── CONTRIBUTING.md ├── LICENSE ├── Package.swift ├── README.md -├── SECURITY.md -├── SUPPORT.md ├── release-please-config.json -└── version.txt +├── version.txt +└── project.yml # Added with the native companion ``` The approved transparent artwork is stored as `docs/assets/clamshellctl.png` and used near the top of the README. @@ -260,7 +275,7 @@ The initial public setup includes: Initial issues cover the core CLI, privileged setup, timed enablement, native companion feasibility, Control Centre integration, DMG packaging, documentation, Homebrew publication, and release automation. A later issue covers USB-C disconnect automation. -After the repository is public and its README is ready, `LMLiam/LMLiam` is updated to feature `clamshellctl` on the GitHub profile. +After the first public release succeeds, `LMLiam/LMLiam` is updated to feature `clamshellctl` on the GitHub profile. ## Success criteria diff --git a/docs/coderabbit-implementation-plan.md b/docs/coderabbit-implementation-plan.md new file mode 100644 index 0000000..b4d6543 --- /dev/null +++ b/docs/coderabbit-implementation-plan.md @@ -0,0 +1,212 @@ +# CodeRabbit Configuration Implementation Plan + +**Goal:** Add a version-controlled CodeRabbit policy that produces strict, +high-signal reviews without spending review allowance on drafts or incremental +pushes. + +**Architecture:** `.coderabbit.yaml` owns review timing, presentation, +pre-merge checks, tool configuration, and path-specific guidance. The existing +repository-quality design records the policy, while CodeRabbit's manual +pre-merge command verifies the effective configuration on pull request #11. + +**Tech stack:** CodeRabbit schema v2, YAML, SwiftLint, ShellCheck, GitHub pull +requests. + +--- + +## Task 1: Add the repository configuration + +**Files:** + +- Create: `.coderabbit.yaml` + +- [ ] **Step 1: Replace the minimal configuration** + +Use this complete configuration: + +```yaml +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: "en-GB" +early_access: false + +reviews: + profile: "assertive" + request_changes_workflow: true + high_level_summary: true + high_level_summary_in_walkthrough: true + high_level_summary_instructions: >- + Summarise user-visible behaviour, security boundaries, and verification in + concise British English. Omit release-note filler. + review_status: true + review_details: false + commit_status: true + fail_commit_status: false + collapse_walkthrough: true + changed_files_summary: true + sequence_diagrams: false + estimate_code_review_effort: false + assess_linked_issues: true + related_issues: false + related_prs: false + suggested_labels: false + auto_apply_labels: false + suggested_reviewers: false + in_progress_fortune: false + poem: false + enable_prompt_for_ai_agents: false + abort_on_close: true + + auto_review: + enabled: true + auto_incremental_review: false + drafts: false + + finishing_touches: + docstrings: + enabled: false + + pre_merge_checks: + docstrings: + mode: "off" + title: + mode: "off" + description: + mode: "warning" + issue_assessment: + mode: "warning" + + path_instructions: + - path: "**/*" + instructions: | + Report only findings with a concrete correctness, security, or + maintainability consequence. State the consequence and the smallest + suitable remedy. Do not request cosmetic churn, redundant comments, + or documentation for self-explanatory implementation details. Verify + each finding against the current code and repository conventions. + - path: "**/*.swift" + instructions: | + Follow the Google Swift Style Guide and the repository SwiftLint + policy. Prefer idiomatic Swift 6, narrow access control, Sendable-safe + concurrency, typed errors, and explicit failure handling. Treat one + primary responsibility per file as a design goal, not a rigid + one-declaration rule. + - path: "Sources/ClamshellCore/Privilege/**/*.swift" + instructions: | + Treat helper actions, sudoers generation, filesystem replacement, + ownership, permissions, username handling, and process construction as + security boundaries. Check exact allow-lists and failure consistency. + - path: "Sources/ClamshellHelper/**/*.swift" + instructions: | + Treat every accepted argument and privileged command as part of the + root boundary. Reject broader command shapes and require observable + verification of requested state changes. + - path: "Tests/**/*.swift" + instructions: | + Review observable behaviour, failure paths, and privilege constraints. + Tests must not mutate live power settings or privileged system files. + Do not request assertions about private implementation or call order + unless order is part of the contract. + - path: ".github/workflows/**" + instructions: | + Check immutable action pinning, least-privilege permissions, untrusted + pull-request code execution, concurrency, timeouts, and fail-closed + status handling. Request comments only for non-obvious security + constraints or necessary tool suppressions. + - path: "scripts/**" + instructions: | + Check quoting, exit-status preservation, temporary-file cleanup, and + fail-closed behaviour. Prefer portable shell accepted by ShellCheck. + - path: "**/*.md" + instructions: | + Check technical accuracy against current behaviour. Use concise British + English. Reject stale paths, unsupported claims, and internal AI + process metadata. + + tools: + swiftlint: + enabled: true + config_file: ".swiftlint.yml" + shellcheck: + enabled: true + +chat: + auto_reply: true +``` + +- [ ] **Step 2: Parse the YAML locally** + +Run: + +```bash +ruby -ryaml -e ' + config = YAML.safe_load(File.read(".coderabbit.yaml"), aliases: false) + abort "invalid CodeRabbit config" unless + config.dig("reviews", "profile") == "assertive" && + config.dig("reviews", "auto_review", "drafts") == false && + config.dig("reviews", "auto_review", "auto_incremental_review") == false && + config.dig("reviews", "pre_merge_checks", "docstrings", "mode") == "off" +' +``` + +Expected: exit status 0 with no output. + +- [ ] **Step 3: Check formatting and the repository diff** + +Run: + +```bash +git diff --check +scripts/check.sh +``` + +Expected: no whitespace errors; formatting, SwiftLint, tests, builds, and +workflow lint all pass. + +### Task 2: Publish and verify the effective policy + +**Files:** + +- Add: `.coderabbit.yaml` +- Verify: pull request #11 metadata and CodeRabbit walkthrough + +- [ ] **Step 1: Commit the configuration** + +Run: + +```bash +git add .coderabbit.yaml +git commit -m "ci(review): configure high-signal CodeRabbit reviews" +git push +``` + +Expected: only `.coderabbit.yaml` is included in the commit. + +- [ ] **Step 2: Verify pull-request issue coverage** + +Confirm that pull request #11 states that it partially addresses issues #7 and #8 +without closing either issue. + +- [ ] **Step 3: Run the configured pre-merge checks** + +Post this pull-request comment: + +```text +@coderabbitai run pre-merge checks +``` + +Expected: the docstring and title checks are disabled. Description and linked +issue assessment run as warnings, with the issue assessment recognising #7 and #8 +as partial scope. + +- [ ] **Step 4: Verify the final review state** + +Run: + +```bash +gh pr checks 11 --repo LMLiam/clamshellctl +git status --short --branch +``` + +Expected: required checks pass or remain in progress with no configuration +failure; no CodeRabbit-authored review thread remains unresolved; `.vscode/` +remains untouched. diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index e01c480..3e819e6 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -16,21 +16,19 @@ The implementation uses responsibility-based files rather than grouping unrelate ```text Package.swift SwiftPM products, targets, dependency versions -Sources/ClamshellCore/BuildVersion.swift Release-please managed version -Sources/ClamshellCore/ClamshellState.swift Enabled and disabled domain states -Sources/ClamshellCore/PowerSettingsParser.swift Battery-section pmset parsing -Sources/ClamshellCore/ProcessRunner.swift Process boundary and Foundation adapter -Sources/ClamshellCore/PowerSettingsClient.swift Read and mutate pmset through ProcessRunner -Sources/ClamshellCore/ClamshellService.swift Idempotent state transitions -Sources/ClamshellCore/Duration.swift Strict m, h, and d duration parsing -Sources/ClamshellCore/TimerMetadata.swift Codable absolute timer deadline -Sources/ClamshellCore/TimerController.swift LaunchAgent lifecycle and expiry decisions -Sources/ClamshellCore/PrivilegedInstallation.swift Root setup and uninstall rules -Sources/ClamshellCore/SudoersPolicy.swift Exact sudoers document generation -Sources/ClamshellCore/ClamshellError.swift Domain errors and exit-code mapping +Sources/ClamshellCore/BuildVersion.swift Release-please-managed version +Sources/ClamshellCore/Errors/*.swift Domain errors and user-facing recovery +Sources/ClamshellCore/Power/*.swift pmset parsing, reads, writes, and helper arguments +Sources/ClamshellCore/Privilege/*.swift Helper client, paths, and sudoers policy +Sources/ClamshellCore/Privilege/Installation/*.swift Root setup, verification, and removal +Sources/ClamshellCore/Process/*.swift Process values, protocol, and Foundation adapter +Sources/ClamshellCore/State/*.swift State contracts and idempotent transitions +Sources/ClamshellCore/Timing/*.swift Duration and LaunchAgent lifecycle added in Phase 4 Sources/ClamshellCLI/ClamshellCommand.swift Root ArgumentParser command and composition root Sources/ClamshellCLI/Commands/*.swift One public command per file +Sources/ClamshellCLI/CommandComposition.swift Production dependency composition Sources/ClamshellCLI/Console.swift Quiet-aware stdout and stderr rendering +Sources/ClamshellCLI/OutputOptions.swift Shared command output flags Sources/ClamshellHelper/ClamshellHelper.swift Minimal privileged executable entry point App/ClamshellApp/ClamshellApp.swift Setup-only SwiftUI application entry point App/ClamshellApp/SetupView.swift First-run, diagnostics, and removal interface @@ -42,14 +40,16 @@ App/ClamshellControl/SetBatteryClamshellIntent.swift Exact-state App Intent acti App/ClamshellControl/Info.plist WidgetKit extension metadata App/ClamshellAppTests/*.swift Companion model tests App/ClamshellControlTests/*.swift Control model tests -Tests/ClamshellCoreTests/*.swift Swift Testing suites by responsibility -Tests/ClamshellCLITests/*.swift Black-box CLI tests without privilege changes +Tests/ClamshellCoreTests/{Power,Privilege,Process,State}/*.swift Domain suites +Tests/ClamshellCoreTests/Support/*.swift Shared test support by responsibility +Tests/ClamshellCLITests/Commands/*.swift Black-box CLI tests without privilege changes project.yml Deterministic XcodeGen app project definition scripts/embed-command-products.sh Bundle release CLI and helper payloads scripts/package-dmg.sh Build the ad-hoc-signed release DMG Tests/Scripts/run-dmg-packaging-tests.sh DMG structure and input validation tests scripts/generate-homebrew-formula.sh Deterministic formula generation -scripts/validate-conventional-title.sh Pull-request title validation +scripts/check-conventional-subject.sh Commit and pull-request title validation +scripts/check.sh Complete local quality gate .github/workflows/ci.yml macOS build and test checks .github/workflows/pr.yml Conventional pull-request title check .github/workflows/release.yml release-please and tap publication @@ -59,9 +59,9 @@ version.txt Simple release strategy versi Formula/clamshellctl.rb Generated formula fixture for validation docs/assets/clamshellctl.png Approved transparent README artwork README.md Installation, safety, use, and troubleshooting -CONTRIBUTING.md Development and commit conventions -SECURITY.md Privilege boundary and reporting policy -SUPPORT.md Support channels and diagnostics +.github/CONTRIBUTING.md Development and commit conventions +.github/SECURITY.md Privilege boundary and reporting policy +.github/SUPPORT.md Support channels and diagnostics LICENSE MIT licence ``` @@ -489,8 +489,8 @@ git commit -m "feat(helper): restrict privileged power mutations" Assert that the client runs only: ```text -/usr/bin/sudo -n /usr/local/libexec/clamshellctl-helper enable -/usr/bin/sudo -n /usr/local/libexec/clamshellctl-helper disable +/usr/bin/sudo -n /Library/PrivilegedHelperTools/clamshellctl-helper enable +/usr/bin/sudo -n /Library/PrivilegedHelperTools/clamshellctl-helper disable ``` Map sudo's non-zero result to an error that names the setup command without echoing arbitrary stderr. @@ -531,7 +531,7 @@ git commit -m "feat(cli): add clamshell control commands" - [ ] **Step 1: Test username validation and exact policy text** -Accept only ASCII usernames matching `[A-Za-z0-9._-]+`. The generated policy contains exactly two non-comment command rules, one for `enable` and one for `disable`, both targeting `/usr/local/libexec/clamshellctl-helper`. Reject whitespace, path separators, shell punctuation, empty names, and newlines. +Accept only ASCII usernames matching `[A-Za-z0-9._-]+`. The generated policy contains exactly two non-comment command rules, one for `enable` and one for `disable`, both targeting `/Library/PrivilegedHelperTools/clamshellctl-helper`. Reject whitespace, path separators, shell punctuation, empty names, and newlines. - [ ] **Step 2: Test installation operations through an injected file system** @@ -545,7 +545,7 @@ Resolve the original user from validated `SUDO_USER`; never default to root. Loc Run: `swift test --filter SudoersPolicyTests && swift test --filter PrivilegedInstallationTests` -Expected: all tests use temporary directories and recording runners; `/usr/local/libexec` and `/etc/sudoers.d` remain unchanged. +Expected: all tests use temporary directories and recording runners; `/Library/PrivilegedHelperTools` and `/etc/sudoers.d` remain unchanged. - [ ] **Step 5: Commit installation services** @@ -1081,9 +1081,9 @@ git commit -m "build(dmg): package self-contained companion" **Files:** - Modify: `README.md` -- Create: `CONTRIBUTING.md` -- Create: `SECURITY.md` -- Create: `SUPPORT.md` +- Create: `.github/CONTRIBUTING.md` +- Create: `.github/SECURITY.md` +- Create: `.github/SUPPORT.md` - Create: `.markdownlint-cli2.jsonc` - [ ] **Step 1: Write the complete README** @@ -1092,18 +1092,18 @@ Cover purpose, warning and scope, separate Homebrew and DMG installation paths, - [ ] **Step 2: Write maintenance policies** -`CONTRIBUTING.md` requires Swift 6, `swift format`, `swift test`, Conventional Commits, and `verb(area): description`. `SECURITY.md` explains the helper and sudoers boundary and provides private reporting instructions. `SUPPORT.md` lists safe diagnostic commands and forbids posting sudoers contents containing unexpected local customisations without review. Configure markdownlint with `MD013` disabled so prose uses semantic lines without an arbitrary rendered-width limit; keep all other default rules enabled. +`CONTRIBUTING.md` requires Swift 6, `scripts/check.sh`, Conventional Commits, and `verb(area): description`. `SECURITY.md` explains the helper and sudoers boundary and provides private reporting instructions. `SUPPORT.md` lists safe diagnostic commands and forbids posting sudoers contents containing unexpected local customisations without review. Configure markdownlint with `MD013` disabled so prose uses semantic lines without an arbitrary rendered-width limit; keep all other default rules enabled. - [ ] **Step 3: Check links and prose** -Run: `npx --yes markdownlint-cli2 '**/*.md' '#.build'` +Run: `scripts/check.sh && npx --yes markdownlint-cli2 '**/*.md' '#.build'` Expected: no Markdown errors. Manually verify every relative link and ensure no internal tooling paths or process notes exist anywhere in the repository. - [ ] **Step 4: Commit documentation** ```bash -git add .markdownlint-cli2.jsonc README.md CONTRIBUTING.md SECURITY.md SUPPORT.md docs +git add .markdownlint-cli2.jsonc README.md .github docs git commit -m "docs(project): document installation and maintenance" ``` @@ -1115,8 +1115,7 @@ git commit -m "docs(project): document installation and maintenance" - Create: `.github/workflows/ci.yml` - Create: `.github/workflows/pr.yml` -- Create: `scripts/validate-conventional-title.sh` -- Create: `Tests/Scripts/run-title-tests.sh` +- Create: `scripts/check-conventional-subject.sh` - [ ] **Step 1: Test the title validator** @@ -1128,21 +1127,20 @@ Use a portable anchored regular expression for the allowed Conventional Commit v - [ ] **Step 3: Add least-privilege workflows** -`ci.yml` runs on pushes to `main` and pull requests, uses a macOS runner, checks out pinned action SHAs, installs XcodeGen, runs `swift package resolve`, `swift format lint --recursive --strict .`, `swift test`, `swift build -c release`, `xcodegen generate`, and an ad-hoc-signed `xcodebuild` for `ClamshellApp`. It grants `contents: read` only. +`ci.yml` runs on pushes to `main` and pull requests, uses macOS runners, checks out pinned action SHAs, and runs formatting, SwiftLint, tests, and debug and release builds. Add XcodeGen and an unsigned `xcodebuild` when the native targets exist. It grants `contents: read` only. -`pr.yml` validates the pull-request title without checking out or executing pull-request code. It grants `pull-requests: read` only. +`pr.yml` validates the pull-request title and commit subjects. It checks out the +trusted base and pull-request head. It executes only the trusted validator and +grants `contents: read` only. - [ ] **Step 4: Verify locally and with actionlint** Run: ```bash -bash Tests/Scripts/run-title-tests.sh -swift format lint --recursive --strict . -swift test -xcodegen generate -xcodebuild -project Clamshell.xcodeproj -scheme ClamshellApp -configuration Debug -destination 'platform=macOS' CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= build -actionlint +scripts/check-conventional-subject.sh "feat(cli): add toggle command" +! scripts/check-conventional-subject.sh "feat: add toggle command" +scripts/check.sh ``` Install `actionlint` with `brew install actionlint` first when it is not already available. Expected: all local checks pass. @@ -1150,7 +1148,7 @@ Install `actionlint` with `brew install actionlint` first when it is not already - [ ] **Step 5: Commit CI** ```bash -git add .github/workflows scripts Tests/Scripts +git add .github/workflows scripts git commit -m "ci(checks): verify Swift and pull-request quality" ``` @@ -1168,7 +1166,12 @@ git commit -m "ci(checks): verify Swift and pull-request quality" Use `release-type: simple`, root package `.`, `include-v-in-tag: true`, `include-component-in-tag: false`, `bump-minor-pre-major: true`, and `bump-patch-for-minor-pre-major: false`. Configure generic updaters for `Sources/ClamshellCore/BuildVersion.swift` and both `MARKETING_VERSION` entries in `project.yml`. Use the agreed visible changelog sections and hide tests and routine chores. -Initial manifest content is `{}` and the root package sets `initial-version: "0.1.0"`; the first release PR therefore targets `0.1.0`. `version.txt` starts at `0.1.0` and must remain identical to `BuildVersion.current` and the two app `MARKETING_VERSION` values. The consistency script compares the manifest only after release-please has recorded a root version. +Initial manifest content is `{ ".": "0.0.0" }`. Do not set `initial-version`; +with `bump-minor-pre-major: true`, the first feature release targets `0.1.0`. +`version.txt` starts at `0.1.0` and must remain identical to +`BuildVersion.current` and the two app `MARKETING_VERSION` values. The +consistency script treats the synthetic manifest version as the pre-release +bootstrap value until release-please records the first release. - [ ] **Step 2: Add configuration validation** @@ -1341,14 +1344,11 @@ Close the launch milestone only after the GitHub Release, public DMG, Homebrew i Before declaring the MVP complete, run: ```bash -swift format lint --recursive --strict . -swift test -swift build -c release -xcodegen generate -xcodebuild -project Clamshell.xcodeproj -scheme ClamshellApp -configuration Release -destination 'platform=macOS' CODE_SIGN_IDENTITY=- DEVELOPMENT_TEAM= test -actionlint +scripts/check.sh bash scripts/check-version-consistency.sh -bash Tests/Scripts/run-title-tests.sh +while IFS= read -r subject; do + scripts/check-conventional-subject.sh "$subject" +done < <(git log --format=%s origin/main..HEAD) bash Tests/Scripts/run-dmg-packaging-tests.sh bash Tests/Scripts/run-formula-generator-tests.sh bash Tests/Scripts/run-publish-formula-tests.sh diff --git a/docs/repository-quality-design.md b/docs/repository-quality-design.md new file mode 100644 index 0000000..205347c --- /dev/null +++ b/docs/repository-quality-design.md @@ -0,0 +1,306 @@ +# Repository Quality Design + +## Summary + +This document defines the quality standard for `clamshellctl`. It covers Swift +style enforcement, source organisation, continuous integration, repository +governance, security scanning, and the GitGuardian test-fixture finding. + +The goal is an idiomatic and maintainable Swift project whose local checks and +GitHub checks produce the same result. The work must preserve the existing CLI +behaviour and privileged-operation boundaries. + +## Style enforcement + +The project follows the [Google Swift Style Guide][google-swift-style]. Two +tools enforce separate parts of that standard: + +- `swift-format` owns source formatting. Its checked-in `.swift-format` + configuration uses two-space indentation, a 100-column line limit, + deterministic imports, and the applicable Google conventions. +- [SwiftLint][swiftlint] owns maintainability and correctness rules that + formatting cannot express. Its checked-in `.swiftlint.yml` covers naming, + API hygiene, unsafe constructs, complexity, and selected opt-in rules. + +SwiftLint is a standalone development tool. It is not a production package +dependency. Local verification requires version 0.65.0. The GitHub +`macos-26` runner also includes this version. This configuration keeps lint +tools out of production builds and CodeQL builds. Local checks and CI use the +same rules. + +SwiftLint configuration must be deliberate. The project does not enable every +opt-in rule, duplicate formatting rules owned by `swift-format`, or accept a +baseline of existing violations. A suppression must be limited to the smallest +declaration or line and include a reason when the code does not make the reason +obvious. + +`.editorconfig` defines editor-neutral properties for Swift and repository +files: UTF-8, LF endings, a final newline, trailing-whitespace handling, and +two-space indentation where appropriate. It complements the Swift tools; it is +not the source of truth for Swift syntax formatting. + +## Documentation style + +All documentation uses ASD-STE100 Simplified Technical English and British +English spelling. Sentences are short and use active voice. Each instruction +contains one action. Each item or action has one term. + +Technical accuracy has priority over the controlled vocabulary. Product names, +commands, code identifiers, API names, quoted interface text, and standard +names can use their required terms. + +## Source organisation + +Directories describe responsibilities rather than current consumers. The core +module uses these domains: + +```text +Sources/ClamshellCore/ +├── Errors/ +├── Power/ +├── Privilege/ +│ └── Installation/ +├── Process/ +├── State/ +└── Timing/ +``` + +- `State` owns the clamshell state and state-transition decisions. +- `Power` owns `pmset` parsing and the read or mutation boundary. +- `Process` owns process invocation values, protocols, and Foundation adapters. +- `Privilege` owns the helper client and the exact sudoers policy. +- `Privilege/Installation` owns privileged installation and removal workflows, + their filesystem boundary, and operation results. +- `Timing` owns duration parsing, timer metadata, and timer lifecycle rules. +- `Errors` owns domain errors and their stable presentation or exit semantics. + +CLI subcommands remain in `Sources/ClamshellCLI/Commands`. Command composition, +console output, and entry-point concerns use focused files outside that folder. +The helper remains a thin executable target. Future app and control-extension +folders continue to follow the architecture in `clamshellctl-design.md`. + +Tests mirror production responsibilities: + +```text +Tests/ClamshellCoreTests/ +├── Power/ +├── Privilege/ +│ └── Installation/ +├── Process/ +├── State/ +├── Support/ +└── Timing/ +``` + +Reusable test infrastructure belongs in `Support`. A fake used by one suite +remains private in that suite's file. + +## File and declaration boundaries + +A production file has one primary responsibility, usually represented by one +primary top-level type. This is a design rule rather than a declaration-count +rule. Closely related declarations may share a file when separating them would +make the code harder to understand. Examples include: + +- A protocol beside its sole production implementation. +- A small result value beside the operation that returns it. +- A private helper used only by the file's primary type. +- A test suite and its suite-specific private fakes. + +Independent public types, reusable protocols, and unrelated operation results +use separate files. Existing mixed-responsibility files are split where they +cross these boundaries. In particular, privileged installation workflow, +filesystem access, Foundation filesystem adaptation, and installation results +must not remain bundled as unrelated declarations in one large file. + +The project does not add a brittle script that counts top-level declarations. +SwiftLint complexity limits and code review enforce the intent while preserving +the useful exceptions in the Google guide. + +## Idiomatic Swift boundaries + +`ClamshellCore` stays independent of argument parsing, SwiftUI, WidgetKit, and +App Intents. Domain decisions use values and narrow protocols. Foundation types +adapt operating-system behaviour at the edge. + +System operations remain injectable. Tests must not invoke real `pmset`, +`sudo`, filesystem ownership changes, or `launchd`. Generic dumping grounds +such as `Utils`, `Common`, or `Models` are prohibited. + +Access control is as narrow as target boundaries permit. Declarations exposed +to the future app package product are public. Other declarations remain +internal or private. Documentation is added when it records contracts, +constraints, or failure behaviour that the declaration does not make clear; +implementation comments explain non-obvious decisions instead of restating +code. + +The refactor does not change command output, exit codes, accepted arguments, +privileged paths, or the sudoers allow-list unless a separate behaviour change +is explicitly designed and tested. + +## Error handling + +Errors remain typed and actionable. Domain errors preserve stable CLI exit-code +behaviour. Platform adapters include enough underlying context to diagnose a +failure but do not expose sensitive command output unnecessarily. + +The codebase does not introduce forced casts, forced tries, avoidable forced +unwraps, silent catches, or `fatalError` for recoverable conditions. A process, +filesystem, parsing, or verification failure propagates through an explicit +error path and receives behavioural test coverage. + +## Testing and local verification + +Tests verify externally observable behaviour rather than private implementation +shape. Coverage includes: + +- Valid and malformed battery sections in `pmset` output. +- Idempotent enable and disable transitions. +- Exact privileged command allow-listing. +- Installation ownership, permissions, rollback, and repeatability. +- Process output capture and failure propagation. +- CLI exit codes and human-readable output. +- Regression protection before existing code is moved or rewritten. + +The repository exposes one documented local verification entry point. It runs, +in order: + +1. `swift-format` in strict lint mode. +2. SwiftLint in strict lint mode. +3. The complete Swift test suite. +4. Debug and release package builds. +5. `actionlint` for GitHub Actions workflows. +6. XcodeGen and the companion-app build once those targets exist. + +Each local command stops on failure and preserves the failing tool's output. +CI runs the same checks in separate jobs to provide parallel feedback. The +script remains the single local entry point. + +## GitHub Actions + +All third-party actions are pinned to immutable commit SHAs. Workflows receive +the minimum required permissions. + +### Continuous integration + +`ci.yml` runs on pull requests and pushes to `main` using a macOS runner. It +checks formatting, runs SwiftLint, executes tests, and builds debug and release +configurations. It also generates and builds the Xcode project after the native +targets are introduced. + +### CodeQL + +`codeql.yml` uses [GitHub CodeQL][codeql-compiled] to analyse Swift on pull +requests, pushes to `main`, a weekly schedule, and manual dispatch. It runs on +macOS and uses an explicit manual Swift build so the analysed targets are +deterministic. The workflow enables the `security-extended` query suite and +publishes results through GitHub code scanning. + +### Pull-request titles + +`pr.yml` enforces `verb(area): description`. Accepted verbs follow the +repository's release-please conventions. The same structure is used for local +commits. + +### Automated review + +`.coderabbit.yaml` is the version-controlled source of truth for automated +review. [CodeRabbit][coderabbit-config] uses British English and an assertive, +high-signal profile. It prioritises correctness, security, maintainability, and +idiomatic Swift. Review comments must identify a concrete consequence and must +not request cosmetic churn, redundant comments, or documentation for +self-explanatory implementation details. + +CodeRabbit reviews a pull request when it becomes ready for review. It does not +review drafts or rerun automatically after each push. Maintainers batch fixes +and request a manual follow-up review when the result justifies another review. +Actionable findings use GitHub's request changes workflow. + +The walkthrough remains concise and collapsed. It omits poems, fortunes, +sequence diagrams, effort estimates, suggested labels or reviewers, and prompts +for automated code generation. Swift, tests, privileged code, workflows, shell +scripts, and documentation receive focused path-specific instructions. + +The docstring coverage and generation features are disabled. The deterministic +`pr.yml` check owns title syntax, so CodeRabbit does not duplicate it with a +subjective title check. Description and linked-issue checks remain advisory. +SwiftLint reads `.swiftlint.yml`, and ShellCheck remains enabled. + +### Releases and dependency updates + +`release.yml` uses release-please to create release pull requests and GitHub +releases. Artifact upload remains output-gated until the CLI and DMG packaging +work exists. Dependabot checks Swift Package Manager and GitHub Actions weekly +and groups compatible routine updates. + +## Repository configuration + +The public repository has a clear description and relevant topics. Issues +remain enabled. Unused wiki and project features are disabled. Merge +configuration allows squash merges only and deletes merged branches +automatically. + +Repository metadata includes: + +- `CODEOWNERS`, assigning the repository to `@LMLiam`. +- A pull-request template. +- Structured bug, feature, and security issue forms. +- Contributing, security, and support policies in GitHub's recognised + `.github` locations. +- Consistent type, area, priority, and status labels. +- An MVP milestone for the existing implementation issues. + +Private vulnerability reporting, Dependabot alerts, Dependabot security +updates, secret scanning, and push protection are enabled where GitHub makes +them available to the public repository. + +## Main branch ruleset + +The default-branch ruleset requires: + +- A pull request before merging. +- One approving maintainer and code-owner review. +- Dismissal of stale approvals after relevant changes. +- Resolution of every review conversation. +- Passing CI, CodeQL, pull-request-title, and GitGuardian checks. +- Linear history. + +Force pushes and branch deletion are blocked. Repository administrators retain +a bypass so Liam can deliberately admin-merge his own pull requests. + +The ruleset is enabled only after each required workflow has produced its +stable check name. This prevents a configuration that requires a check GitHub +has never observed and therefore cannot satisfy. + +## GitGuardian finding + +The current GitGuardian detection is a sudoers-policy test fixture containing a +sample username and an allowed helper command. It is neither a password nor a +credential. + +The individual incident is resolved with GitGuardian's `Skip: false positive` +action. The implementation must not ignore the test directory, disable the +detector, add a broad match exclusion, or rewrite Git history. Secret scanning +and future detections remain fully active. + +## Completion criteria + +The quality pass is complete when: + +1. The source and tests follow the approved responsibility-based layout. +2. `swift-format` and SwiftLint pass in strict mode with no unexplained + suppressions. +3. All tests and debug and release builds pass. +4. CI and CodeQL complete successfully on the pull request. +5. The GitGuardian false positive is resolved and its check passes. +6. Repository metadata, security features, labels, merge settings, and the + default-branch ruleset match this design. +7. Existing architecture and implementation documentation matches the final + layout and workflows. +8. A final review finds no unresolved correctness, security, or maintainability + issues. + +[codeql-compiled]: https://docs.github.com/en/code-security/concepts/code-scanning/codeql/codeql-for-compiled-languages +[coderabbit-config]: https://docs.coderabbit.ai/reference/configuration +[google-swift-style]: https://google.github.io/swift/ +[swiftlint]: https://github.com/realm/SwiftLint diff --git a/docs/repository-quality-implementation-plan.md b/docs/repository-quality-implementation-plan.md new file mode 100644 index 0000000..eb9347f --- /dev/null +++ b/docs/repository-quality-implementation-plan.md @@ -0,0 +1,1450 @@ +# Repository Quality Implementation Plan + +**Goal:** Make `clamshellctl` an idiomatic, consistently enforced Swift project +with a complete public GitHub repository, CodeQL analysis, release automation, +and protected-main governance. + +**Architecture:** Preserve the current SwiftPM target boundaries and observable +CLI behaviour. Organise each target by responsibility, keep operating-system +interactions behind protocols, and give formatting, linting, testing, security, +and repository policy separate enforceable owners. + +**Tech Stack:** Swift 6.3, Swift Package Manager, Swift Testing, +`swift-format`, SwiftLint 0.65.0, GitHub Actions, CodeQL, GitGuardian, +release-please v5, Dependabot, `actionlint`, and GitHub rulesets. + +--- + +## File map + +### Production source + +```text +Sources/ClamshellCore/ +├── BuildVersion.swift +├── Errors/ClamshellError.swift +├── Power/ +│ ├── PowerMutation.swift +│ ├── PowerSettingsClient.swift +│ └── PowerSettingsParser.swift +├── Privilege/ +│ ├── PrivilegedHelperClient.swift +│ ├── PrivilegedPaths.swift +│ ├── SudoersPolicy.swift +│ └── Installation/ +│ ├── FoundationInstallationFileSystem.swift +│ ├── InstallationFileSystem.swift +│ ├── InstallationResult.swift +│ ├── PrivilegedInstallation.swift +│ └── UninstallationResult.swift +├── Process/ +│ ├── FoundationProcessRunner.swift +│ ├── ProcessOutputStream.swift +│ ├── ProcessResult.swift +│ └── ProcessRunning.swift +└── State/ + ├── ClamshellService.swift + ├── ClamshellState.swift + ├── PowerStateReading.swift + ├── PowerStateWriting.swift + └── TransitionResult.swift + +Sources/ClamshellCLI/ +├── ClamshellCommand.swift +├── CommandComposition.swift +├── Console.swift +├── OutputOptions.swift +└── Commands/*.swift +``` + +`BuildVersion.swift` stays at the module root because it is package metadata, +not a domain. No one-file `Versioning` directory is introduced. Issue #4 adds +the `Timing` production and test directories when timed enablement exists; this +plan does not commit empty directories. + +### Tests + +```text +Tests/ClamshellCoreTests/ +├── BuildVersionTests.swift +├── Power/*.swift +├── Privilege/*.swift +├── Privilege/Installation/*.swift +├── Process/FoundationProcessRunnerTests.swift +├── State/ClamshellServiceTests.swift +└── Support/InstallationTestSupport.swift + +Tests/ClamshellCLITests/ +└── Commands/ + ├── SetupCommandTests.swift + └── StatusCommandTests.swift +``` + +### Quality and repository files + +```text +.editorconfig +.gitattributes +.swift-format +.swiftlint.yml +.github/CODEOWNERS +.github/CODE_OF_CONDUCT.md +.github/CONTRIBUTING.md +.github/SECURITY.md +.github/SUPPORT.md +.github/dependabot.yml +.github/pull_request_template.md +.github/ISSUE_TEMPLATE/config.yml +.github/workflows/ci.yml +.github/workflows/codeql.yml +.github/workflows/pr.yml +.github/workflows/release.yml +.release-please-manifest.json +CHANGELOG.md +release-please-config.json +scripts/check.sh +scripts/check-conventional-subject.sh +``` + +The untracked `.vscode/` directory is user-owned and must remain untouched. + +## Task 1: Record the behavioural baseline + +**Files:** None. + +- [ ] **Step 1: Confirm the protected worktree scope** + +Run: + +```bash +git status --short --branch +``` + +Expected: branch `feat/mvp`; the only untracked path is `.vscode/`. + +- [ ] **Step 2: Run the current formatter check** + +Run: + +```bash +swift format lint --recursive --strict Sources Tests Package.swift +``` + +Expected: exit 0 before the formatting configuration changes. + +- [ ] **Step 3: Run the current tests and builds** + +Run: + +```bash +swift test +swift build +swift build -c release +``` + +Expected: 33 tests in 12 suites pass; both builds exit 0. + +## Task 2: Organise source and tests by responsibility + +**Files:** + +- Move: existing Swift files under `Sources/ClamshellCore/` +- Move: `Sources/ClamshellCLI/ClamshellCommand.swift` +- Move: `Sources/ClamshellCLI/Console.swift` +- Move: existing Swift test files under `Tests/` + +- [ ] **Step 1: Create the responsibility directories** + +Run: + +```bash +mkdir -p Sources/ClamshellCore/{Errors,Power,Privilege/Installation,Process,State} +mkdir -p Tests/ClamshellCoreTests/{Power,Privilege/Installation,Process,State,Support} +mkdir -p Tests/ClamshellCLITests/Commands +``` + +- [ ] **Step 2: Move each intact production file** + +Run: + +```bash +git mv Sources/ClamshellCore/ClamshellError.swift Sources/ClamshellCore/Errors/ +git mv Sources/ClamshellCore/ClamshellService.swift Sources/ClamshellCore/State/ +git mv Sources/ClamshellCore/ClamshellState.swift Sources/ClamshellCore/State/ +git mv Sources/ClamshellCore/PowerSettingsClient.swift Sources/ClamshellCore/Power/ +git mv Sources/ClamshellCore/PowerSettingsParser.swift Sources/ClamshellCore/Power/ +git mv Sources/ClamshellCore/PrivilegedHelperClient.swift \ + Sources/ClamshellCore/Privilege/ +git mv Sources/ClamshellCore/PrivilegedInstallation.swift \ + Sources/ClamshellCore/Privilege/Installation/ +git mv Sources/ClamshellCore/ProcessRunner.swift Sources/ClamshellCore/Process/ +git mv Sources/ClamshellCore/SudoersPolicy.swift Sources/ClamshellCore/Privilege/ +``` + +SwiftPM discovers target sources recursively, so `Package.swift` does not need +path declarations. + +- [ ] **Step 3: Move each intact test file** + +Run: + +```bash +git mv Tests/ClamshellCoreTests/ClamshellServiceTests.swift Tests/ClamshellCoreTests/State/ +git mv Tests/ClamshellCoreTests/PowerMutationTests.swift Tests/ClamshellCoreTests/Power/ +git mv Tests/ClamshellCoreTests/PowerSettingsClientTests.swift Tests/ClamshellCoreTests/Power/ +git mv Tests/ClamshellCoreTests/PowerSettingsParserTests.swift Tests/ClamshellCoreTests/Power/ +git mv Tests/ClamshellCoreTests/PrivilegedHelperClientTests.swift \ + Tests/ClamshellCoreTests/Privilege/ +git mv Tests/ClamshellCoreTests/PrivilegedInstallationTests.swift \ + Tests/ClamshellCoreTests/Privilege/Installation/ +git mv Tests/ClamshellCoreTests/SudoersPolicyTests.swift Tests/ClamshellCoreTests/Privilege/ +git mv Tests/ClamshellCLITests/SetupCommandTests.swift Tests/ClamshellCLITests/Commands/ +``` + +- [ ] **Step 4: Verify the moves did not change behaviour** + +Run: + +```bash +swift test +swift build -c release +``` + +Expected: all 33 tests pass and the release build exits 0. + +- [ ] **Step 5: Commit the mechanical layout change** + +Run: + +```bash +git add Sources Tests +git commit -m "refactor(layout): organise code by responsibility" +``` + +## Task 3: Split mixed-responsibility production files + +**Files:** + +- Modify: `Sources/ClamshellCore/State/ClamshellService.swift` +- Create: `Sources/ClamshellCore/State/PowerStateReading.swift` +- Create: `Sources/ClamshellCore/State/PowerStateWriting.swift` +- Create: `Sources/ClamshellCore/State/TransitionResult.swift` +- Modify: `Sources/ClamshellCore/Power/PowerSettingsClient.swift` +- Create: `Sources/ClamshellCore/Power/PowerMutation.swift` +- Replace: `Sources/ClamshellCore/Process/ProcessRunner.swift` +- Create: four focused files under `Sources/ClamshellCore/Process/` +- Modify: `Sources/ClamshellCore/Privilege/SudoersPolicy.swift` +- Create: `Sources/ClamshellCore/Privilege/PrivilegedPaths.swift` +- Replace: `Sources/ClamshellCore/Privilege/Installation/PrivilegedInstallation.swift` +- Create: four supporting installation files +- Modify: `Sources/ClamshellCLI/ClamshellCommand.swift` +- Create: `Sources/ClamshellCLI/CommandComposition.swift` +- Modify: `Sources/ClamshellCLI/Console.swift` +- Create: `Sources/ClamshellCLI/OutputOptions.swift` + +- [ ] **Step 1: Separate state contracts and results** + +Move the two protocols and result value out of `ClamshellService.swift` without +changing their names or signatures. The resulting files contain: + +```swift +// PowerStateReading.swift +public protocol PowerStateReading: Sendable { + func currentState() throws -> ClamshellState +} + +// PowerStateWriting.swift +public protocol PowerStateWriting: Sendable { + func setState(_ state: ClamshellState) throws +} + +// TransitionResult.swift +public struct TransitionResult: Sendable, Equatable { + public let previous: ClamshellState + public let current: ClamshellState + public let didChange: Bool + + public init(previous: ClamshellState, current: ClamshellState, didChange: Bool) { + self.previous = previous + self.current = current + self.didChange = didChange + } +} +``` + +`ClamshellService.swift` then contains only `ClamshellService`. + +- [ ] **Step 2: Separate power mutation arguments** + +Move `PowerMutation` unchanged from `PowerSettingsClient.swift` into +`PowerMutation.swift`. Keep the client focused on reading and writing `pmset`. + +- [ ] **Step 3: Separate process values, protocol, and adapter** + +Delete `ProcessRunner.swift` after distributing its declarations exactly as +follows: + +```text +ProcessOutputStream.swift ProcessOutputStream +ProcessResult.swift ProcessResult +ProcessRunning.swift ProcessRunning +FoundationProcessRunner.swift FoundationProcessRunner and private ProcessOutputCollector +``` + +The collector remains private beside its sole consumer. Do not change process +launching, concurrent pipe collection, UTF-8 validation, or returned values. + +- [ ] **Step 4: Separate privileged paths from policy generation** + +Move `PrivilegedPaths` unchanged into `PrivilegedPaths.swift`. +`SudoersPolicy.swift` then contains only username validation and exact policy +generation. + +- [ ] **Step 5: Separate installation workflow and filesystem adaptation** + +Distribute the declarations from `PrivilegedInstallation.swift` as follows: + +```text +InstallationFileSystem.swift InstallationFileSystem and InstalledFileAttributes +FoundationInstallationFileSystem.swift FoundationInstallationFileSystem +InstallationResult.swift InstallationResult +UninstallationResult.swift UninstallationResult +PrivilegedInstallation.swift PrivilegedInstallation +``` + +Keep `InstalledFileAttributes` beside the protocol whose method returns it. +Retain every existing path, permission (`0755` and `0440`), ownership check, +temporary-file cleanup, `visudo` validation, and idempotency branch. Stage both +replacement files before validation, validate the staged policy, then replace +the policy before the helper. This avoids changing either live path when +staging or validation fails; the two separate filesystem renames are not a +single transaction. + +- [ ] **Step 6: Separate CLI composition and output options** + +Move `CommandComposition` unchanged into `CommandComposition.swift` and +`OutputOptions` unchanged into `OutputOptions.swift`. Leave +`ClamshellCommand.swift` and `Console.swift` with one primary responsibility +each. + +- [ ] **Step 7: Move the Foundation process tests to their domain** + +Move `FoundationProcessRunnerTests` from +`Tests/ClamshellCoreTests/Power/PowerSettingsClientTests.swift` into +`Tests/ClamshellCoreTests/Process/FoundationProcessRunnerTests.swift`. Keep its +temporary executable fixture and assertions unchanged. + +- [ ] **Step 8: Extract the large installation fixture** + +Move these test-only declarations from +`Tests/ClamshellCoreTests/Privilege/Installation/PrivilegedInstallationTests.swift` +to `Tests/ClamshellCoreTests/Support/InstallationTestSupport.swift`: + +```text +InstallationOperation +InstallationOperationLog +InstallationRecordingRunner +RecordingInstallationFileSystem +``` + +Remove `private` only where cross-file test access requires internal access. +Keep suite-specific constants and test methods in the suite file. + +- [ ] **Step 9: Verify the structural refactor** + +Run: + +```bash +swift test +swift build +swift build -c release +``` + +Expected: all 33 tests pass; both builds exit 0; command output and exit codes +are unchanged. + +- [ ] **Step 10: Commit the focused declarations** + +Run: + +```bash +git add Sources Tests +git commit -m "refactor(core): separate domain responsibilities" +``` + +## Task 4: Enforce Google Swift formatting + +**Files:** + +- Create: `.editorconfig` +- Create: `.gitattributes` +- Modify: `.swift-format` +- Modify: `.gitignore` +- Modify: every checked-in Swift file through deterministic formatting + +- [ ] **Step 1: Replace `.swift-format` with the explicit project policy** + +Use: + +```json +{ + "indentation": { + "spaces": 2 + }, + "lineLength": 100, + "maximumBlankLines": 1, + "multiElementCollectionTrailingCommas": true, + "rules": { + "NeverForceUnwrap": true, + "NeverUseForceTry": true, + "NeverUseImplicitlyUnwrappedOptionals": true, + "OrderedImports": true + }, + "version": 1 +} +``` + +- [ ] **Step 2: Add editor-neutral settings** + +Create `.editorconfig`: + +```ini +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.swift] +indent_style = space +indent_size = 2 +max_line_length = 100 + +[{*.json,*.yml,*.yaml}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false +``` + +Create `.gitattributes`: + +```gitattributes +* text=auto eol=lf +*.icns binary +*.png binary +*.dmg binary +``` + +Add these generated paths to `.gitignore` while preserving existing entries: + +```gitignore +Clamshell.xcodeproj/ +DerivedData/ +``` + +- [ ] **Step 3: Confirm the formatter configuration changes the result** + +Run: + +```bash +swift format lint --recursive --strict Sources Tests Package.swift +``` + +Expected: non-zero with formatting diagnostics caused by the change from four +spaces to two. + +- [ ] **Step 4: Apply formatting only** + +Run: + +```bash +swift format --recursive --in-place Sources Tests Package.swift +``` + +Expected: every Swift file uses two-space indentation and Google-compatible +layout. A second strict lint invocation exits 0. + +- [ ] **Step 5: Commit the deterministic formatting** + +Run: + +```bash +git add .editorconfig .gitattributes .gitignore .swift-format Package.swift Sources Tests +git commit -m "style(swift): adopt Google formatting" +``` + +## Task 5: Add strict SwiftLint and useful API documentation + +**Files:** + +- Modify: `Package.swift` +- Modify: `.swift-format` +- Create: `.swiftlint.yml` +- Modify: public declarations under `Sources/` + +- [ ] **Step 1: Require standalone SwiftLint** + +Use SwiftLint 0.65.0 as a standalone tool. Do not add lint tools to the +production package graph. The GitHub `macos-26` runner includes this version. +Install it locally with Homebrew: + +```bash +brew install swiftlint +test "$(swiftlint version)" = "0.65.0" +``` + +Do not add SwiftLint or its plugins to `Package.swift`. Production builds do +not need lint tools. CodeQL analysis does not need lint tools. + +The CLI test target imports ArgumentParser for command parsing, so it declares +the `ArgumentParser` product directly as well as depending on `ClamshellCLI`. + +- [ ] **Step 2: Add a curated non-formatting SwiftLint policy** + +First extend the `rules` object in `.swift-format` with the documentation rules +that become enforceable in this task: + +```json +"BeginDocumentationCommentWithOneLineSummary": true, +"ValidateDocumentationComments": true +``` + +Create `.swiftlint.yml`: + +```yaml +strict: true +check_for_updates: false +allow_zero_lintable_files: false + +excluded: + - .build + - .swiftpm + - Clamshell.xcodeproj + - DerivedData + +disabled_rules: + - closing_brace + - colon + - comma + - leading_whitespace + - line_length + - opening_brace + - function_name_whitespace + - return_arrow_whitespace + - statement_position + - force_try + - trailing_comma + - trailing_newline + - trailing_semicolon + - trailing_whitespace + - vertical_whitespace + +opt_in_rules: + - array_init + - closure_body_length + - contains_over_filter_count + - contains_over_filter_is_empty + - discouraged_optional_boolean + - empty_collection_literal + - empty_count + - empty_string + - explicit_init + - fatal_error_message + - first_where + - last_where + - legacy_multiple + - modifier_order + - overridden_super_call + - pattern_matching_keywords + - reduce_into + - redundant_nil_coalescing + - sorted_first_last + - toggle_bool + - unavailable_function + +closure_body_length: + warning: 40 + error: 60 +cyclomatic_complexity: + warning: 10 + error: 15 +file_length: + warning: 400 + error: 500 + ignore_comment_only_lines: true +function_body_length: + warning: 40 + error: 60 +function_parameter_count: + warning: 6 + error: 8 +type_body_length: + warning: 250 + error: 350 + +reporter: xcode +``` + +- [ ] **Step 3: Run SwiftLint to expose concrete violations** + +Run: + +```bash +swiftlint lint --strict +``` + +Expected: non-zero until every reported correctness, naming, and complexity +violation is addressed. Do not create a baseline or disable a rule for the +whole repository. + +- [ ] **Step 4: Document non-obvious public contracts** + +Add concise `///` documentation only where it records behaviour that a +self-describing declaration does not communicate. Cover these contracts: + +```text +PowerMutation Accept only one exact helper action. +PowerSettingsParser Define missing and malformed pmset behaviour. +PrivilegedHelperClient Restrict non-interactive helper invocation. +SudoersPolicy Reject unsafe usernames and allow only exact commands. +PrivilegedInstallation Preserve staging, validation, verification, and idempotency. +ClamshellService Avoid redundant writes and verify state transitions. +``` + +Do not document obvious properties, trivial initialisers, or declarations whose +names and types already state their complete contract. Do not add comments to +private implementation details unless the decision is non-obvious. + +- [ ] **Step 5: Resolve every strict lint finding idiomatically** + +Prefer changing code over suppressing a finding. When a rule is inapplicable, +use a line- or declaration-scoped disable with a reason immediately above it. +Run after each group of fixes: + +```bash +swift format --recursive --in-place Sources Tests Package.swift +swift format lint --recursive --strict Sources Tests Package.swift +swiftlint lint --strict +swift test +``` + +Expected: both linters exit 0 and all 33 tests pass. + +- [ ] **Step 6: Commit linting and API documentation** + +Run: + +```bash +git add .swift-format .swiftlint.yml Package.swift Sources Tests +git commit -m "build(lint): enforce Swift conventions" +``` + +## Task 6: Add one local verification entry point + +**Files:** + +- Create: `scripts/check.sh` +- Create: `scripts/check-conventional-subject.sh` +- Modify: `.github/CONTRIBUTING.md` later in Task 9 + +- [ ] **Step 1: Add conventional-subject validation** + +Create `scripts/check-conventional-subject.sh`: + +```bash +#!/bin/bash +set -euo pipefail + +readonly subject="${1:-}" +readonly verbs='feat|fix|docs|test|build|ci|refactor|perf|style|chore|revert' +readonly pattern="^(${verbs})\([a-z0-9][a-z0-9-]*\): .+" + +if [[ ! "$subject" =~ $pattern ]]; then + echo "Expected verb(area): description, received: $subject" >&2 + exit 1 +fi +``` + +Dependency updates use `build(deps): ...`; `deps` is not a separate accepted +verb, so dependency commits stay inside the repository's standard vocabulary. + +Make it executable and verify both branches: + +```bash +chmod +x scripts/check-conventional-subject.sh +scripts/check-conventional-subject.sh "feat(cli): add status output" +! scripts/check-conventional-subject.sh "feat: add status output" +``` + +Expected: the scoped subject passes and the unscoped subject fails. + +- [ ] **Step 2: Add the complete local check** + +Create executable `scripts/check.sh`: + +```bash +#!/bin/bash +set -euo pipefail + +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly repository_root +cd "$repository_root" + +swift_paths=(Package.swift Sources Tests) +if [[ -d App ]]; then + swift_paths+=(App) +fi + +swift format lint --recursive --strict "${swift_paths[@]}" +swiftlint lint --strict +swift test +swift build +swift build -c release + +if [[ -d .github/workflows ]]; then + command -v actionlint >/dev/null || { + echo "actionlint is required: brew install actionlint" >&2 + exit 1 + } + actionlint +fi + +if [[ -f project.yml ]]; then + command -v xcodegen >/dev/null || { + echo "XcodeGen is required: brew install xcodegen" >&2 + exit 1 + } + xcodegen generate + xcodebuild \ + -project Clamshell.xcodeproj \ + -scheme ClamshellApp \ + -configuration Debug \ + CODE_SIGNING_ALLOWED=NO \ + build +fi +``` + +Run: + +```bash +chmod +x scripts/check.sh +brew list actionlint >/dev/null 2>&1 || brew install actionlint +scripts/check.sh +``` + +Expected: both linters, all tests, both builds, and actionlint exit 0. + +- [ ] **Step 3: Commit the reusable checks** + +Run: + +```bash +git add scripts +git commit -m "build(checks): add local verification entry point" +``` + +## Task 7: Add deterministic CI and pull-request policy + +**Files:** + +- Create: `.github/workflows/ci.yml` +- Create: `.github/workflows/pr.yml` + +- [ ] **Step 1: Add CI with stable job names** + +Create `ci.yml` with these jobs and exact names: + +```yaml +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + quality: + name: Quality + runs-on: macos-26 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: swift format lint --recursive --strict Sources Tests Package.swift + - run: swiftlint lint --strict + + tests: + name: Tests + runs-on: macos-26 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: swift test + + build: + name: Build + runs-on: macos-26 + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - run: swift build + - run: swift build -c release + + workflows: + name: Workflow lint + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0 + with: + version: 1.7.12 +``` + +- [ ] **Step 2: Add PR title and commit validation** + +Create `pr.yml` with two stable jobs, `Title` and `Commits`. The title job +checks out the trusted base revision. The commit job checks out the pull +request's head SHA with full history for the commit range, but extracts the +validator from the trusted base revision before invoking it. The synthetic +merge subject is never validated, and a pull request cannot alter the +validator it uses. If the trusted base predates the validator, both jobs use a +literal bootstrap fallback identical to the script being introduced; later +pull requests use only the base-revision copy. + +The workflow uses the trusted-base validator materialization described above. +Keep the bootstrap fallback literal identical to +`scripts/check-conventional-subject.sh`; the abbreviated shape is: + +```yaml +concurrency: + group: pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + title: + steps: + - uses: actions/checkout@... # pinned SHA + with: + ref: ${{ github.event.pull_request.base.sha }} + - run: materialize trusted-base validator or the identical bootstrap rule + - run: $RUNNER_TEMP/check-conventional-subject.sh "$PR_TITLE" + + commits: + steps: + - uses: actions/checkout@... # pinned SHA + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + - env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: materialize trusted-base validator or the identical bootstrap rule + - run: apply the temporary validator to git log --format=%s "$BASE_SHA..HEAD" +``` + +- [ ] **Step 3: Validate and commit the workflows** + +Run: + +```bash +actionlint +scripts/check.sh +git add .github/workflows/ci.yml .github/workflows/pr.yml +git commit -m "ci(checks): enforce repository quality" +``` + +Expected: actionlint and the complete local check exit 0. + +## Task 8: Add CodeQL, Dependabot, and release-please + +**Files:** + +- Create: `.github/workflows/codeql.yml` +- Create: `.github/workflows/release.yml` +- Create: `.github/dependabot.yml` +- Create: `release-please-config.json` +- Create: `.release-please-manifest.json` +- Create: `CHANGELOG.md` + +- [ ] **Step 1: Add Swift CodeQL analysis** + +Create `codeql.yml` using `github/codeql-action` commit +`e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81` (`v4`): + +```yaml +name: CodeQL + +on: + pull_request: + branches: [main] + push: + branches: [main] + schedule: + - cron: "23 4 * * 1" + workflow_dispatch: + +concurrency: + group: codeql-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + analyze: + name: Analyze (swift) + runs-on: macos-26 + timeout-minutes: 30 + permissions: + contents: read + # CodeQL must upload Swift analysis results to code scanning. + security-events: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 + with: + build-mode: manual + languages: swift + queries: security-extended + - run: swift build + - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 + with: + category: /language:swift +``` + +- [ ] **Step 2: Add grouped dependency updates** + +Create `.github/dependabot.yml`: + +```yaml +version: 2 +updates: + - package-ecosystem: swift + directory: / + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Europe/London + open-pull-requests-limit: 5 + groups: + swift-development: + patterns: + - "*" + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Europe/London + open-pull-requests-limit: 5 + groups: + actions: + patterns: + - "*" +``` + +- [ ] **Step 3: Configure release-please v5** + +Create `.release-please-manifest.json`: + +```json +{ + ".": "0.0.0" +} +``` + +The synthetic `0.0.0` root version is intentional: with +`bump-minor-pre-major: true`, the first feature release becomes `0.1.0`. +Release-please does not use an `initial-version` setting in this configuration. + +Create `CHANGELOG.md`: + +```markdown +# Changelog + +Notable changes to `clamshellctl` are recorded here by release-please. +``` + +Create `release-please-config.json`: + +```json +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "simple", + "package-name": "clamshellctl", + "include-v-in-tag": true, + "include-component-in-tag": false, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "extra-files": [ + { + "type": "generic", + "path": "Sources/ClamshellCore/BuildVersion.swift" + } + ], + "changelog-sections": [ + {"type": "feat", "section": "Features"}, + {"type": "fix", "section": "Fixes"}, + {"type": "perf", "section": "Performance"}, + {"type": "docs", "section": "Documentation"}, + {"type": "test", "section": "Tests", "hidden": true}, + {"type": "build", "section": "Build", "hidden": true}, + {"type": "ci", "section": "CI", "hidden": true}, + {"type": "refactor", "section": "Refactoring", "hidden": true}, + {"type": "style", "section": "Style", "hidden": true}, + {"type": "chore", "section": "Chores", "hidden": true} + ] + } + } +} +``` + +Create `release.yml` using +`googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7` +(`v5`): + +```yaml +name: Release + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + release-please: + name: Release Please + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + # release-please opens release PRs, updates release metadata, and creates releases. + contents: write + issues: write + pull-requests: write + steps: + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5 + with: + token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json +``` + +Do not add DMG upload until issue #6 supplies the packaging script. + +- [ ] **Step 4: Validate and commit automation** + +Run: + +```bash +actionlint +python3 -m json.tool release-please-config.json >/dev/null +python3 -m json.tool .release-please-manifest.json >/dev/null +scripts/check.sh +git add .github CHANGELOG.md release-please-config.json .release-please-manifest.json +git commit -m "ci(release): add security and release automation" +``` + +Expected: workflow and JSON validation pass; the local quality gate passes. + +## Task 9: Complete public repository documentation + +**Files:** + +- Create: `.github/CODEOWNERS` +- Create: `.github/CODE_OF_CONDUCT.md` +- Create: `.github/CONTRIBUTING.md` +- Create: `.github/SECURITY.md` +- Create: `.github/SUPPORT.md` +- Create: `.github/pull_request_template.md` +- Create: `.github/ISSUE_TEMPLATE/config.yml` +- Modify: `.github/ISSUE_TEMPLATE/bug.yml` +- Modify: `.github/ISSUE_TEMPLATE/feature.yml` +- Modify: `README.md` +- Modify: `docs/clamshellctl-design.md` +- Modify: `docs/implementation-plan.md` + +- [ ] **Step 1: Add ownership and contribution policy** + +Use this CODEOWNERS rule: + +```text +* @LMLiam +``` + +`CONTRIBUTING.md` must contain exact sections for prerequisites, setup, +`scripts/check.sh`, Google Swift style, the one-primary-responsibility file +rule, `verb(area): description`, pull requests, behavioural testing, and +security reporting. It must state that SwiftLint suppressions require a local +reason and that contributors must not run privileged tests against live system +paths. + +Use Contributor Covenant 2.1 unchanged except for replacing its enforcement +contact placeholder with `https://github.com/LMLiam`. This intentional +substitution avoids publishing a personal email address. + +- [ ] **Step 2: Add security and support policy** + +`SECURITY.md` must define supported versions as the latest release, direct +vulnerabilities to GitHub private vulnerability reporting, prohibit public +proofs containing secrets or destructive commands, promise acknowledgement +within seven days, and describe the privileged helper, sudoers policy, and +unsigned distribution as security-sensitive surfaces. + +`SUPPORT.md` must direct bugs to the bug form, feature proposals to the feature +form, security reports to private vulnerability reporting, and macOS or +Homebrew usage questions to a normal GitHub issue without promising individual +support. + +- [ ] **Step 3: Add issue and pull-request guidance** + +Set `blank_issues_enabled: false`. Add contact links for private security +reporting and support. Keep the current bug and feature forms, but make their +required fields and labels consistent with `type: bug` and `type: feature`. + +The pull-request template must include summary, linked issues, behaviour and +security impact, verification commands, screenshots only when UI changes, and +a checklist covering `scripts/check.sh`, documentation, and scoped commit +subjects. Start it with a single top-level `# Summary` heading. + +- [ ] **Step 4: Align public and internal documentation** + +Add CI and CodeQL badges and contributor links to `README.md`. Update the source +trees in `clamshellctl-design.md` and `implementation-plan.md` to match the +responsibility folders. Replace direct formatter-only verification commands +with `scripts/check.sh` where the full quality gate is intended. Preserve issue +#10's rule that the `LMLiam/LMLiam` profile README changes only after the first +public release succeeds. + +- [ ] **Step 5: Verify and commit documentation** + +Run: + +```bash +scripts/check.sh +git diff --check +git add .github README.md docs +git commit -m "docs(community): complete repository guidance" +``` + +Expected: all checks pass and no generated or `.vscode/` file is staged. + +## Task 10: Run the complete local verification and push the branch + +**Files:** None beyond corrections required by the checks. + +- [ ] **Step 1: Run the quality gate from a clean build state** + +Run: + +```bash +swift package clean +scripts/check.sh +git diff --check +git status --short +``` + +Expected: all checks pass; only `.vscode/` remains untracked. + +- [ ] **Step 2: Inspect every commit subject** + +Run: + +```bash +while IFS= read -r subject; do + scripts/check-conventional-subject.sh "$subject" +done < <(git log --format=%s origin/main..HEAD) +``` + +Expected: each subject exits 0. Do not rewrite the historical GitGuardian +fixture commit. + +- [ ] **Step 3: Push the existing feature branch** + +Run: + +```bash +git push origin feat/mvp +``` + +Expected: draft PR #11 updates and CI, PR, CodeQL, GitGuardian, and CodeRabbit +checks appear. Keep the PR in draft while repository configuration is applied. + +## Task 11: Resolve GitGuardian and enable repository features + +**Files:** None. + +- [ ] **Step 1: Resolve only the false-positive incident** + +Open the failing `GitGuardian Security Checks` details for PR #11. Select the +historical occurrence in `Tests/ClamshellCoreTests/SudoersPolicyTests.swift` and +choose `Skip: false positive`. Do not select a path ignore, detector ignore, +test-credential classification, or history rewrite. + +Verify: + +```bash +gh pr checks 11 --repo LMLiam/clamshellctl +``` + +Expected: `GitGuardian Security Checks` is successful. + +- [ ] **Step 2: Apply repository merge and feature settings** + +Run: + +```bash +gh repo edit LMLiam/clamshellctl \ + --description "Control battery clamshell mode on macOS" \ + --enable-issues=true \ + --enable-projects=false \ + --enable-wiki=false \ + --enable-merge-commit=false \ + --enable-rebase-merge=false \ + --enable-squash-merge=true \ + --delete-branch-on-merge \ + --add-topic battery \ + --add-topic clamshell-mode \ + --add-topic command-line-tool \ + --add-topic control-center \ + --add-topic macos \ + --add-topic swift \ + --add-topic widgetkit +``` + +- [ ] **Step 3: Enable available security features** + +Run: + +```bash +gh api --method PUT repos/LMLiam/clamshellctl/vulnerability-alerts +gh api --method PUT repos/LMLiam/clamshellctl/automated-security-fixes +gh api --method PUT repos/LMLiam/clamshellctl/private-vulnerability-reporting +gh api --method PATCH repos/LMLiam/clamshellctl \ + -f 'security_and_analysis[secret_scanning][status]=enabled' \ + -f 'security_and_analysis[secret_scanning_push_protection][status]=enabled' +``` + +Expected: each request succeeds. If GitHub reports that a public-repository +feature is already enabled, verify its state and continue without weakening it. + +- [ ] **Step 4: Curate labels without duplicating existing taxonomy** + +Keep the current `area:*`, `type:*`, `good first issue`, `help wanted`, and +`question` labels. Remove redundant default `bug`, `documentation`, and +`enhancement` labels after confirming no issue uses them. Add: + +```text +priority: high B60205 +priority: medium FBCA04 +priority: low 0E8A16 +status: blocked D93F0B +status: ready 0E8A16 +status: in review 5319E7 +``` + +Keep milestone `v0.1.0` and all current issue assignments. Mark issues #1-#3 +`status: in review`; do not close them before PR #11 merges. Issue #8 remains +open because native-app and DMG release jobs depend on issues #5 and #6. + +## Task 12: Create the protected-main ruleset + +**Files:** None. + +- [ ] **Step 1: Wait for every required check name to exist** + +Run: + +```bash +gh pr checks 11 --repo LMLiam/clamshellctl +``` + +Expected check names: + +```text +Quality +Tests +Build +Workflow lint +Title +Commits +Analyze (swift) +GitGuardian Security Checks +``` + +Do not create the ruleset while any name is absent. + +- [ ] **Step 2: Create the active default-branch ruleset** + +Use `gh api --method POST repos/LMLiam/clamshellctl/rulesets --input -` with a +JSON body that contains: + +```json +{ + "name": "Main", + "target": "branch", + "enforcement": "active", + "bypass_actors": [ + { + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "always" + } + ], + "conditions": { + "ref_name": { + "exclude": [], + "include": ["~DEFAULT_BRANCH"] + } + }, + "rules": [ + {"type": "deletion"}, + {"type": "non_fast_forward"}, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1, + "dismiss_stale_reviews_on_push": true, + "required_reviewers": [], + "require_code_owner_review": true, + "require_last_push_approval": false, + "required_review_thread_resolution": true, + "allowed_merge_methods": ["squash"] + } + }, + { + "type": "code_scanning", + "parameters": { + "code_scanning_tools": [ + { + "tool": "CodeQL", + "security_alerts_threshold": "medium_or_higher", + "alerts_threshold": "errors" + } + ] + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "do_not_enforce_on_create": false, + "required_status_checks": [ + {"context": "Quality"}, + {"context": "Tests"}, + {"context": "Build"}, + {"context": "Workflow lint"}, + {"context": "Title"}, + {"context": "Commits"}, + {"context": "GitGuardian Security Checks"} + ] + } + } + ] +} +``` + +This requires one maintainer/code-owner approval. Repository-role ID `5` +retains the administrator bypass Liam chose for his own pull requests. + +- [ ] **Step 3: Verify repository state** + +Run: + +```bash +gh api repos/LMLiam/clamshellctl/rulesets +readonly repository_fields="deleteBranchOnMerge,hasProjectsEnabled,hasWikiEnabled" +repository_fields+=",mergeCommitAllowed,rebaseMergeAllowed,squashMergeAllowed" +repository_fields+=",repositoryTopics" +gh repo view LMLiam/clamshellctl \ + --json "$repository_fields" +``` + +Expected: active `Main` ruleset, squash-only merging, automatic branch deletion, +projects and wiki disabled, and all seven topics present. + +## Task 13: Final review and pull-request handoff + +**Files:** Any narrow correction required by review. + +- [ ] **Step 1: Run final verification** + +Run: + +```bash +scripts/check.sh +gh pr checks 11 --repo LMLiam/clamshellctl +git status --short --branch +``` + +Expected: local checks and every required remote check pass; `.vscode/` remains +the only untracked path. + +- [ ] **Step 2: Review the complete branch diff** + +Review: + +```bash +git diff --stat origin/main...HEAD +git diff --check origin/main...HEAD +``` + +Confirm that no user-visible CLI behaviour, privileged command, installation +path, permission, or exit code changed accidentally. Run the configured code +review workflow and resolve only evidence-backed findings. + +- [ ] **Step 3: Update the existing draft PR** + +Keep PR #11's valid title unless the final scope requires a more accurate +`feat(area): description` title. Update its body with: + +```text +- Google Swift style enforced by swift-format and SwiftLint +- Responsibility-based source and test layout +- CI, CodeQL, release-please, and Dependabot +- Community health and repository security configuration +- GitGuardian false positive resolved without weakening scanning +``` + +Keep `Closes #1`, `Closes #2`, and `Closes #3`. Do not add `Closes #8` because +its native-app and DMG acceptance criteria remain outstanding. Leave the pull +request in draft for Liam's final review; he can make it ready or use the +administrator merge bypass after reviewing the complete result. + +- [ ] **Step 4: Report the handoff** + +Provide Liam with the PR URL, commit list, local and remote verification results, +the ruleset URL, and the still-open issue list. State explicitly that the +profile README remains assigned to issue #10 after the first public release. diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..30f73ce --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "simple", + "package-name": "clamshellctl", + "include-v-in-tag": true, + "include-component-in-tag": false, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "extra-files": [ + { + "type": "generic", + "path": "Sources/ClamshellCore/BuildVersion.swift" + } + ], + "changelog-sections": [ + {"type": "feat", "section": "Features"}, + {"type": "fix", "section": "Fixes"}, + {"type": "perf", "section": "Performance"}, + {"type": "docs", "section": "Documentation"}, + {"type": "test", "section": "Tests", "hidden": true}, + {"type": "build", "section": "Build", "hidden": true}, + {"type": "ci", "section": "CI", "hidden": true}, + {"type": "refactor", "section": "Refactoring", "hidden": true}, + {"type": "style", "section": "Style", "hidden": true}, + {"type": "chore", "section": "Chores", "hidden": true} + ] + } + } +} diff --git a/scripts/check-conventional-subject.sh b/scripts/check-conventional-subject.sh new file mode 100755 index 0000000..78e422e --- /dev/null +++ b/scripts/check-conventional-subject.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -euo pipefail + +readonly subject="${1:-}" +readonly verbs='feat|fix|docs|test|build|ci|refactor|perf|style|chore|revert' +readonly pattern="^(${verbs})\([a-z0-9][a-z0-9-]*\): .*[^.]$" + +if [[ ! "$subject" =~ $pattern ]]; then + echo "Expected verb(area): description without a trailing full stop, received: $subject" >&2 + exit 1 +fi diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..4810d81 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,48 @@ +#!/bin/bash +set -euo pipefail + +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly repository_root +cd "$repository_root" + +swift_paths=(Package.swift Sources Tests) +if [[ -d App ]]; then + swift_paths+=(App) +fi + +command -v swiftlint >/dev/null || { + echo "SwiftLint is required: brew install swiftlint" >&2 + exit 1 +} +[[ "$(swiftlint version)" == "0.65.0" ]] || { + echo "SwiftLint 0.65.0 is required" >&2 + exit 1 +} + +swift format lint --recursive --strict "${swift_paths[@]}" +swiftlint lint --strict +swift test +swift build +swift build -c release + +if [[ -d .github/workflows ]]; then + command -v actionlint >/dev/null || { + echo "actionlint is required: brew install actionlint" >&2 + exit 1 + } + actionlint +fi + +if [[ -f project.yml ]]; then + command -v xcodegen >/dev/null || { + echo "XcodeGen is required: brew install xcodegen" >&2 + exit 1 + } + xcodegen generate + xcodebuild \ + -project Clamshell.xcodeproj \ + -scheme ClamshellApp \ + -configuration Debug \ + CODE_SIGNING_ALLOWED=NO \ + build +fi