From b34539672cf5cae680f45c92529ded1a3b829876 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 28 Apr 2026 15:17:34 -0500 Subject: [PATCH 1/3] feat: add bootstrap fleet reconciliation --- .devcontainer/devcontainer.json | 27 - .github/workflows/claude.yml | 73 --- .github/workflows/extended-validation.yml | 4 - .github/workflows/pr-fast-ci.yml | 4 - .gitignore | 1 - CLAUDE.md | 28 - README.md | 34 +- docs/bootstrap/claude-environment.md | 61 -- docs/bootstrap/onboarding.md | 18 +- profiles/home/claude/CLAUDE.md | 5 - .../home/claude/commands/project-bootstrap.md | 3 - .../home/claude/skills/bootstrap-checklist.md | 5 - .../home/codex/prompts/project-bootstrap.md | 2 +- project.bootstrap.yaml | 96 ++- scripts/check-detect-secrets.sh | 1 - scripts/claude-cloud/setup.sh | 28 - scripts/claude/setup-devcontainer.sh | 26 - src/archetypes.ts | 578 +----------------- src/cli.ts | 55 +- src/doctor.ts | 40 -- src/fleet.ts | 366 +++++++++++ src/github/provision.ts | 80 +++ src/home/sync.ts | 7 - src/index.ts | 1 + src/manifest.ts | 62 +- src/types.ts | 12 +- tests/__snapshots__/render.test.ts.snap | 96 --- tests/fleet.test.ts | 155 +++++ tests/github-provision.test.ts | 101 ++- tests/home-sync.test.ts | 34 +- tests/manifest.test.ts | 59 +- tests/render.test.ts | 14 +- tests/smoke.test.ts | 76 ++- 33 files changed, 1116 insertions(+), 1036 deletions(-) delete mode 100644 .devcontainer/devcontainer.json delete mode 100644 .github/workflows/claude.yml delete mode 100644 CLAUDE.md delete mode 100644 docs/bootstrap/claude-environment.md delete mode 100644 profiles/home/claude/CLAUDE.md delete mode 100644 profiles/home/claude/commands/project-bootstrap.md delete mode 100644 profiles/home/claude/skills/bootstrap-checklist.md delete mode 100755 scripts/claude-cloud/setup.sh delete mode 100755 scripts/claude/setup-devcontainer.sh create mode 100644 src/fleet.ts create mode 100644 tests/fleet.test.ts diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index 3c7c16f..0000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "bootstrap Claude Code", - "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", - "remoteUser": "vscode", - "updateRemoteUserUID": true, - "features": { - "ghcr.io/anthropics/devcontainer-features/claude-code:1": {}, - "ghcr.io/devcontainers/features/github-cli:1": {}, - "ghcr.io/devcontainers/features/node:1": { - "version": "20" - }, - "ghcr.io/devcontainers/features/python:1": { - "version": "3.12" - } - }, - "mounts": [ - "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind" - ], - "postCreateCommand": "bash scripts/claude/setup-devcontainer.sh", - "customizations": { - "vscode": { - "settings": { - "terminal.integrated.defaultProfile.linux": "bash" - } - } - } -} diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index 3058bf7..0000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,73 +0,0 @@ -name: Claude Code - -on: - workflow_dispatch: - inputs: - prompt: - description: 'Task for Claude to run in this repository' - required: true - default: 'Review the current branch changes for bugs, CI regressions, and missing tests.' - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - pull_request_review: - types: [submitted] - -concurrency: - group: claude-${{ github.event.pull_request.number || github.event.issue.number || github.run_id }} - cancel-in-progress: false - -permissions: - contents: read - -jobs: - claude: - if: | - github.event_name == 'workflow_dispatch' || - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) - runs-on: ubuntu-latest - timeout-minutes: 30 - permissions: - contents: write - pull-requests: write - issues: write - id-token: write - actions: read - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - fetch-depth: 1 - - - name: Require Claude auth - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - run: | - if [[ -z "${ANTHROPIC_API_KEY}" ]]; then - echo "Missing repository secret ANTHROPIC_API_KEY. Run /install-github-app in Claude Code or add the secret before using this workflow." >&2 - exit 1 - fi - - - name: Run Claude Code - uses: anthropics/claude-code-action@v1 - with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} - track_progress: true - use_sticky_comment: true - additional_permissions: "actions: read" - prompt: | - REPO: ${{ github.repository }} - DEFAULT BRANCH: main - - Use CLAUDE.md and docs/bootstrap/onboarding.md as repo policy context. - Keep CI Gate as the single required PR status check. - Preserve the split fast and extended validation model. - Shell-safe jobs may use `[self-hosted, synology, shell-only, public]`. - Docker, service-container, browser, and `container:` jobs stay on GitHub-hosted runners. - Prefer the smallest safe change and add tests for behavior changes. - - MANUAL TASK: ${{ github.event.inputs.prompt }} - If this is not a manual run, ignore the MANUAL TASK line and respond to the current `@claude` request instead. diff --git a/.github/workflows/extended-validation.yml b/.github/workflows/extended-validation.yml index 7ab6c76..f52c967 100644 --- a/.github/workflows/extended-validation.yml +++ b/.github/workflows/extended-validation.yml @@ -54,10 +54,8 @@ jobs: app: - 'project.bootstrap.yaml' - 'AGENTS.md' - - 'CLAUDE.md' - 'CONTRIBUTING.md' - '.github/PULL_REQUEST_TEMPLATE.md' - - '.devcontainer/**' - '.githooks/**' - '.github/workflows/**' - 'scripts/**' @@ -67,10 +65,8 @@ jobs: ci: - 'project.bootstrap.yaml' - 'AGENTS.md' - - 'CLAUDE.md' - 'CONTRIBUTING.md' - '.github/PULL_REQUEST_TEMPLATE.md' - - '.devcontainer/**' - '.githooks/**' - '.github/workflows/**' - 'scripts/**' diff --git a/.github/workflows/pr-fast-ci.yml b/.github/workflows/pr-fast-ci.yml index 5c44421..16916d9 100644 --- a/.github/workflows/pr-fast-ci.yml +++ b/.github/workflows/pr-fast-ci.yml @@ -34,10 +34,8 @@ jobs: app: - 'project.bootstrap.yaml' - 'AGENTS.md' - - 'CLAUDE.md' - 'CONTRIBUTING.md' - '.github/PULL_REQUEST_TEMPLATE.md' - - '.devcontainer/**' - '.githooks/**' - '.github/workflows/**' - 'scripts/**' @@ -47,10 +45,8 @@ jobs: ci: - 'project.bootstrap.yaml' - 'AGENTS.md' - - 'CLAUDE.md' - 'CONTRIBUTING.md' - '.github/PULL_REQUEST_TEMPLATE.md' - - '.devcontainer/**' - '.githooks/**' - '.github/workflows/**' - 'scripts/**' diff --git a/.gitignore b/.gitignore index b548a1b..375590d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,5 @@ dist/ coverage/ .playwright-cli/ tmp/ -.claude/ .bootstrap/ .new-project-bootstrap/ diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index afbb8bb..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,28 +0,0 @@ -# CLAUDE.md - -## Project Map - -- `project.bootstrap.yaml`: source of truth for bootstrap policy -- `.github/workflows/`: generated fast and extended CI lanes -- `CONTRIBUTING.md`: human contributor workflow and local validation guidance -- `.github/PULL_REQUEST_TEMPLATE.md`: standard PR summary, issue link, and validation checklist -- `scripts/claude-cloud/setup.sh`: first-party Claude Code on the web setup script -- `.github/workflows/claude.yml`: opt-in Claude GitHub Action for manual or `@claude` review flows -- `.devcontainer/devcontainer.json`: interactive Claude Code workspace baseline -- `.github/workflows/`: repo CI and review workflows -- `scripts/ci/`: bootstrap CI entrypoints when this repo uses the generated workflow lane -- `scripts/claude/setup-devcontainer.sh`: installs repo dependencies inside the devcontainer -- `.githooks/pre-commit`: branch and env-file guardrail when local hooks are bootstrap-managed -- `docs/bootstrap/onboarding.md`: operator checklist for repo/governance setup -- `docs/bootstrap/claude-environment.md`: Claude setup guide for hosted, interactive, and GitHub-hosted use - -## Guardrails - -- Keep `CI Gate` as the single required PR status check. -- Use one approval plus code owners on `main` unless the manifest explicitly changes it. -- Contributors and agents must use `CONTRIBUTING.md` and `.github/PULL_REQUEST_TEMPLATE.md` for PR shape unless a repo intentionally replaces those files. -- `stage` and `prod` environments require reviewers and prevent self-review by default. -- Home-level Codex and Claude profile sync is managed by the bootstrap tool, not by ad-hoc manual edits. -- Claude Code on the web should use the repo-managed setup script and keep network access limited by default. -- The generated Claude GitHub Action is a separate review lane. It must not become a required status check. -- Treat the devcontainer as a trusted-repo workspace. Do not mount extra secrets beyond the persisted `~/.claude` profile unless you explicitly need them. diff --git a/README.md b/README.md index f317be1..af5e799 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,17 @@ Manifest-first control plane for repo scaffolding, GitHub governance, and portable agent profiles. -Use `project.bootstrap.yaml` as the control plane for repo-local scaffolding, GitHub governance, CI policy, and portable Codex/Claude profile sync. Plan first, then apply repo, GitHub, and home targets deliberately. +Use `project.bootstrap.yaml` as the control plane for repo-local scaffolding, GitHub governance, CI policy, and portable Codex profile sync. Plan first, then apply repo, GitHub, and home targets deliberately. ## What The Bootstrap Owns -- GitHub governance, environments, and optional org defaults +- GitHub governance, issue labels, environments, and optional org defaults -- Repo-local `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md`, and pull request template guidance +- Repo-local `AGENTS.md`, `CONTRIBUTING.md`, and pull request template guidance - Fast PR checks plus heavier extended validation lanes - SemVer release automation with floating major/minor compatibility tags - Optional signed AI attestation workflow backed by the control-plane reusable contract -- Portable Codex and Claude home profile sync +- Portable Codex home profile sync - Operator docs for onboarding, hosted agents, and follow-up setup ## Quickstart @@ -25,7 +25,21 @@ bootstrap apply home --manifest ./project.bootstrap.yaml bootstrap doctor --manifest ./project.bootstrap.yaml ``` -If `github.organization` is set and `OMT-Global` is an organization, `bootstrap apply github` also reconciles org defaults for new repos. +Daily fleet reconciliation should start in plan mode and write a report: + +```sh +bootstrap reconcile --workspace-root ~/src --report bootstrap-reconcile.json +``` + +To discover GitHub repos first, add `--org OMT-Global`; repositories without local bootstrapped checkouts are skipped in the report. + +Once the repo allowlist is trusted, run repo file drift through draft PRs: + +```sh +bootstrap reconcile --workspace-root ~/src --apply-repo --create-pr --report bootstrap-reconcile.json +``` + +If `github.organization` is set and `OMT-Global` is an organization, `bootstrap apply github` also reconciles org defaults for new repos. It also syncs `github.issueLabels` for issue routing, risk, status, and review gates. Confirm branch protection points at the `CI Gate` status. and require approval from someone other than the most recent pusher. @@ -67,16 +81,6 @@ This repo now carries the shared Tier A workflow contracts: Use `docs/bootstrap/tier-a-ci-contract.md` for the consumer interface and rollout pattern. Use `docs/bootstrap/next-steps.md` as the publish checklist before downstream repos pin to a tag or immutable SHA. -## Claude Code - -This bootstrap can prepare these Claude workflows: - -- First-party Claude Code on the web via `claude.ai/code` and `bash scripts/claude-cloud/setup.sh` -- Interactive containerized work via `.devcontainer/devcontainer.json` and `bash scripts/claude/setup-devcontainer.sh` -- Remote GitHub-hosted automation via `.github/workflows/claude.yml` - -The full checklist is in `docs/bootstrap/claude-environment.md`. - ## Repository URL - https://github.com/OMT-Global/bootstrap diff --git a/docs/bootstrap/claude-environment.md b/docs/bootstrap/claude-environment.md deleted file mode 100644 index c218e7e..0000000 --- a/docs/bootstrap/claude-environment.md +++ /dev/null @@ -1,61 +0,0 @@ -# Claude Environment - -Claude Code on the web provides a first-party cloud environment comparable to Codex Web. This bootstrap prepares the hosted path first, then adds optional local and GitHub-native alternatives: - -- First-party hosted sessions at `claude.ai/code` -- Interactive containerized work with `.devcontainer/devcontainer.json` -- GitHub-hosted automation with `.github/workflows/claude.yml` - -## Project - -- Product name: `Bootstrap` -- Repository: `OMT-Global/bootstrap` -- Manifest: `project.bootstrap.yaml` - -## Claude Code On The Web - -- Hosted entrypoint: `https://claude.ai/code` -- Repo: `OMT-Global/bootstrap` -- Setup script: `bash scripts/claude-cloud/setup.sh` -- Network access: start with limited access; only expand it when a task truly needs more than registries and GitHub -- Environment variables: configure them in the Claude environment UI as `.env`-style key-value pairs -- GitHub integration: connect GitHub, install the Claude GitHub App, then pick this repo as an allowed target -- Repo guidance: Claude on the web reads `CLAUDE.md` from the repository - -## Teleport And Remote Sessions - -- Start a hosted task from the terminal with `claude --remote "your task"` -- Pull a hosted session back into the terminal with `claude --teleport` -- Hosted tasks clone the default branch unless you specify a branch in the prompt -- Teleport requires a clean git state and the same repository/account pairing - -## Interactive Devcontainer - -- Open the repo in a devcontainer-capable editor and reopen in container. -- The container installs the Claude Code feature plus repo dependencies via `bash scripts/claude/setup-devcontainer.sh`. -- `~/.claude` is mounted into the container so Claude Code auth persists between sessions. -- Only use this with trusted repositories. Mounted Claude credentials are available inside the container. - -## GitHub Action - -- Workflow file: `.github/workflows/claude.yml` -- Runner: `ubuntu-latest` -- Triggers: - - manual `workflow_dispatch` - - PR or issue comments containing `@claude` - - review comments or review bodies containing `@claude` -- Auth: - - preferred: run `/install-github-app` in Claude Code as a repo admin - - fallback: add a repository secret named `ANTHROPIC_API_KEY` - -## Guardrails - -- Keep the Claude workflow out of the required PR check set. The required checks are `CI Gate`. -- Prefer Claude Code on the web for long-running async review or fix tasks; use the devcontainer when you need a local interactive container. -- Treat the devcontainer as a trusted-repo workspace because the mounted `~/.claude` profile is available inside the container. -- Do not relax the action to allow non-write users on public repos unless you intentionally accept the prompt-injection risk. -- Keep Claude review and automation on GitHub-hosted runners; do not move it onto the self-hosted shell-only fleet. - -## Project - -- Default branch: `main` diff --git a/docs/bootstrap/onboarding.md b/docs/bootstrap/onboarding.md index b1661af..ec0d3bc 100644 --- a/docs/bootstrap/onboarding.md +++ b/docs/bootstrap/onboarding.md @@ -45,6 +45,15 @@ Use this checklist after the first bootstrap render or whenever `project.bootstr - To retrofit an existing bootstrapped repo, add `CONTRIBUTING.md` and `.github/PULL_REQUEST_TEMPLATE.md` to `repo.managedPaths` when that repo restricts managed paths, then run `bootstrap apply repo --manifest ./project.bootstrap.yaml`. - Keep these files repo-generic unless project metadata or the manifest requires a stricter local rule. +## Fleet Reconciliation + +- Run `bootstrap reconcile --workspace-root ~/src --report bootstrap-reconcile.json` first; this is plan-only and does not write files. +- Add `--org OMT-Global` when OpenClaw should enumerate GitHub repos first; missing local checkouts or repos without `project.bootstrap.yaml` are skipped and reported. +- Use `--repo ` as the initial allowlist when onboarding daily OpenClaw reconciliation. +- Use `--apply-repo --create-pr` for unattended repo drift so generated changes go through draft PRs instead of default-branch pushes. +- Use `--apply-github` only after the report shape is trusted because it mutates repository settings, environments, branch protection, and labels directly through the GitHub API. +- Dirty target worktrees are blocked and reported instead of being overwritten. + ## Release Standard - Use immutable exact SemVer tags such as `v1.2.3` as the source of truth. @@ -60,11 +69,4 @@ Use this checklist after the first bootstrap render or whenever `project.bootstr ## Home Profiles - Run `bootstrap apply home --manifest ./project.bootstrap.yaml` after reviewing the bundled profile content. -- The bootstrap manages portable Codex and Claude assets only. Auth, sessions, caches, and machine-local state stay unmanaged. - -## Claude Setup - -- First-party Claude web sessions should use `bash scripts/claude-cloud/setup.sh` in `claude.ai/code`. -- Interactive Claude work is prepared through `.devcontainer/devcontainer.json`. -- GitHub-hosted Claude automation lives in `.github/workflows/claude.yml` and is intentionally separate from the required PR checks. -- Finish GitHub-side auth by running `/install-github-app` in Claude Code or adding `ANTHROPIC_API_KEY` as a repo secret. +- The bootstrap manages portable Codex assets only. Auth, sessions, caches, and machine-local state stay unmanaged. diff --git a/profiles/home/claude/CLAUDE.md b/profiles/home/claude/CLAUDE.md deleted file mode 100644 index 7053951..0000000 --- a/profiles/home/claude/CLAUDE.md +++ /dev/null @@ -1,5 +0,0 @@ -# Claude Home Profile - -- Use the manifest-driven bootstrap flow for new repositories. -- Keep repo governance, CI routing, and home sync changes explicit and reviewable. -- Never manage auth/session/cached Claude state through the bootstrap tool. diff --git a/profiles/home/claude/commands/project-bootstrap.md b/profiles/home/claude/commands/project-bootstrap.md deleted file mode 100644 index 7edde43..0000000 --- a/profiles/home/claude/commands/project-bootstrap.md +++ /dev/null @@ -1,3 +0,0 @@ -# /project-bootstrap - -Review `project.bootstrap.yaml`, summarize planned repo/GitHub/home changes, and apply only the requested target once the operator confirms the scope. diff --git a/profiles/home/claude/skills/bootstrap-checklist.md b/profiles/home/claude/skills/bootstrap-checklist.md deleted file mode 100644 index 4861ad8..0000000 --- a/profiles/home/claude/skills/bootstrap-checklist.md +++ /dev/null @@ -1,5 +0,0 @@ -# Bootstrap Checklist - -- Confirm branch protection requires reviews. -- Confirm environments exist with the expected gates. -- Confirm only portable Claude assets are being synced. diff --git a/profiles/home/codex/prompts/project-bootstrap.md b/profiles/home/codex/prompts/project-bootstrap.md index e13c7b1..5551332 100644 --- a/profiles/home/codex/prompts/project-bootstrap.md +++ b/profiles/home/codex/prompts/project-bootstrap.md @@ -6,4 +6,4 @@ When asked to set up a new repository, standardize on: - one required PR status check named `CI Gate` - `dev`, `stage`, and `prod` GitHub environments - 1 approval plus code owner review on the default branch -- portable Codex and Claude home assets only +- portable Codex home assets only diff --git a/project.bootstrap.yaml b/project.bootstrap.yaml index 2598190..3b67fc9 100644 --- a/project.bootstrap.yaml +++ b/project.bootstrap.yaml @@ -21,6 +21,97 @@ github: - pattern: "*" owners: - "@jmcte" + issueLabels: + - name: area:frontend + color: 1f77b4 + description: Frontend and user-interface work. + - name: area:api + color: 2ca02c + description: API contracts, endpoints, and integrations. + - name: area:data + color: 9467bd + description: Data models, persistence, migration, and analytics work. + - name: area:ledger + color: 8c564b + description: Ledger, accounting, transaction, or reconciliation work. + - name: area:rules + color: bcbd22 + description: Domain rules, policy logic, and decision engines. + - name: area:ai + color: 17becf + description: AI, agents, prompts, and model integration work. + - name: area:infra + color: 7f7f7f + description: Infrastructure, CI, deployment, and operations work. + - name: area:security + color: d62728 + description: Security-sensitive implementation or hardening work. + - name: area:accessibility + color: e377c2 + description: Accessibility and inclusive UX work. + - name: area:qa + color: ff7f0e + description: Quality assurance, test coverage, and release validation. + - name: risk:low + color: 0e8a16 + description: Low implementation or operational risk. + - name: risk:medium + color: fbca04 + description: Moderate implementation or operational risk. + - name: risk:high + color: d93f0b + description: High implementation or operational risk. + - name: risk:domain + color: "5319e7" + description: Domain correctness risk requiring subject-matter review. + - name: risk:security + color: b60205 + description: Security risk requiring explicit review. + - name: risk:prod + color: "000000" + description: Production impact or rollout risk. + - name: status:needs-spec + color: cfd3d7 + description: Needs clearer scope, acceptance criteria, or constraints. + - name: status:ready-for-agent + color: 0e8a16 + description: Ready for assigned agent implementation. + - name: status:agent-building + color: 1d76db + description: Agent implementation is in progress. + - name: status:needs-review + color: fbca04 + description: Needs review before merge or closure. + - name: status:needs-human-approval + color: d93f0b + description: Needs explicit human approval before proceeding. + - name: status:ready-to-merge + color: 0e8a16 + description: Ready to merge after required checks pass. + - name: status:blocked + color: b60205 + description: Blocked by a dependency, decision, credential, or access gate. + - name: review:product + color: 0052cc + description: Needs product review. + - name: review:architecture + color: "5319e7" + description: Needs architecture review. + - name: review:security + color: b60205 + description: Needs security review. + - name: review:tax + color: d4c5f9 + description: Needs tax review. + - name: review:legal + color: c2e0c6 + description: Needs legal review. + - name: review:accessibility + color: e99695 + description: Needs accessibility review. + - name: review:release + color: f9d0c4 + description: Needs release review. organization: defaultRepositoryPermission: read membersCanCreateRepositories: false @@ -83,12 +174,7 @@ release: reusableWorkflowRef: refs/heads/main agents: manageCodexHome: true - manageClaudeHome: true codexProfile: default - claudeProfile: default - enableClaudeWebEnvironment: true - enableClaudeDevcontainer: true - enableClaudeGitHubAction: true sharedSkills: [] environments: dev: diff --git a/scripts/check-detect-secrets.sh b/scripts/check-detect-secrets.sh index 897cf20..f127c75 100755 --- a/scripts/check-detect-secrets.sh +++ b/scripts/check-detect-secrets.sh @@ -51,7 +51,6 @@ 'sk-proj-' 'AKIA[0-9A-Z]{16}' 'BEGIN (RSA|OPENSSH|EC) PRIVATE KEY' - 'ANTHROPIC_API_KEY=' 'OPENAI_API_KEY=' 'SUDO_PASS=' 'BW_SESSION=' diff --git a/scripts/claude-cloud/setup.sh b/scripts/claude-cloud/setup.sh deleted file mode 100755 index 128337e..0000000 --- a/scripts/claude-cloud/setup.sh +++ /dev/null @@ -1,28 +0,0 @@ - #!/usr/bin/env bash - set -euo pipefail - - if ! command -v gh >/dev/null 2>&1; then - apt-get update - apt-get install -y gh -fi - -if [[ -f package-lock.json ]]; then - npm ci --prefer-offline --no-audit --no-fund -elif [[ -f pnpm-lock.yaml ]]; then - corepack enable - pnpm install --frozen-lockfile -elif [[ -f yarn.lock ]]; then - corepack enable - yarn install --immutable -elif [[ -f package.json ]]; then - npm install --prefer-offline --no-audit --no-fund -fi - -if [[ -f pyproject.toml ]]; then - if [[ ! -d .venv ]]; then - python3 -m venv .venv - fi - source .venv/bin/activate - python -m pip install --upgrade pip setuptools wheel - python -m pip install -e ".[dev]" >/dev/null 2>&1 || python -m pip install -e . >/dev/null 2>&1 || true -fi diff --git a/scripts/claude/setup-devcontainer.sh b/scripts/claude/setup-devcontainer.sh deleted file mode 100755 index 2fe8264..0000000 --- a/scripts/claude/setup-devcontainer.sh +++ /dev/null @@ -1,26 +0,0 @@ - #!/usr/bin/env bash - set -euo pipefail - - git config --global --add safe.directory "$(pwd)" - -if [[ -f package-lock.json ]]; then - npm ci --prefer-offline --no-audit --no-fund -elif [[ -f pnpm-lock.yaml ]]; then - corepack enable - pnpm install --frozen-lockfile -elif [[ -f yarn.lock ]]; then - corepack enable - yarn install --immutable -elif [[ -f package.json ]]; then - npm install --prefer-offline --no-audit --no-fund -fi - -if [[ -f pyproject.toml ]]; then - if [[ ! -d .venv ]]; then - python3 -m venv .venv - fi - - source .venv/bin/activate - python -m pip install --upgrade pip setuptools wheel - python -m pip install -e ".[dev]" >/dev/null 2>&1 || python -m pip install -e . >/dev/null 2>&1 || true -fi diff --git a/src/archetypes.ts b/src/archetypes.ts index bb2d264..9dd0f7d 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -152,7 +152,6 @@ function baseGitignore(manifest: BootstrapManifest): string { "coverage/", ".playwright-cli/", "tmp/", - ".claude/", ".bootstrap/", ".new-project-bootstrap/" ]; @@ -205,71 +204,6 @@ function repoAgents(manifest: BootstrapManifest): string { `; } -function repoClaude(manifest: BootstrapManifest): string { - const projectMapLines = [ - "- `project.bootstrap.yaml`: source of truth for bootstrap policy", - "- `.github/workflows/`: generated fast and extended CI lanes", - ...additionalWorkflowLines(manifest), - "- `CONTRIBUTING.md`: human contributor workflow and local validation guidance", - "- `.github/PULL_REQUEST_TEMPLATE.md`: standard PR summary, issue link, and validation checklist", - manifest.agents.enableClaudeWebEnvironment - ? "- `scripts/claude-cloud/setup.sh`: first-party Claude Code on the web setup script" - : null, - manifest.agents.enableClaudeGitHubAction - ? "- `.github/workflows/claude.yml`: opt-in Claude GitHub Action for manual or `@claude` review flows" - : null, - manifest.agents.enableClaudeDevcontainer - ? "- `.devcontainer/devcontainer.json`: interactive Claude Code workspace baseline" - : null, - "- `.github/workflows/`: repo CI and review workflows", - "- `scripts/ci/`: bootstrap CI entrypoints when this repo uses the generated workflow lane", - manifest.agents.enableClaudeDevcontainer - ? "- `scripts/claude/setup-devcontainer.sh`: installs repo dependencies inside the devcontainer" - : null, - "- `.githooks/pre-commit`: branch and env-file guardrail when local hooks are bootstrap-managed", - "- `docs/bootstrap/onboarding.md`: operator checklist for repo/governance setup", - manifest.agents.enableClaudeWebEnvironment || manifest.agents.enableClaudeDevcontainer || manifest.agents.enableClaudeGitHubAction - ? "- `docs/bootstrap/claude-environment.md`: Claude setup guide for hosted, interactive, and GitHub-hosted use" - : null - ] - .filter((line): line is string => Boolean(line)) - .join("\n"); - - const guardrailLines = [ - requiredStatusCheckGuardrail(manifest), - `- Use one approval plus code owners on \`${manifest.project.defaultBranch}\` unless the manifest explicitly changes it.`, - "- Contributors and agents must use `CONTRIBUTING.md` and `.github/PULL_REQUEST_TEMPLATE.md` for PR shape unless a repo intentionally replaces those files.", - "- `stage` and `prod` environments require reviewers and prevent self-review by default.", - "- Home-level Codex and Claude profile sync is managed by the bootstrap tool, not by ad-hoc manual edits.", - manifest.agents.enableClaudeWebEnvironment - ? "- Claude Code on the web should use the repo-managed setup script and keep network access limited by default." - : null, - manifest.agents.enableClaudeGitHubAction - ? "- The generated Claude GitHub Action is a separate review lane. It must not become a required status check." - : null, - manifest.ci.additionalWorkflows.length > 0 - ? `- Repo-specific workflow lanes (${manifest.ci.additionalWorkflows.map((workflow) => `\`${workflow.path}\``).join(", ")}) stay adjunct to the standard PR and extended validation lanes.` - : null, - manifest.agents.enableClaudeDevcontainer - ? "- Treat the devcontainer as a trusted-repo workspace. Do not mount extra secrets beyond the persisted `~/.claude` profile unless you explicitly need them." - : null - ] - .filter((line): line is string => Boolean(line)) - .join("\n"); - - return dedent` - # CLAUDE.md - - ## Project Map - -${indentBlock(projectMapLines, 4)} - - ## Guardrails - -${indentBlock(guardrailLines, 4)} - `; -} - function releaseTagExamples(manifest: BootstrapManifest): { exact: string; minor: string; major: string } { const prefix = manifest.release.tagPrefix; @@ -321,51 +255,25 @@ function repoReadme(manifest: BootstrapManifest): string { Use \`docs/bootstrap/tier-a-ci-contract.md\` for the consumer interface and rollout pattern. Use \`docs/bootstrap/next-steps.md\` as the publish checklist before downstream repos pin to a tag or immutable SHA. ` : ""; - const claudeBullets = [ - manifest.agents.enableClaudeWebEnvironment - ? "- First-party Claude Code on the web via `claude.ai/code` and `bash scripts/claude-cloud/setup.sh`" - : null, - manifest.agents.enableClaudeDevcontainer - ? "- Interactive containerized work via `.devcontainer/devcontainer.json` and `bash scripts/claude/setup-devcontainer.sh`" - : null, - manifest.agents.enableClaudeGitHubAction - ? "- Remote GitHub-hosted automation via `.github/workflows/claude.yml`" - : null - ] - .filter((line): line is string => Boolean(line)) - .join("\n"); - - const claudeSection = claudeBullets.length > 0 - ? dedent` - - ## Claude Code - - This bootstrap can prepare these Claude workflows: - -${indentBlock(claudeBullets, 6)} - - The full checklist is in \`docs/bootstrap/claude-environment.md\`. - ` - : ""; return dedent` # ${displayName} ${manifest.project.description} - Use \`project.bootstrap.yaml\` as the control plane for repo-local scaffolding, GitHub governance, CI policy, and portable Codex/Claude profile sync. Plan first, then apply repo, GitHub, and home targets deliberately. + Use \`project.bootstrap.yaml\` as the control plane for repo-local scaffolding, GitHub governance, CI policy, and portable Codex profile sync. Plan first, then apply repo, GitHub, and home targets deliberately. ## What The Bootstrap Owns - - GitHub governance, environments, and optional org defaults + - GitHub governance, issue labels, environments, and optional org defaults ${manifest.ci.additionalWorkflows.length > 0 ? "- Optional repo-specific workflow lanes declared in the manifest without replacing the standard CI frame" : ""} - - Repo-local \`AGENTS.md\`, \`CLAUDE.md\`, \`CONTRIBUTING.md\`, and pull request template guidance + - Repo-local \`AGENTS.md\`, \`CONTRIBUTING.md\`, and pull request template guidance - Fast PR checks plus heavier extended validation lanes ${manifest.release.enabled ? "- SemVer release automation with floating major/minor compatibility tags" : ""} ${manifest.ci.aiAttestation.enabled ? "- Optional signed AI attestation workflow backed by the control-plane reusable contract" : ""} - - Portable Codex and Claude home profile sync + - Portable Codex home profile sync - Operator docs for onboarding, hosted agents, and follow-up setup ## Quickstart @@ -378,9 +286,23 @@ ${indentBlock(claudeBullets, 6)} bootstrap doctor --manifest ./project.bootstrap.yaml \`\`\` + Daily fleet reconciliation should start in plan mode and write a report: + + \`\`\`sh + bootstrap reconcile --workspace-root ~/src --report bootstrap-reconcile.json + \`\`\` + + To discover GitHub repos first, add \`--org ${manifest.project.owner}\`; repositories without local bootstrapped checkouts are skipped in the report. + + Once the repo allowlist is trusted, run repo file drift through draft PRs: + + \`\`\`sh + bootstrap reconcile --workspace-root ~/src --apply-repo --create-pr --report bootstrap-reconcile.json + \`\`\` + ${manifest.github.organization ? `If \`github.organization\` is set and \`${manifest.project.owner}\` is an organization, \`bootstrap apply github\` also reconciles org defaults for new repos.` - : ""} + : ""} It also syncs \`github.issueLabels\` for issue routing, risk, status, and review gates. ${requiredStatusCheckConfirmation(manifest)} and require approval from someone other than the most recent pusher. @@ -400,7 +322,6 @@ ${indentBlock(additionalWorkflowSection(manifest), 4)} ${indentBlock(releaseSection, 4)} ${indentBlock(aiAttestationSection, 4)} ${indentBlock(tierASection, 4)} -${indentBlock(claudeSection, 4)} ## Repository URL @@ -567,7 +488,6 @@ function detectSecretsScript(): string { 'sk-proj-' 'AKIA[0-9A-Z]{16}' 'BEGIN (RSA|OPENSSH|EC) PRIVATE KEY' - 'ANTHROPIC_API_KEY=' 'OPENAI_API_KEY=' 'SUDO_PASS=' 'BW_SESSION=' @@ -826,383 +746,6 @@ ${indentBlock(projectIdentityLines(manifest), 4)} `; } -function claudeCloudSetupScript(manifest: BootstrapManifest): string { - const installBody = - manifest.archetype.kind === "python-service" - ? dedent` - if ! command -v gh >/dev/null 2>&1; then - apt-get update - apt-get install -y gh - fi - - if [[ -f pyproject.toml ]]; then - if [[ ! -d .venv ]]; then - python3 -m venv .venv - fi - source .venv/bin/activate - python -m pip install --upgrade pip setuptools wheel - python -m pip install -e ".[dev]" >/dev/null 2>&1 || python -m pip install -e . >/dev/null 2>&1 || true - fi - ` - : manifest.archetype.kind === "generic-empty" - ? dedent` - if ! command -v gh >/dev/null 2>&1; then - apt-get update - apt-get install -y gh - fi - - if [[ -f package-lock.json ]]; then - npm ci --prefer-offline --no-audit --no-fund - elif [[ -f pnpm-lock.yaml ]]; then - corepack enable - pnpm install --frozen-lockfile - elif [[ -f yarn.lock ]]; then - corepack enable - yarn install --immutable - elif [[ -f package.json ]]; then - npm install --prefer-offline --no-audit --no-fund - fi - - if [[ -f pyproject.toml ]]; then - if [[ ! -d .venv ]]; then - python3 -m venv .venv - fi - source .venv/bin/activate - python -m pip install --upgrade pip setuptools wheel - python -m pip install -e ".[dev]" >/dev/null 2>&1 || python -m pip install -e . >/dev/null 2>&1 || true - fi - ` - : dedent` - if ! command -v gh >/dev/null 2>&1; then - apt-get update - apt-get install -y gh - fi - - if [[ -f package-lock.json ]]; then - npm ci --prefer-offline --no-audit --no-fund - elif [[ -f pnpm-lock.yaml ]]; then - corepack enable - pnpm install --frozen-lockfile - elif [[ -f yarn.lock ]]; then - corepack enable - yarn install --immutable - elif [[ -f package.json ]]; then - npm install --prefer-offline --no-audit --no-fund - fi - `; - - return `${dedent` - #!/usr/bin/env bash - set -euo pipefail - - ${installBody} - `}\n`; -} - -function claudeDevcontainerFeatures(manifest: BootstrapManifest): Record> { - const features: Record> = { - "ghcr.io/anthropics/devcontainer-features/claude-code:1": {}, - "ghcr.io/devcontainers/features/github-cli:1": {} - }; - - if ( - manifest.archetype.kind === "nextjs-web" || - manifest.archetype.kind === "node-ts-service" || - manifest.archetype.kind === "generic-empty" - ) { - features["ghcr.io/devcontainers/features/node:1"] = { - version: manifest.ci.nodeVersion - }; - } - - if (manifest.archetype.kind === "python-service" || manifest.archetype.kind === "generic-empty") { - features["ghcr.io/devcontainers/features/python:1"] = { - version: manifest.ci.pythonVersion - }; - } - - return features; -} - -function claudeDevcontainer(manifest: BootstrapManifest): string { - return `${JSON.stringify( - { - name: `${manifest.project.name} Claude Code`, - image: "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", - remoteUser: "vscode", - updateRemoteUserUID: true, - features: claudeDevcontainerFeatures(manifest), - mounts: ["source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind"], - postCreateCommand: "bash scripts/claude/setup-devcontainer.sh", - customizations: { - vscode: { - settings: { - "terminal.integrated.defaultProfile.linux": "bash" - } - } - } - }, - null, - 2 - )}\n`; -} - -function claudeDevcontainerSetupScript(manifest: BootstrapManifest): string { - const installBody = - manifest.archetype.kind === "python-service" - ? dedent` - git config --global --add safe.directory "$(pwd)" - - if [[ -f pyproject.toml ]]; then - if [[ ! -d .venv ]]; then - python3 -m venv .venv - fi - - source .venv/bin/activate - python -m pip install --upgrade pip setuptools wheel - python -m pip install -e ".[dev]" >/dev/null 2>&1 || python -m pip install -e . >/dev/null 2>&1 || true - fi - ` - : manifest.archetype.kind === "generic-empty" - ? dedent` - git config --global --add safe.directory "$(pwd)" - - if [[ -f package-lock.json ]]; then - npm ci --prefer-offline --no-audit --no-fund - elif [[ -f pnpm-lock.yaml ]]; then - corepack enable - pnpm install --frozen-lockfile - elif [[ -f yarn.lock ]]; then - corepack enable - yarn install --immutable - elif [[ -f package.json ]]; then - npm install --prefer-offline --no-audit --no-fund - fi - - if [[ -f pyproject.toml ]]; then - if [[ ! -d .venv ]]; then - python3 -m venv .venv - fi - - source .venv/bin/activate - python -m pip install --upgrade pip setuptools wheel - python -m pip install -e ".[dev]" >/dev/null 2>&1 || python -m pip install -e . >/dev/null 2>&1 || true - fi - ` - : dedent` - git config --global --add safe.directory "$(pwd)" - - if [[ -f package-lock.json ]]; then - npm ci --prefer-offline --no-audit --no-fund - elif [[ -f pnpm-lock.yaml ]]; then - corepack enable - pnpm install --frozen-lockfile - elif [[ -f yarn.lock ]]; then - corepack enable - yarn install --immutable - elif [[ -f package.json ]]; then - npm install --prefer-offline --no-audit --no-fund - fi - `; - - return `${dedent` - #!/usr/bin/env bash - set -euo pipefail - - ${installBody} - `}\n`; -} - -function claudeWorkflow(manifest: BootstrapManifest): string { - return dedent` - name: Claude Code - - on: - workflow_dispatch: - inputs: - prompt: - description: 'Task for Claude to run in this repository' - required: true - default: 'Review the current branch changes for bugs, CI regressions, and missing tests.' - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - pull_request_review: - types: [submitted] - - concurrency: - group: claude-\${{ github.event.pull_request.number || github.event.issue.number || github.run_id }} - cancel-in-progress: false - - permissions: - contents: read - - jobs: - claude: - if: | - github.event_name == 'workflow_dispatch' || - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) - runs-on: ubuntu-latest - timeout-minutes: 30 - permissions: - contents: write - pull-requests: write - issues: write - id-token: write - actions: read - steps: - - name: Checkout repository - uses: actions/checkout@v6 - with: - fetch-depth: 1 - - - name: Require Claude auth - env: - ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }} - run: | - if [[ -z "\${ANTHROPIC_API_KEY}" ]]; then - echo "Missing repository secret ANTHROPIC_API_KEY. Run /install-github-app in Claude Code or add the secret before using this workflow." >&2 - exit 1 - fi - - - name: Run Claude Code - uses: anthropics/claude-code-action@v1 - with: - anthropic_api_key: \${{ secrets.ANTHROPIC_API_KEY }} - track_progress: true - use_sticky_comment: true - additional_permissions: "actions: read" - prompt: | - REPO: \${{ github.repository }} - DEFAULT BRANCH: ${manifest.project.defaultBranch} - - Use CLAUDE.md and docs/bootstrap/onboarding.md as repo policy context. - ${ - manifest.github.requiredStatusChecks.length === 1 - ? `Keep ${primaryRequiredStatusCheck(manifest)} as the single required PR status check.` - : `Keep required PR status checks aligned with ${requiredStatusChecksPlain(manifest)}.` - } - Preserve the split fast and extended validation model. - Shell-safe jobs may use \`[self-hosted, synology, shell-only, ${manifest.project.visibility === "public" ? "public" : "private"}]\`. - Docker, service-container, browser, and \`container:\` jobs stay on GitHub-hosted runners. - Prefer the smallest safe change and add tests for behavior changes. - - MANUAL TASK: \${{ github.event.inputs.prompt }} - If this is not a manual run, ignore the MANUAL TASK line and respond to the current \`@claude\` request instead. - `; -} - -function claudeEnvironmentDoc(manifest: BootstrapManifest): string { - const featureList = [ - manifest.agents.enableClaudeWebEnvironment ? "- First-party hosted sessions at `claude.ai/code`" : null, - manifest.agents.enableClaudeDevcontainer - ? "- Interactive containerized work with `.devcontainer/devcontainer.json`" - : null, - manifest.agents.enableClaudeGitHubAction - ? "- GitHub-hosted automation with `.github/workflows/claude.yml`" - : null - ] - .filter((line): line is string => Boolean(line)) - .join("\n"); - - const webSection = manifest.agents.enableClaudeWebEnvironment - ? dedent` - - ## Claude Code On The Web - - - Hosted entrypoint: \`https://claude.ai/code\` - - Repo: \`${manifest.project.owner}/${manifest.project.name}\` - - Setup script: \`bash scripts/claude-cloud/setup.sh\` - - Network access: start with limited access; only expand it when a task truly needs more than registries and GitHub - - Environment variables: configure them in the Claude environment UI as \`.env\`-style key-value pairs - - GitHub integration: connect GitHub, install the Claude GitHub App, then pick this repo as an allowed target - - Repo guidance: Claude on the web reads \`CLAUDE.md\` from the repository - - ## Teleport And Remote Sessions - - - Start a hosted task from the terminal with \`claude --remote "your task"\` - - Pull a hosted session back into the terminal with \`claude --teleport\` - - Hosted tasks clone the default branch unless you specify a branch in the prompt - - Teleport requires a clean git state and the same repository/account pairing - ` - : ""; - - const devcontainerSection = manifest.agents.enableClaudeDevcontainer - ? dedent` - - ## Interactive Devcontainer - - - Open the repo in a devcontainer-capable editor and reopen in container. - - The container installs the Claude Code feature plus repo dependencies via \`bash scripts/claude/setup-devcontainer.sh\`. - - \`~/.claude\` is mounted into the container so Claude Code auth persists between sessions. - - Only use this with trusted repositories. Mounted Claude credentials are available inside the container. - ` - : ""; - - const actionSection = manifest.agents.enableClaudeGitHubAction - ? dedent` - - ## GitHub Action - - - Workflow file: \`.github/workflows/claude.yml\` - - Runner: \`ubuntu-latest\` - - Triggers: - - manual \`workflow_dispatch\` - - PR or issue comments containing \`@claude\` - - review comments or review bodies containing \`@claude\` - - Auth: - - preferred: run \`/install-github-app\` in Claude Code as a repo admin - - fallback: add a repository secret named \`ANTHROPIC_API_KEY\` - ` - : ""; - - const guardrailLines = [ - manifest.agents.enableClaudeGitHubAction - ? `- Keep the Claude workflow out of the required PR check set. The required checks are ${requiredStatusChecksDisplay(manifest)}.` - : null, - manifest.agents.enableClaudeWebEnvironment - ? "- Prefer Claude Code on the web for long-running async review or fix tasks; use the devcontainer when you need a local interactive container." - : null, - manifest.agents.enableClaudeDevcontainer - ? "- Treat the devcontainer as a trusted-repo workspace because the mounted `~/.claude` profile is available inside the container." - : null, - manifest.agents.enableClaudeGitHubAction - ? "- Do not relax the action to allow non-write users on public repos unless you intentionally accept the prompt-injection risk." - : null, - manifest.agents.enableClaudeGitHubAction - ? "- Keep Claude review and automation on GitHub-hosted runners; do not move it onto the self-hosted shell-only fleet." - : null - ] - .filter((line): line is string => Boolean(line)) - .join("\n"); - - return dedent` - # Claude Environment - - Claude Code on the web provides a first-party cloud environment comparable to Codex Web. This bootstrap prepares the hosted path first, then adds optional local and GitHub-native alternatives: - -${indentBlock(featureList, 4)} - - ## Project - -${indentBlock(projectIdentityLines(manifest), 4)} -${indentBlock(webSection, 4)} -${indentBlock(devcontainerSection, 4)} -${indentBlock(actionSection, 4)} - - ## Guardrails - -${indentBlock(guardrailLines, 4)} - - ## Project - - - Default branch: \`${manifest.project.defaultBranch}\` - `; -} - function fastChecksScript(manifest: BootstrapManifest): string { const body = manifest.archetype.kind === "python-service" @@ -1387,10 +930,8 @@ function workflowPaths(manifest: BootstrapManifest): { app: string[]; ci: string const common = [ "project.bootstrap.yaml", "AGENTS.md", - "CLAUDE.md", "CONTRIBUTING.md", ".github/PULL_REQUEST_TEMPLATE.md", - ".devcontainer/**", ".githooks/**", ".github/workflows/**", "scripts/**", @@ -1794,22 +1335,6 @@ ${indentBlock(setupSteps(manifest), 6)} function onboardingDoc(manifest: BootstrapManifest): string { const releaseTags = releaseTagExamples(manifest); - const claudeSetupLines = [ - manifest.agents.enableClaudeWebEnvironment - ? "- First-party Claude web sessions should use `bash scripts/claude-cloud/setup.sh` in `claude.ai/code`." - : null, - manifest.agents.enableClaudeDevcontainer - ? "- Interactive Claude work is prepared through `.devcontainer/devcontainer.json`." - : null, - manifest.agents.enableClaudeGitHubAction - ? "- GitHub-hosted Claude automation lives in `.github/workflows/claude.yml` and is intentionally separate from the required PR checks." - : null, - manifest.agents.enableClaudeGitHubAction - ? "- Finish GitHub-side auth by running `/install-github-app` in Claude Code or adding `ANTHROPIC_API_KEY` as a repo secret." - : null - ] - .filter((line): line is string => Boolean(line)) - .join("\n"); return dedent` # Bootstrap Onboarding @@ -1856,6 +1381,15 @@ ${indentBlock(additionalWorkflowSection(manifest), 4)} - To retrofit an existing bootstrapped repo, add \`CONTRIBUTING.md\` and \`.github/PULL_REQUEST_TEMPLATE.md\` to \`repo.managedPaths\` when that repo restricts managed paths, then run \`bootstrap apply repo --manifest ./project.bootstrap.yaml\`. - Keep these files repo-generic unless project metadata or the manifest requires a stricter local rule. + ## Fleet Reconciliation + + - Run \`bootstrap reconcile --workspace-root ~/src --report bootstrap-reconcile.json\` first; this is plan-only and does not write files. + - Add \`--org ${manifest.project.owner}\` when OpenClaw should enumerate GitHub repos first; missing local checkouts or repos without \`project.bootstrap.yaml\` are skipped and reported. + - Use \`--repo \` as the initial allowlist when onboarding daily OpenClaw reconciliation. + - Use \`--apply-repo --create-pr\` for unattended repo drift so generated changes go through draft PRs instead of default-branch pushes. + - Use \`--apply-github\` only after the report shape is trusted because it mutates repository settings, environments, branch protection, and labels directly through the GitHub API. + - Dirty target worktrees are blocked and reported instead of being overwritten. + ${manifest.release.enabled ? indentBlock( dedent` @@ -1885,11 +1419,7 @@ ${manifest.ci.aiAttestation.enabled ## Home Profiles - Run \`bootstrap apply home --manifest ./project.bootstrap.yaml\` after reviewing the bundled profile content. - - The bootstrap manages portable Codex and Claude assets only. Auth, sessions, caches, and machine-local state stay unmanaged. - - ## Claude Setup - -${indentBlock(claudeSetupLines, 4)} + - The bootstrap manages portable Codex assets only. Auth, sessions, caches, and machine-local state stay unmanaged. `; } @@ -1941,11 +1471,6 @@ export function renderManagedFiles(manifest: BootstrapManifest): RenderedFile[] reason: "Repo-local Codex instructions", contents: `${repoAgents(manifest)}\n` }, - { - path: "CLAUDE.md", - reason: "Repo-local Claude instructions", - contents: `${repoClaude(manifest)}\n` - }, { path: "CONTRIBUTING.md", reason: "Contributor workflow guidance", @@ -2051,40 +1576,6 @@ export function renderManagedFiles(manifest: BootstrapManifest): RenderedFile[] contents: codexCloudMaintenanceScript(manifest), executable: true }, - ...(manifest.agents.enableClaudeWebEnvironment - ? [ - { - path: "scripts/claude-cloud/setup.sh", - reason: "Claude cloud setup script", - contents: claudeCloudSetupScript(manifest), - executable: true - } - ] - : []), - ...(manifest.agents.enableClaudeDevcontainer - ? [ - { - path: ".devcontainer/devcontainer.json", - reason: "Claude interactive devcontainer", - contents: claudeDevcontainer(manifest) - }, - { - path: "scripts/claude/setup-devcontainer.sh", - reason: "Claude devcontainer dependency bootstrap", - contents: claudeDevcontainerSetupScript(manifest), - executable: true - } - ] - : []), - ...(manifest.agents.enableClaudeGitHubAction - ? [ - { - path: ".github/workflows/claude.yml", - reason: "Claude GitHub automation workflow", - contents: `${claudeWorkflow(manifest)}\n` - } - ] - : []), { path: "docs/bootstrap/onboarding.md", reason: "Operator onboarding checklist", @@ -2104,17 +1595,6 @@ export function renderManagedFiles(manifest: BootstrapManifest): RenderedFile[] } ] : []), - ...(manifest.agents.enableClaudeWebEnvironment || - manifest.agents.enableClaudeDevcontainer || - manifest.agents.enableClaudeGitHubAction - ? [ - { - path: "docs/bootstrap/claude-environment.md", - reason: "Claude environment setup guide", - contents: `${claudeEnvironmentDoc(manifest)}\n` - } - ] - : []) ]; switch (manifest.archetype.kind) { diff --git a/src/cli.ts b/src/cli.ts index b2d35cf..73d81e2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { Command } from "commander"; import { runDoctor } from "./doctor.js"; +import { reconcileFleet } from "./fleet.js"; import { planGitHub, applyGitHub } from "./github/provision.js"; import { planHome, applyHome } from "./home/sync.js"; import { @@ -43,6 +44,26 @@ function formatHomeActions(actions: Awaited>["action ].join("\n"); } +function formatFleetReport(report: Awaited>): string { + if (report.results.length === 0) { + return "Fleet: no bootstrapped repositories found."; + } + + return [ + `**Fleet ${report.mode}**`, + ...report.results.map((result) => { + const changed = result.repoChanges.filter((change) => change.type !== "unchanged").length; + const details = [ + `${result.repo}: ${result.status}`, + changed > 0 ? `${changed} repo change(s)` : "no repo drift", + result.pullRequestUrl ? `PR ${result.pullRequestUrl}` : undefined, + result.reason + ].filter(Boolean); + return `- ${details.join(" - ")}`; + }) + ].join("\n"); +} + async function main(): Promise { const program = new Command(); program @@ -122,6 +143,38 @@ async function main(): Promise { ); }); + program + .command("reconcile") + .description("Plan or apply bootstrap alignment across local bootstrapped repositories.") + .requiredOption("--workspace-root ", "Directory containing local repository checkouts") + .option("--org ", "Discover repositories from a GitHub org or user, then map them to local checkouts") + .option("--repo ", "Restrict to one or more repo names or owner/name values") + .option("--apply-repo", "Write repo-local bootstrap drift") + .option("--apply-github", "Apply GitHub settings and label drift") + .option("--create-pr", "Commit repo drift on a branch, push, and open a draft PR") + .option("--branch-prefix ", "Branch prefix for PR mode", "codex/bootstrap-reconcile") + .option("--report ", "Write JSON report") + .option("--json", "Emit JSON") + .action(async (options) => { + const report = await reconcileFleet({ + workspaceRoot: path.resolve(options.workspaceRoot), + org: options.org, + repos: options.repo, + applyRepo: options.applyRepo ?? false, + applyGitHub: options.applyGithub ?? false, + createPr: options.createPr ?? false, + branchPrefix: options.branchPrefix, + reportPath: options.report + }); + + if (options.json) { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + return; + } + + process.stdout.write(`${formatFleetReport(report)}\n`); + }); + const apply = program.command("apply").description("Apply one bootstrap target."); apply @@ -148,7 +201,7 @@ async function main(): Promise { apply .command("home") - .description("Sync portable Codex and Claude home assets.") + .description("Sync portable Codex home assets.") .option("--manifest ", "Path to manifest") .option("--home-dir ", "Override home directory") .action(async (options) => { diff --git a/src/doctor.ts b/src/doctor.ts index 94b2f7e..ea368ae 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -75,46 +75,6 @@ export async function runDoctor( }); } - if (manifest.agents.manageClaudeHome) { - const claudeAvailable = await commandExists(runner, "claude"); - checks.push({ - name: "Claude CLI", - status: claudeAvailable ? "ok" : "warn", - detail: claudeAvailable - ? "claude is available." - : "claude is not on PATH. Home sync can still write files, but the CLI is unavailable." - }); - } - - if (manifest.agents.enableClaudeDevcontainer) { - const dockerAvailable = await commandExists(runner, "docker"); - checks.push({ - name: "Claude devcontainer runtime", - status: dockerAvailable ? "ok" : "warn", - detail: dockerAvailable - ? "docker is available for the generated Claude devcontainer." - : "docker is not on PATH. The generated .devcontainer config will exist, but local container launches will fail until Docker is installed." - }); - } - - if (manifest.agents.enableClaudeWebEnvironment) { - checks.push({ - name: "Claude web environment", - status: "ok", - detail: - "Use claude.ai/code with the generated scripts/claude-cloud/setup.sh file, limited network access, and the repo CLAUDE.md instructions." - }); - } - - if (manifest.agents.enableClaudeGitHubAction) { - checks.push({ - name: "Claude GitHub Action", - status: "ok", - detail: - `The generated workflow is opt-in and separate from the required PR checks (${requiredStatusChecksLabel(manifest)}). Finish GitHub-side auth with the Claude GitHub app or a repository ANTHROPIC_API_KEY secret.` - }); - } - const runnerLabels = resolveRunsOn( manifest.ci.runnerPolicy, manifest.project.visibility, diff --git a/src/fleet.ts b/src/fleet.ts new file mode 100644 index 0000000..4b31d33 --- /dev/null +++ b/src/fleet.ts @@ -0,0 +1,366 @@ +import { readdir } from "node:fs/promises"; +import path from "node:path"; + +import { applyGitHub } from "./github/provision.js"; +import { exists, writeTextFile } from "./lib/fs.js"; +import type { CommandRunner } from "./lib/process.js"; +import { execRunner } from "./lib/process.js"; +import { loadManifest } from "./manifest.js"; +import { applyRepo, planRepo } from "./render.js"; +import type { PlannedFileChange, PlannedGitHubAction } from "./types.js"; + +export type FleetRepoStatus = + | "planned" + | "unchanged" + | "skipped" + | "applied" + | "pr-opened" + | "blocked" + | "failed"; + +export interface FleetRepoResult { + repo: string; + path: string; + status: FleetRepoStatus; + branch?: string; + pullRequestUrl?: string; + reason?: string; + repoChanges: PlannedFileChange[]; + githubActions: PlannedGitHubAction[]; +} + +export interface FleetReport { + mode: "plan" | "apply"; + generatedAt: string; + workspaceRoot: string; + results: FleetRepoResult[]; +} + +export interface ReconcileFleetOptions { + workspaceRoot: string; + org?: string; + repos?: string[]; + applyRepo?: boolean; + applyGitHub?: boolean; + createPr?: boolean; + branchPrefix?: string; + reportPath?: string; + runner?: CommandRunner; +} + +interface LocalRepo { + name: string; + nameWithOwner?: string; + path: string; +} + +function hasRepoDrift(changes: PlannedFileChange[]): boolean { + return changes.some((change) => change.type !== "unchanged"); +} + +function repoSlug(repoPath: string): string { + return path.basename(repoPath).replace(/[^a-zA-Z0-9._-]+/g, "-"); +} + +function branchName(repoPath: string, branchPrefix: string): string { + const date = new Date().toISOString().slice(0, 10).replace(/-/g, ""); + return `${branchPrefix}/${repoSlug(repoPath)}-${date}`; +} + +function parsePullRequestUrl(output: string): string | undefined { + return output + .split(/\s+/) + .find((token) => token.startsWith("https://github.com/") && token.includes("/pull/")); +} + +interface GitHubRepoListItem { + name: string; + nameWithOwner: string; + isArchived: boolean; +} + +async function discoverOrgRepos( + workspaceRoot: string, + org: string, + runner: CommandRunner +): Promise { + const result = await runner("gh", [ + "repo", + "list", + org, + "--limit", + "1000", + "--json", + "name,nameWithOwner,isArchived" + ]); + if (result.exitCode !== 0) { + throw new Error(result.stderr.trim() || result.stdout.trim() || `gh repo list ${org} failed`); + } + + const repos = JSON.parse(result.stdout) as GitHubRepoListItem[]; + return repos + .filter((repo) => !repo.isArchived) + .map((repo) => ({ + name: repo.name, + nameWithOwner: repo.nameWithOwner, + path: path.join(workspaceRoot, repo.name) + })) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +async function discoverWorkspaceRepos( + workspaceRoot: string, + runner: CommandRunner, + repos?: string[], + org?: string +): Promise { + if (repos && repos.length > 0) { + return repos.map((repo) => { + const name = repo.includes("/") ? repo.split("/").at(-1)! : repo; + return { + name, + ...(repo.includes("/") ? { nameWithOwner: repo } : {}), + path: path.resolve(workspaceRoot, name) + }; + }); + } + + if (org) { + return discoverOrgRepos(workspaceRoot, org, runner); + } + + const entries = await readdir(workspaceRoot, { withFileTypes: true }); + const candidates = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ + name: entry.name, + path: path.join(workspaceRoot, entry.name) + })); + const bootstrapped: LocalRepo[] = []; + for (const candidate of candidates) { + if (await exists(path.join(candidate.path, "project.bootstrap.yaml"))) { + bootstrapped.push(candidate); + } + } + return bootstrapped.sort((left, right) => left.name.localeCompare(right.name)); +} + +async function gitOutput( + runner: CommandRunner, + cwd: string, + args: string[] +): Promise { + const result = await runner("git", args, { cwd }); + if (result.exitCode !== 0) { + throw new Error(result.stderr.trim() || result.stdout.trim() || `git ${args.join(" ")} failed`); + } + return result.stdout.trim(); +} + +async function isCleanWorktree(runner: CommandRunner, cwd: string): Promise { + return (await gitOutput(runner, cwd, ["status", "--porcelain"])).length === 0; +} + +async function currentBranch(runner: CommandRunner, cwd: string): Promise { + return gitOutput(runner, cwd, ["rev-parse", "--abbrev-ref", "HEAD"]); +} + +async function applyRepoThroughPullRequest( + repo: LocalRepo, + manifestPath: string, + branch: string, + runner: CommandRunner +): Promise<{ branch: string; pullRequestUrl?: string }> { + await gitOutput(runner, repo.path, ["checkout", "-B", branch]); + await applyRepo(await loadManifest(manifestPath), repo.path); + await gitOutput(runner, repo.path, ["add", "."]); + await gitOutput(runner, repo.path, [ + "commit", + "-m", + "chore: reconcile bootstrap-managed files" + ]); + await gitOutput(runner, repo.path, ["push", "-u", "origin", branch]); + + const pr = await runner( + "gh", + [ + "pr", + "create", + "--fill", + "--draft", + "--title", + "chore: reconcile bootstrap-managed files", + "--body", + [ + "## Summary", + "- Reconcile bootstrap-managed files with the current control-plane templates.", + "", + "## Governing Issue", + "No governing issue is linked; this is scheduled bootstrap fleet reconciliation.", + "", + "## Validation", + "- [x] `bootstrap reconcile --apply-repo --create-pr` generated this PR after local repo apply.", + "- [ ] Required PR checks are expected to pass after GitHub runs them.", + "", + "## Bootstrap Governance", + "- [x] Changes are limited to bootstrap-managed file drift.", + "- [x] No real secrets, runtime auth, or machine-local env files are committed.", + "", + "## Notes", + "- Daily fleet reconciliation PR; review before merge." + ].join("\n") + ], + { cwd: repo.path } + ); + if (pr.exitCode !== 0) { + throw new Error(pr.stderr.trim() || pr.stdout.trim() || "gh pr create failed"); + } + + const pullRequestUrl = parsePullRequestUrl(pr.stdout); + return { + branch, + ...(pullRequestUrl ? { pullRequestUrl } : {}) + }; +} + +async function reconcileOneRepo( + repo: LocalRepo, + options: Required> & { + runner: CommandRunner; + } +): Promise { + const manifestPath = path.join(repo.path, "project.bootstrap.yaml"); + if (!(await exists(manifestPath))) { + return { + repo: repo.nameWithOwner ?? repo.name, + path: repo.path, + status: "skipped", + reason: "project.bootstrap.yaml not found.", + repoChanges: [], + githubActions: [] + }; + } + + const manifest = await loadManifest(manifestPath); + const plannedRepo = await planRepo(manifest, repo.path); + const repoDrift = hasRepoDrift(plannedRepo.changes); + let githubActions: PlannedGitHubAction[] = []; + + if (!repoDrift && !options.applyGitHub) { + return { + repo: `${manifest.project.owner}/${manifest.project.name}`, + path: repo.path, + status: "unchanged", + repoChanges: plannedRepo.changes, + githubActions + }; + } + + if (!options.applyRepo) { + if (options.applyGitHub) { + githubActions = await applyGitHub(manifest); + } + return { + repo: `${manifest.project.owner}/${manifest.project.name}`, + path: repo.path, + status: "planned", + ...(repoDrift ? { reason: "Repo drift detected; run with --apply-repo to write changes." } : {}), + repoChanges: plannedRepo.changes, + githubActions + }; + } + + if (!(await isCleanWorktree(options.runner, repo.path))) { + return { + repo: `${manifest.project.owner}/${manifest.project.name}`, + path: repo.path, + status: "blocked", + reason: "Target worktree is dirty; refusing to apply bootstrap changes.", + repoChanges: plannedRepo.changes, + githubActions + }; + } + + const startingBranch = await currentBranch(options.runner, repo.path); + const branch = branchName(repo.path, options.branchPrefix); + try { + if (options.createPr) { + const pr = await applyRepoThroughPullRequest(repo, manifestPath, branch, options.runner); + if (options.applyGitHub) { + githubActions = await applyGitHub(manifest); + } + return { + repo: `${manifest.project.owner}/${manifest.project.name}`, + path: repo.path, + status: "pr-opened", + branch: pr.branch, + ...(pr.pullRequestUrl ? { pullRequestUrl: pr.pullRequestUrl } : {}), + repoChanges: plannedRepo.changes, + githubActions + }; + } + + await applyRepo(manifest, repo.path); + if (options.applyGitHub) { + githubActions = await applyGitHub(manifest); + } + return { + repo: `${manifest.project.owner}/${manifest.project.name}`, + path: repo.path, + status: "applied", + repoChanges: plannedRepo.changes, + githubActions + }; + } finally { + if (options.createPr && startingBranch !== branch) { + await options.runner("git", ["checkout", startingBranch], { cwd: repo.path }); + } + } +} + +export async function reconcileFleet(options: ReconcileFleetOptions): Promise { + const workspaceRoot = path.resolve(options.workspaceRoot); + const runner = options.runner ?? execRunner; + const repos = await discoverWorkspaceRepos(workspaceRoot, runner, options.repos, options.org); + const results: FleetRepoResult[] = []; + const applyRepoOption = options.applyRepo ?? false; + const applyGitHubOption = options.applyGitHub ?? false; + const createPrOption = options.createPr ?? false; + const branchPrefix = options.branchPrefix ?? "codex/bootstrap-reconcile"; + + for (const repo of repos) { + try { + results.push( + await reconcileOneRepo(repo, { + applyRepo: applyRepoOption, + applyGitHub: applyGitHubOption, + createPr: createPrOption, + branchPrefix, + runner + }) + ); + } catch (error) { + results.push({ + repo: repo.name, + path: repo.path, + status: "failed", + reason: error instanceof Error ? error.message : String(error), + repoChanges: [], + githubActions: [] + }); + } + } + + const report: FleetReport = { + mode: applyRepoOption || applyGitHubOption ? "apply" : "plan", + generatedAt: new Date().toISOString(), + workspaceRoot, + results + }; + + if (options.reportPath) { + await writeTextFile(path.resolve(options.reportPath), `${JSON.stringify(report, null, 2)}\n`); + } + + return report; +} diff --git a/src/github/provision.ts b/src/github/provision.ts index 4499eb5..2a12708 100644 --- a/src/github/provision.ts +++ b/src/github/provision.ts @@ -27,6 +27,12 @@ interface GitHubOrganizationSettings { secret_scanning_push_protection_enabled_for_new_repositories: boolean; } +interface GitHubLabel { + name: string; + color: string; + description: string | null; +} + interface ReviewerIdentity { type: "User" | "Team"; id: number; @@ -40,6 +46,54 @@ function hasOrganizationPolicy(manifest: BootstrapManifest): boolean { return manifest.github.organization !== undefined; } +function labelEndpoint(manifest: BootstrapManifest, labelName: string): string { + return `/repos/${manifest.project.owner}/${manifest.project.name}/labels/${encodeURIComponent(labelName)}`; +} + +function labelNeedsUpdate( + desired: BootstrapManifest["github"]["issueLabels"][number], + existing: GitHubLabel | undefined +): boolean { + if (!existing) { + return true; + } + + return ( + existing.name !== desired.name || + existing.color.toLowerCase() !== desired.color.toLowerCase() || + (existing.description ?? "") !== desired.description + ); +} + +async function getLabel( + client: GitHubClient, + manifest: BootstrapManifest, + labelName: string +): Promise { + return client.tryApi("GET", labelEndpoint(manifest, labelName)); +} + +async function planIssueLabels( + manifest: BootstrapManifest, + client: GitHubClient +): Promise { + const labelStates = await Promise.all( + manifest.github.issueLabels.map(async (label) => ({ + label, + existing: await getLabel(client, manifest, label.name) + })) + ); + const driftCount = labelStates.filter(({ label, existing }) => labelNeedsUpdate(label, existing)).length; + + return { + id: driftCount > 0 ? "issue-labels" : "issue-labels-sync", + description: + driftCount > 0 + ? `Create or update ${driftCount} issue label(s) for ${manifest.project.owner}/${manifest.project.name}.` + : `Issue labels for ${manifest.project.owner}/${manifest.project.name} already match the manifest.` + }; +} + function organizationPayload(manifest: BootstrapManifest): Record | undefined { const organization = manifest.github.organization; if (!organization) { @@ -223,6 +277,10 @@ export async function planGitHub( { id: "environments", description: "Ensure dev, stage, and prod environments exist with reviewer gates and self-review prevention." + }, + { + id: "issue-labels", + description: `Ensure ${manifest.github.issueLabels.length} issue labels exist for issue routing, risk, status, and review gates.` } ); return actions; @@ -260,6 +318,7 @@ export async function planGitHub( id: "environments", description: `Ensure environments dev, stage, and prod exist with ${manifest.github.reviewers.length} default reviewer target(s).` }); + actions.push(await planIssueLabels(manifest, client)); return actions; } @@ -423,5 +482,26 @@ export async function applyGitHub( } } + for (const label of manifest.github.issueLabels) { + const existingLabel = await getLabel(client, manifest, label.name); + const payload = { + name: label.name, + color: label.color, + description: label.description + }; + + if (existingLabel) { + if (labelNeedsUpdate(label, existingLabel)) { + await client.api("PATCH", labelEndpoint(manifest, existingLabel.name), payload); + } + } else { + await client.api("POST", `/repos/${manifest.project.owner}/${manifest.project.name}/labels`, payload); + } + } + actions.push({ + id: "issue-labels", + description: `Synced ${manifest.github.issueLabels.length} issue labels.` + }); + return actions; } diff --git a/src/home/sync.ts b/src/home/sync.ts index f27f359..13ab491 100644 --- a/src/home/sync.ts +++ b/src/home/sync.ts @@ -100,13 +100,6 @@ export async function planHome( }); } - if (manifest.agents.manageClaudeHome) { - mappings.push({ - sourceDir: path.join(baseRoot, "profiles/home/claude"), - targetRoot: path.join(homeDir, ".claude") - }); - } - const files = (await Promise.all( mappings.map((mapping) => loadProfileFiles(mapping.sourceDir, mapping.targetRoot)) )).flat(); diff --git a/src/index.ts b/src/index.ts index 96c1e3f..54287d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * from "./doctor.js"; +export * from "./fleet.js"; export * from "./github/client.js"; export * from "./github/provision.js"; export * from "./home/sync.js"; diff --git a/src/manifest.ts b/src/manifest.ts index e56e30f..7e7a8ee 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -9,9 +9,43 @@ import type { CodeownerRule, DefaultRepositoryPermission, EnvironmentConfig, + IssueLabelConfig, OrganizationConfig } from "./types.js"; +export const DEFAULT_ISSUE_LABELS: IssueLabelConfig[] = [ + { name: "area:frontend", color: "1f77b4", description: "Frontend and user-interface work." }, + { name: "area:api", color: "2ca02c", description: "API contracts, endpoints, and integrations." }, + { name: "area:data", color: "9467bd", description: "Data models, persistence, migration, and analytics work." }, + { name: "area:ledger", color: "8c564b", description: "Ledger, accounting, transaction, or reconciliation work." }, + { name: "area:rules", color: "bcbd22", description: "Domain rules, policy logic, and decision engines." }, + { name: "area:ai", color: "17becf", description: "AI, agents, prompts, and model integration work." }, + { name: "area:infra", color: "7f7f7f", description: "Infrastructure, CI, deployment, and operations work." }, + { name: "area:security", color: "d62728", description: "Security-sensitive implementation or hardening work." }, + { name: "area:accessibility", color: "e377c2", description: "Accessibility and inclusive UX work." }, + { name: "area:qa", color: "ff7f0e", description: "Quality assurance, test coverage, and release validation." }, + { name: "risk:low", color: "0e8a16", description: "Low implementation or operational risk." }, + { name: "risk:medium", color: "fbca04", description: "Moderate implementation or operational risk." }, + { name: "risk:high", color: "d93f0b", description: "High implementation or operational risk." }, + { name: "risk:domain", color: "5319e7", description: "Domain correctness risk requiring subject-matter review." }, + { name: "risk:security", color: "b60205", description: "Security risk requiring explicit review." }, + { name: "risk:prod", color: "000000", description: "Production impact or rollout risk." }, + { name: "status:needs-spec", color: "cfd3d7", description: "Needs clearer scope, acceptance criteria, or constraints." }, + { name: "status:ready-for-agent", color: "0e8a16", description: "Ready for assigned agent implementation." }, + { name: "status:agent-building", color: "1d76db", description: "Agent implementation is in progress." }, + { name: "status:needs-review", color: "fbca04", description: "Needs review before merge or closure." }, + { name: "status:needs-human-approval", color: "d93f0b", description: "Needs explicit human approval before proceeding." }, + { name: "status:ready-to-merge", color: "0e8a16", description: "Ready to merge after required checks pass." }, + { name: "status:blocked", color: "b60205", description: "Blocked by a dependency, decision, credential, or access gate." }, + { name: "review:product", color: "0052cc", description: "Needs product review." }, + { name: "review:architecture", color: "5319e7", description: "Needs architecture review." }, + { name: "review:security", color: "b60205", description: "Needs security review." }, + { name: "review:tax", color: "d4c5f9", description: "Needs tax review." }, + { name: "review:legal", color: "c2e0c6", description: "Needs legal review." }, + { name: "review:accessibility", color: "e99695", description: "Needs accessibility review." }, + { name: "review:release", color: "f9d0c4", description: "Needs release review." } +]; + const environmentSchema = z.object({ reviewers: z.array(z.string()).optional(), requireApproval: z.boolean().optional(), @@ -24,6 +58,12 @@ const codeownerSchema = z.object({ owners: z.array(z.string().min(1)).min(1) }); +const issueLabelSchema = z.object({ + name: z.string().min(1), + color: z.string().regex(/^#?[0-9a-fA-F]{6}$/), + description: z.string().min(1).max(100) +}); + const organizationSecuritySchema = z.object({ dependabotAlerts: z.boolean().optional(), dependabotSecurityUpdates: z.boolean().optional(), @@ -72,6 +112,7 @@ const manifestSchema = z.object({ createRepo: z.boolean().optional(), reviewers: z.array(z.string()).optional(), codeowners: z.array(codeownerSchema).optional(), + issueLabels: z.array(issueLabelSchema).optional(), organization: organizationSchema.optional(), autoMerge: z.boolean().optional(), deleteBranchOnMerge: z.boolean().optional(), @@ -131,12 +172,7 @@ const manifestSchema = z.object({ agents: z .object({ manageCodexHome: z.boolean().optional(), - manageClaudeHome: z.boolean().optional(), codexProfile: z.string().optional(), - claudeProfile: z.string().optional(), - enableClaudeWebEnvironment: z.boolean().optional(), - enableClaudeDevcontainer: z.boolean().optional(), - enableClaudeGitHubAction: z.boolean().optional(), sharedSkills: z.array(z.string()).optional() }) .optional(), @@ -208,6 +244,16 @@ function normalizeCodeowners( ]; } +function normalizeIssueLabels( + labels: z.input[] | undefined +): IssueLabelConfig[] { + return (labels ?? DEFAULT_ISSUE_LABELS).map((label) => ({ + name: label.name.trim(), + color: label.color.replace(/^#/, "").toLowerCase(), + description: label.description.trim() + })); +} + function normalizeOrganization( organization: z.input | undefined ): OrganizationConfig | undefined { @@ -291,6 +337,7 @@ export function normalizeManifest(raw: z.input): Bootstra createRepo: github.createRepo ?? true, reviewers, codeowners: normalizeCodeowners(github.codeowners ?? [], reviewers), + issueLabels: normalizeIssueLabels(github.issueLabels), ...(organization ? { organization } : {}), autoMerge: github.autoMerge ?? true, deleteBranchOnMerge: github.deleteBranchOnMerge ?? true, @@ -341,12 +388,7 @@ export function normalizeManifest(raw: z.input): Bootstra }, agents: { manageCodexHome: parsed.agents?.manageCodexHome ?? true, - manageClaudeHome: parsed.agents?.manageClaudeHome ?? true, codexProfile: parsed.agents?.codexProfile ?? "default", - claudeProfile: parsed.agents?.claudeProfile ?? "default", - enableClaudeWebEnvironment: parsed.agents?.enableClaudeWebEnvironment ?? true, - enableClaudeDevcontainer: parsed.agents?.enableClaudeDevcontainer ?? true, - enableClaudeGitHubAction: parsed.agents?.enableClaudeGitHubAction ?? true, sharedSkills: parsed.agents?.sharedSkills ?? [] }, environments: { diff --git a/src/types.ts b/src/types.ts index 80551ef..80ace73 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,6 +8,12 @@ export interface CodeownerRule { owners: string[]; } +export interface IssueLabelConfig { + name: string; + color: string; + description: string; +} + export interface EnvironmentConfig { reviewers: string[]; requireApproval: boolean; @@ -62,6 +68,7 @@ export interface BootstrapManifest { createRepo: boolean; reviewers: string[]; codeowners: CodeownerRule[]; + issueLabels: IssueLabelConfig[]; organization?: OrganizationConfig; autoMerge: boolean; deleteBranchOnMerge: boolean; @@ -111,12 +118,7 @@ export interface BootstrapManifest { }; agents: { manageCodexHome: boolean; - manageClaudeHome: boolean; codexProfile: string; - claudeProfile: string; - enableClaudeWebEnvironment: boolean; - enableClaudeDevcontainer: boolean; - enableClaudeGitHubAction: boolean; sharedSkills: string[]; }; environments: { diff --git a/tests/__snapshots__/render.test.ts.snap b/tests/__snapshots__/render.test.ts.snap index 212e2e9..d82f17e 100644 --- a/tests/__snapshots__/render.test.ts.snap +++ b/tests/__snapshots__/render.test.ts.snap @@ -14,10 +14,6 @@ exports[`renderManagedFiles > renders a stable managed file set for generic-empt "executable": false, "path": "AGENTS.md", }, - { - "executable": false, - "path": "CLAUDE.md", - }, { "executable": false, "path": "CONTRIBUTING.md", @@ -82,22 +78,6 @@ exports[`renderManagedFiles > renders a stable managed file set for generic-empt "executable": true, "path": "scripts/codex-cloud/maintenance.sh", }, - { - "executable": true, - "path": "scripts/claude-cloud/setup.sh", - }, - { - "executable": false, - "path": ".devcontainer/devcontainer.json", - }, - { - "executable": true, - "path": "scripts/claude/setup-devcontainer.sh", - }, - { - "executable": false, - "path": ".github/workflows/claude.yml", - }, { "executable": false, "path": "docs/bootstrap/onboarding.md", @@ -110,10 +90,6 @@ exports[`renderManagedFiles > renders a stable managed file set for generic-empt "executable": false, "path": "docs/bootstrap/versioning.md", }, - { - "executable": false, - "path": "docs/bootstrap/claude-environment.md", - }, { "executable": false, "path": "docs/bootstrap/next-steps.md", @@ -135,10 +111,6 @@ exports[`renderManagedFiles > renders a stable managed file set for nextjs-web 1 "executable": false, "path": "AGENTS.md", }, - { - "executable": false, - "path": "CLAUDE.md", - }, { "executable": false, "path": "CONTRIBUTING.md", @@ -203,22 +175,6 @@ exports[`renderManagedFiles > renders a stable managed file set for nextjs-web 1 "executable": true, "path": "scripts/codex-cloud/maintenance.sh", }, - { - "executable": true, - "path": "scripts/claude-cloud/setup.sh", - }, - { - "executable": false, - "path": ".devcontainer/devcontainer.json", - }, - { - "executable": true, - "path": "scripts/claude/setup-devcontainer.sh", - }, - { - "executable": false, - "path": ".github/workflows/claude.yml", - }, { "executable": false, "path": "docs/bootstrap/onboarding.md", @@ -231,10 +187,6 @@ exports[`renderManagedFiles > renders a stable managed file set for nextjs-web 1 "executable": false, "path": "docs/bootstrap/versioning.md", }, - { - "executable": false, - "path": "docs/bootstrap/claude-environment.md", - }, { "executable": false, "path": "app/layout.tsx", @@ -264,10 +216,6 @@ exports[`renderManagedFiles > renders a stable managed file set for node-ts-serv "executable": false, "path": "AGENTS.md", }, - { - "executable": false, - "path": "CLAUDE.md", - }, { "executable": false, "path": "CONTRIBUTING.md", @@ -332,22 +280,6 @@ exports[`renderManagedFiles > renders a stable managed file set for node-ts-serv "executable": true, "path": "scripts/codex-cloud/maintenance.sh", }, - { - "executable": true, - "path": "scripts/claude-cloud/setup.sh", - }, - { - "executable": false, - "path": ".devcontainer/devcontainer.json", - }, - { - "executable": true, - "path": "scripts/claude/setup-devcontainer.sh", - }, - { - "executable": false, - "path": ".github/workflows/claude.yml", - }, { "executable": false, "path": "docs/bootstrap/onboarding.md", @@ -360,10 +292,6 @@ exports[`renderManagedFiles > renders a stable managed file set for node-ts-serv "executable": false, "path": "docs/bootstrap/versioning.md", }, - { - "executable": false, - "path": "docs/bootstrap/claude-environment.md", - }, { "executable": false, "path": "src/index.ts", @@ -385,10 +313,6 @@ exports[`renderManagedFiles > renders a stable managed file set for python-servi "executable": false, "path": "AGENTS.md", }, - { - "executable": false, - "path": "CLAUDE.md", - }, { "executable": false, "path": "CONTRIBUTING.md", @@ -453,22 +377,6 @@ exports[`renderManagedFiles > renders a stable managed file set for python-servi "executable": true, "path": "scripts/codex-cloud/maintenance.sh", }, - { - "executable": true, - "path": "scripts/claude-cloud/setup.sh", - }, - { - "executable": false, - "path": ".devcontainer/devcontainer.json", - }, - { - "executable": true, - "path": "scripts/claude/setup-devcontainer.sh", - }, - { - "executable": false, - "path": ".github/workflows/claude.yml", - }, { "executable": false, "path": "docs/bootstrap/onboarding.md", @@ -481,10 +389,6 @@ exports[`renderManagedFiles > renders a stable managed file set for python-servi "executable": false, "path": "docs/bootstrap/versioning.md", }, - { - "executable": false, - "path": "docs/bootstrap/claude-environment.md", - }, { "executable": false, "path": "src/python_service_demo/__init__.py", diff --git a/tests/fleet.test.ts b/tests/fleet.test.ts new file mode 100644 index 0000000..14c0b48 --- /dev/null +++ b/tests/fleet.test.ts @@ -0,0 +1,155 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { reconcileFleet } from "../src/fleet.js"; +import type { CommandRunner } from "../src/lib/process.js"; + +const tempDirs: string[] = []; + +async function makeTempDir(): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), "bootstrap-fleet-")); + tempDirs.push(dir); + return dir; +} + +async function writeManifest(repoPath: string, name: string): Promise { + await writeFile( + path.join(repoPath, "project.bootstrap.yaml"), + [ + "project:", + ` name: ${name}`, + " owner: acme", + "archetype:", + " kind: generic-empty", + "" + ].join("\n"), + "utf8" + ); +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe("reconcileFleet", () => { + it("plans local bootstrapped repos without mutating files", async () => { + const workspaceRoot = await makeTempDir(); + const repoPath = path.join(workspaceRoot, "example"); + await import("node:fs/promises").then(({ mkdir }) => mkdir(repoPath, { recursive: true })); + await writeManifest(repoPath, "example"); + + const report = await reconcileFleet({ workspaceRoot }); + + expect(report.mode).toBe("plan"); + expect(report.results).toHaveLength(1); + expect(report.results[0]).toMatchObject({ + repo: "acme/example", + status: "planned" + }); + await expect(readFile(path.join(repoPath, "AGENTS.md"), "utf8")).rejects.toThrow(); + }); + + it("blocks repo apply when the target worktree is dirty", async () => { + const workspaceRoot = await makeTempDir(); + const repoPath = path.join(workspaceRoot, "dirty"); + await import("node:fs/promises").then(({ mkdir }) => mkdir(repoPath, { recursive: true })); + await writeManifest(repoPath, "dirty"); + + const runner: CommandRunner = async (command, args) => { + if (command === "git" && args?.join(" ") === "status --porcelain") { + return { stdout: "M existing-file\n", stderr: "", exitCode: 0 }; + } + return { stdout: "", stderr: `unexpected ${command} ${args?.join(" ")}`, exitCode: 1 }; + }; + + const report = await reconcileFleet({ workspaceRoot, applyRepo: true, runner }); + + expect(report.results[0]).toMatchObject({ + repo: "acme/dirty", + status: "blocked", + reason: "Target worktree is dirty; refusing to apply bootstrap changes." + }); + }); + + it("opens a draft PR for repo drift when requested", async () => { + const workspaceRoot = await makeTempDir(); + const repoPath = path.join(workspaceRoot, "needs-sync"); + await import("node:fs/promises").then(({ mkdir }) => mkdir(repoPath, { recursive: true })); + await writeManifest(repoPath, "needs-sync"); + const calls: string[] = []; + const runner: CommandRunner = async (command, args) => { + calls.push(`${command} ${args?.join(" ") ?? ""}`.trim()); + if (command === "git" && args?.join(" ") === "status --porcelain") { + return { stdout: "", stderr: "", exitCode: 0 }; + } + if (command === "git" && args?.join(" ") === "rev-parse --abbrev-ref HEAD") { + return { stdout: "main\n", stderr: "", exitCode: 0 }; + } + if (command === "gh" && args?.slice(0, 2).join(" ") === "pr create") { + return { stdout: "https://github.com/acme/needs-sync/pull/42\n", stderr: "", exitCode: 0 }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }; + + const report = await reconcileFleet({ + workspaceRoot, + applyRepo: true, + createPr: true, + runner + }); + + expect(report.results[0]).toMatchObject({ + repo: "acme/needs-sync", + status: "pr-opened", + pullRequestUrl: "https://github.com/acme/needs-sync/pull/42" + }); + expect(calls.some((call) => call.startsWith("git checkout -B codex/bootstrap-reconcile/needs-sync-"))).toBe( + true + ); + expect(calls).toContain("git commit -m chore: reconcile bootstrap-managed files"); + expect(calls).toContain("git checkout main"); + }); + + it("can discover org repos and skip local checkouts that are not bootstrapped", async () => { + const workspaceRoot = await makeTempDir(); + const repoPath = path.join(workspaceRoot, "bootstrapped"); + await import("node:fs/promises").then(({ mkdir }) => mkdir(repoPath, { recursive: true })); + await writeManifest(repoPath, "bootstrapped"); + const runner: CommandRunner = async (command, args) => { + if (command === "gh" && args?.slice(0, 2).join(" ") === "repo list") { + return { + stdout: JSON.stringify([ + { + name: "bootstrapped", + nameWithOwner: "acme/bootstrapped", + isArchived: false + }, + { + name: "missing-local", + nameWithOwner: "acme/missing-local", + isArchived: false + }, + { + name: "archived", + nameWithOwner: "acme/archived", + isArchived: true + } + ]), + stderr: "", + exitCode: 0 + }; + } + return { stdout: "", stderr: `unexpected ${command} ${args?.join(" ")}`, exitCode: 1 }; + }; + + const report = await reconcileFleet({ workspaceRoot, org: "acme", runner }); + + expect(report.results.map((result) => [result.repo, result.status])).toEqual([ + ["acme/bootstrapped", "planned"], + ["acme/missing-local", "skipped"] + ]); + }); +}); diff --git a/tests/github-provision.test.ts b/tests/github-provision.test.ts index cbcb23f..6fb87fd 100644 --- a/tests/github-provision.test.ts +++ b/tests/github-provision.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { applyGitHub, planGitHub } from "../src/github/provision.js"; -import { normalizeManifest } from "../src/manifest.js"; +import { DEFAULT_ISSUE_LABELS, normalizeManifest } from "../src/manifest.js"; describe("GitHub provisioning", () => { it("produces a static plan when gh is unavailable", async () => { @@ -25,6 +25,7 @@ describe("GitHub provisioning", () => { expect(actions.map((action) => action.id)).toContain("github-auth"); expect(actions.map((action) => action.id)).toContain("branch-protection"); + expect(actions.map((action) => action.id)).toContain("issue-labels"); }); it("applies repo settings, branch protection, and environments through gh api", async () => { @@ -64,7 +65,10 @@ describe("GitHub provisioning", () => { if (endpoint === "/repos/acme/example") { return undefined; } - return { name: "example", full_name: "acme/example", private: true, visibility: "private" }; + if (endpoint.startsWith("/repos/acme/example/labels/")) { + return undefined; + } + return undefined; }, api: async (method: string, endpoint: string, payload?: unknown) => { calls.push({ method, endpoint, payload }); @@ -81,6 +85,7 @@ describe("GitHub provisioning", () => { const actions = await applyGitHub(manifest, client as never); expect(actions.map((action) => action.id)).toContain("organization-update"); expect(actions.map((action) => action.id)).toContain("repo-create"); + expect(actions.map((action) => action.id)).toContain("issue-labels"); expect(calls.some((call) => call.endpoint === "/orgs/acme" && call.method === "PATCH")).toBe(true); expect(calls.some((call) => call.endpoint === "/orgs/acme/repos" && call.method === "POST")).toBe(true); expect( @@ -106,6 +111,9 @@ describe("GitHub provisioning", () => { (call) => call.endpoint === "/repos/acme/example/environments/prod" && call.method === "PUT" ) ).toBe(true); + expect( + calls.filter((call) => call.endpoint === "/repos/acme/example/labels" && call.method === "POST") + ).toHaveLength(DEFAULT_ISSUE_LABELS.length); }); it("reports org-policy drift in the GitHub plan when organization defaults differ", async () => { @@ -143,6 +151,9 @@ describe("GitHub provisioning", () => { if (endpoint === "/repos/acme/example") { return undefined; } + if (endpoint.startsWith("/repos/acme/example/labels/")) { + return undefined; + } return undefined; }, api: async (method: string, endpoint: string) => { @@ -170,6 +181,9 @@ describe("GitHub provisioning", () => { expect(actions.find((action) => action.id === "organization")?.description).toContain( "Update organization defaults for acme." ); + expect(actions.find((action) => action.id === "issue-labels")?.description).toContain( + `Create or update ${DEFAULT_ISSUE_LABELS.length} issue label(s)` + ); }); it("falls back to bare environments when private-repo protection rules are unsupported", async () => { @@ -192,7 +206,12 @@ describe("GitHub provisioning", () => { const client = { isAvailable: async () => true, isAuthenticated: async () => true, - tryApi: async () => ({ name: "example", full_name: "acme/example", private: true, visibility: "private" }), + tryApi: async (method: string, endpoint: string) => { + if (endpoint.startsWith("/repos/acme/example/labels/")) { + return undefined; + } + return { name: "example", full_name: "acme/example", private: true, visibility: "private" }; + }, api: async (method: string, endpoint: string, payload?: unknown) => { calls.push({ method, endpoint, payload }); if (endpoint === "/users/acme") { @@ -252,4 +271,80 @@ describe("GitHub provisioning", () => { expect(manifest.github.autoMerge).toBe(true); expect(manifest.github.requireLastPushApproval).toBe(true); }); + + it("updates drifted labels and leaves matching labels alone", async () => { + const manifest = normalizeManifest({ + project: { + name: "example", + owner: "acme" + }, + archetype: { + kind: "generic-empty" + }, + github: { + createRepo: false, + issueLabels: [ + { + name: "area:frontend", + color: "1f77b4", + description: "Frontend and user-interface work." + }, + { + name: "risk:high", + color: "d93f0b", + description: "High implementation or operational risk." + } + ] + } + }); + + const calls: Array<{ method: string; endpoint: string; payload?: unknown }> = []; + const client = { + isAvailable: async () => true, + isAuthenticated: async () => true, + tryApi: async (method: string, endpoint: string) => { + calls.push({ method, endpoint }); + if (endpoint === "/repos/acme/example") { + return { name: "example", full_name: "acme/example", private: true, visibility: "private" }; + } + if (endpoint === "/repos/acme/example/labels/area%3Afrontend") { + return { + name: "area:frontend", + color: "1f77b4", + description: "Frontend and user-interface work." + }; + } + if (endpoint === "/repos/acme/example/labels/risk%3Ahigh") { + return { + name: "risk:high", + color: "cccccc", + description: "Old description." + }; + } + return undefined; + }, + api: async (method: string, endpoint: string, payload?: unknown) => { + calls.push({ method, endpoint, payload }); + if (endpoint === "/users/acme") { + return { login: "acme", type: "Organization" }; + } + return {}; + } + }; + + await applyGitHub(manifest, client as never); + + const labelWrites = calls.filter((call) => call.endpoint.includes("/labels/") && call.method === "PATCH"); + expect(labelWrites).toEqual([ + { + method: "PATCH", + endpoint: "/repos/acme/example/labels/risk%3Ahigh", + payload: { + name: "risk:high", + color: "d93f0b", + description: "High implementation or operational risk." + } + } + ]); + }); }); diff --git a/tests/home-sync.test.ts b/tests/home-sync.test.ts index 386a5c7..751fc9b 100644 --- a/tests/home-sync.test.ts +++ b/tests/home-sync.test.ts @@ -20,7 +20,7 @@ afterEach(async () => { }); describe("home sync", () => { - it("plans and applies portable Codex and Claude assets", async () => { + it("plans and applies portable Codex assets", async () => { const homeDir = await makeTempDir(); const manifest = normalizeManifest({ project: { @@ -37,7 +37,7 @@ describe("home sync", () => { const applied = await applyHome(manifest, homeDir); expect(applied.some((action) => action.path === ".codex/AGENTS.md")).toBe(true); - expect(applied.some((action) => action.path === ".claude/CLAUDE.md")).toBe(true); + expect(applied.some((action) => action.path.startsWith(".claude/"))).toBe(false); const codexAgents = await readFile(path.join(homeDir, ".codex/AGENTS.md"), "utf8"); expect(codexAgents).toContain("Codex Home Profile"); @@ -47,6 +47,36 @@ describe("home sync", () => { expect(secondPlan.actions.every((action) => action.type === "unchanged")).toBe(true); }); + it("backs out previously managed Claude home assets", async () => { + const homeDir = await makeTempDir(); + await mkdir(path.join(homeDir, ".claude"), { recursive: true }); + await writeFile(path.join(homeDir, ".claude/CLAUDE.md"), "legacy Claude profile\n", "utf8"); + await mkdir(path.join(homeDir, ".bootstrap"), { recursive: true }); + await writeFile( + path.join(homeDir, ".bootstrap/home-state.json"), + `${JSON.stringify({ managedFiles: { ".claude/CLAUDE.md": "abc123" } }, null, 2)}\n`, + "utf8" + ); + + const manifest = normalizeManifest({ + project: { + name: "example", + owner: "acme" + }, + archetype: { + kind: "generic-empty" + } + }); + + const plan = await planHome(manifest, homeDir); + expect(plan.actions.some((action) => action.path === ".claude/CLAUDE.md" && action.type === "delete")).toBe( + true + ); + + await applyHome(manifest, homeDir); + await expect(access(path.join(homeDir, ".claude/CLAUDE.md"))).rejects.toThrow(); + }); + it("loads legacy home state from the old path", async () => { const homeDir = await makeTempDir(); await mkdir(path.join(homeDir, ".new-project-bootstrap"), { recursive: true }); diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index 405758c..73fc439 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { normalizeManifest } from "../src/manifest.js"; +import { DEFAULT_ISSUE_LABELS, normalizeManifest } from "../src/manifest.js"; describe("normalizeManifest", () => { it("applies defaults and reviewer-derived governance", () => { @@ -27,6 +27,7 @@ describe("normalizeManifest", () => { } ]); expect(manifest.github.requiredStatusChecks).toEqual(["CI Gate"]); + expect(manifest.github.issueLabels).toEqual(DEFAULT_ISSUE_LABELS); expect(manifest.ci.aiAttestation).toEqual({ enabled: false, artifactName: "ai-attestation", @@ -46,9 +47,6 @@ describe("normalizeManifest", () => { reusableWorkflowRepo: "acme/bootstrap", reusableWorkflowRef: "refs/heads/main" }); - expect(manifest.agents.enableClaudeWebEnvironment).toBe(true); - expect(manifest.agents.enableClaudeDevcontainer).toBe(true); - expect(manifest.agents.enableClaudeGitHubAction).toBe(true); expect(manifest.environments.stage.reviewers).toEqual(["alice", "acme/platform"]); expect(manifest.environments.prod.branches).toEqual(["main"]); }); @@ -103,6 +101,35 @@ describe("normalizeManifest", () => { expect(manifest.github.requiredStatusChecks).toEqual(["test"]); }); + it("normalizes explicit issue label colors", () => { + const manifest = normalizeManifest({ + project: { + name: "labeled-repo", + owner: "acme" + }, + archetype: { + kind: "generic-empty" + }, + github: { + issueLabels: [ + { + name: "area:frontend", + color: "#1F77B4", + description: "Frontend work." + } + ] + } + }); + + expect(manifest.github.issueLabels).toEqual([ + { + name: "area:frontend", + color: "1f77b4", + description: "Frontend work." + } + ]); + }); + it("normalizes declared repo-specific workflow lanes", () => { const manifest = normalizeManifest({ project: { @@ -210,6 +237,30 @@ describe("normalizeManifest", () => { expect(manifest.project.displayName).toBe("Bootstrap"); }); + it("drops explicit legacy Claude agent settings", () => { + const manifest = normalizeManifest({ + project: { + name: "legacy-claude-repo", + owner: "acme" + }, + archetype: { + kind: "generic-empty" + }, + agents: { + manageClaudeHome: true, + enableClaudeWebEnvironment: true, + enableClaudeDevcontainer: true, + enableClaudeGitHubAction: true + } + }); + + expect(manifest.agents).toEqual({ + manageCodexHome: true, + codexProfile: "default", + sharedSkills: [] + }); + }); + it("normalizes optional organization governance settings", () => { const manifest = normalizeManifest({ project: { diff --git a/tests/render.test.ts b/tests/render.test.ts index 4929167..a55acdc 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -43,14 +43,10 @@ describe("renderManagedFiles", () => { expect(prTemplate?.contents).toContain("## Bootstrap Governance"); expect(prTemplate?.contents).toContain("## Notes"); - const claudeWorkflow = files.find((file) => file.path === ".github/workflows/claude.yml"); - expect(claudeWorkflow?.contents).toContain("uses: anthropics/claude-code-action@v1"); - - const devcontainer = files.find((file) => file.path === ".devcontainer/devcontainer.json"); - expect(devcontainer?.contents).toContain("ghcr.io/anthropics/devcontainer-features/claude-code:1"); - - const claudeCloudSetup = files.find((file) => file.path === "scripts/claude-cloud/setup.sh"); - expect(claudeCloudSetup?.contents).toContain("apt-get install -y gh"); + expect(files.some((file) => file.path === "CLAUDE.md")).toBe(false); + expect(files.some((file) => file.path === ".github/workflows/claude.yml")).toBe(false); + expect(files.some((file) => file.path === ".devcontainer/devcontainer.json")).toBe(false); + expect(files.some((file) => file.path === "scripts/claude-cloud/setup.sh")).toBe(false); const contributing = files.find((file) => file.path === "CONTRIBUTING.md"); expect(contributing?.contents).toContain("Use `.github/PULL_REQUEST_TEMPLATE.md`"); @@ -218,13 +214,11 @@ describe("renderManagedFiles", () => { const files = renderManagedFiles(manifest); const readme = files.find((file) => file.path === "README.md"); - const claude = files.find((file) => file.path === "CLAUDE.md"); const onboarding = files.find((file) => file.path === "docs/bootstrap/onboarding.md"); const prWorkflow = files.find((file) => file.path === ".github/workflows/pr-fast-ci.yml"); expect(readme?.contents).toContain("Repo-Specific Workflow Lanes"); expect(readme?.contents).toContain("`.github/workflows/deploy.yml`"); - expect(claude?.contents).toContain("stay adjunct to the standard PR and extended validation lanes"); expect(onboarding?.contents).toContain("Do not repurpose them as the required PR gate"); expect(prWorkflow?.contents).toContain("name: CI Gate"); }); diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index c9502f2..72a5acb 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -45,14 +45,9 @@ describe("repo smoke", () => { expect(workflow).toContain("name: CI Gate"); expect(workflow.match(/name: CI Gate/g)?.length).toBe(1); - const claudeWorkflow = await readFile(path.join(targetDir, ".github/workflows/claude.yml"), "utf8"); - expect(claudeWorkflow).toContain("anthropics/claude-code-action@v1"); - - const devcontainer = await readFile(path.join(targetDir, ".devcontainer/devcontainer.json"), "utf8"); - expect(devcontainer).toContain("\"ghcr.io/anthropics/devcontainer-features/claude-code:1\""); - - const claudeCloudSetup = await readFile(path.join(targetDir, "scripts/claude-cloud/setup.sh"), "utf8"); - expect(claudeCloudSetup).toContain("apt-get install -y gh"); + await expect(access(path.join(targetDir, ".github/workflows/claude.yml"))).rejects.toThrow(); + await expect(access(path.join(targetDir, ".devcontainer/devcontainer.json"))).rejects.toThrow(); + await expect(access(path.join(targetDir, "scripts/claude-cloud/setup.sh"))).rejects.toThrow(); const secondPlan = await planRepo(manifest, targetDir); expect(secondPlan.changes.every((change) => change.type === "unchanged")).toBe(true); @@ -71,12 +66,7 @@ describe("repo smoke", () => { managedPaths: [ "project.bootstrap.yaml", "AGENTS.md", - "CLAUDE.md", - ".devcontainer/**", - ".github/workflows/claude.yml", "scripts/codex-cloud/**", - "scripts/claude-cloud/**", - "scripts/claude/**", "docs/bootstrap/**" ] }, @@ -104,6 +94,64 @@ describe("repo smoke", () => { expect(secondPlan.changes.every((change) => change.type === "unchanged")).toBe(true); }); + it("backs out previously managed Claude files from an already bootstrapped repo", async () => { + const targetDir = await makeTempDir(); + const legacyPaths = [ + "CLAUDE.md", + ".github/workflows/claude.yml", + ".devcontainer/devcontainer.json", + "scripts/claude-cloud/setup.sh", + "scripts/claude/setup-devcontainer.sh", + "docs/bootstrap/claude-environment.md" + ]; + + for (const legacyPath of legacyPaths) { + const absolutePath = path.join(targetDir, legacyPath); + await mkdir(path.dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, "legacy Claude bootstrap file\n", "utf8"); + } + await mkdir(path.join(targetDir, ".bootstrap"), { recursive: true }); + await writeFile( + path.join(targetDir, ".bootstrap/bootstrap-state.json"), + `${JSON.stringify( + { + manifestHash: "legacy", + templateVersion: "legacy", + managedFiles: Object.fromEntries(legacyPaths.map((legacyPath) => [legacyPath, "legacy"])) + }, + null, + 2 + )}\n`, + "utf8" + ); + + const manifest = normalizeManifest({ + project: { + name: "already-bootstrapped", + owner: "acme" + }, + archetype: { + kind: "generic-empty" + }, + agents: { + manageCodexHome: true + } + }); + + const planBeforeApply = await planRepo(manifest, targetDir); + expect( + legacyPaths.every((legacyPath) => + planBeforeApply.changes.some((change) => change.path === legacyPath && change.type === "delete") + ) + ).toBe(true); + + await applyRepo(manifest, targetDir); + + for (const legacyPath of legacyPaths) { + await expect(access(path.join(targetDir, legacyPath))).rejects.toThrow(); + } + }); + it("stores bootstrap state under .git/info when the target is a git repository", async () => { const targetDir = await makeTempDir(); await execFileAsync("git", ["init"], { cwd: targetDir }); From edc22461cad3a51b2ca6757e2684d336d6052712 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 28 Apr 2026 15:23:43 -0500 Subject: [PATCH 2/3] chore: require merge automation in PRs --- .github/PULL_REQUEST_TEMPLATE.md | 4 ++++ .github/workflows/pr-fast-ci.yml | 6 ++++++ src/archetypes.ts | 10 ++++++++++ src/fleet.ts | 3 +++ 4 files changed, 23 insertions(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 86f8422..affaa06 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -18,6 +18,10 @@ Closes # - [ ] Contributor or PR guidance changes are reflected in `CONTRIBUTING.md`, `.github/PULL_REQUEST_TEMPLATE.md`, and `docs/bootstrap/onboarding.md` when applicable - [ ] No real secrets, runtime auth, or machine-local env files are committed +## Merge Automation + +- [ ] Auto-merge is enabled, or the reason it is unavailable or unsafe is noted below + ## Notes - diff --git a/.github/workflows/pr-fast-ci.yml b/.github/workflows/pr-fast-ci.yml index 16916d9..a2d0131 100644 --- a/.github/workflows/pr-fast-ci.yml +++ b/.github/workflows/pr-fast-ci.yml @@ -94,6 +94,7 @@ jobs: require_line "## Governing Issue" require_line "## Validation" require_line "## Bootstrap Governance" + require_line "## Merge Automation" require_line "## Notes" if grep -Eiq 'Closes #$|#|what changed|why it changed|notable tradeoffs|migration or rollout notes|follow-up work if any' <<<"$PR_BODY"; then @@ -111,6 +112,11 @@ jobs: failed=1 fi + if ! grep -Eiq '(auto-merge is enabled|auto-merge enabled|auto merge is enabled|auto merge enabled|auto-merge.*(unavailable|unsafe|blocked|not supported)|auto merge.*(unavailable|unsafe|blocked|not supported))' <<<"$PR_BODY"; then + echo "PR body must state that auto-merge is enabled or explain why it is unavailable or unsafe." + failed=1 + fi + exit "$failed" validate-secrets: diff --git a/src/archetypes.ts b/src/archetypes.ts index 9dd0f7d..d5e0e6d 100644 --- a/src/archetypes.ts +++ b/src/archetypes.ts @@ -385,6 +385,10 @@ function pullRequestTemplate(manifest: BootstrapManifest): string { - [ ] Contributor or PR guidance changes are reflected in \`CONTRIBUTING.md\`, \`.github/PULL_REQUEST_TEMPLATE.md\`, and \`docs/bootstrap/onboarding.md\` when applicable - [ ] No real secrets, runtime auth, or machine-local env files are committed + ## Merge Automation + + - [ ] Auto-merge is enabled, or the reason it is unavailable or unsafe is noted below + ## Notes - @@ -1138,6 +1142,7 @@ ${indentBlock(setupSteps(manifest), 6)} require_line "## Governing Issue" require_line "## Validation" require_line "## Bootstrap Governance" + require_line "## Merge Automation" require_line "## Notes" if grep -Eiq 'Closes #$|#|what changed|why it changed|notable tradeoffs|migration or rollout notes|follow-up work if any' <<<"$PR_BODY"; then @@ -1155,6 +1160,11 @@ ${indentBlock(setupSteps(manifest), 6)} failed=1 fi + if ! grep -Eiq '(auto-merge is enabled|auto-merge enabled|auto merge is enabled|auto merge enabled|auto-merge.*(unavailable|unsafe|blocked|not supported)|auto merge.*(unavailable|unsafe|blocked|not supported))' <<<"$PR_BODY"; then + echo "PR body must state that auto-merge is enabled or explain why it is unavailable or unsafe." + failed=1 + fi + exit "$failed" validate-secrets: diff --git a/src/fleet.ts b/src/fleet.ts index 4b31d33..80a1426 100644 --- a/src/fleet.ts +++ b/src/fleet.ts @@ -206,6 +206,9 @@ async function applyRepoThroughPullRequest( "- [x] Changes are limited to bootstrap-managed file drift.", "- [x] No real secrets, runtime auth, or machine-local env files are committed.", "", + "## Merge Automation", + "- [ ] Auto-merge is unavailable until a human reviews and enables it for this scheduled reconciliation PR.", + "", "## Notes", "- Daily fleet reconciliation PR; review before merge." ].join("\n") From d28ab2db9a629f299aea3066c3f15c2f1db8e7bf Mon Sep 17 00:00:00 2001 From: Pheidon Date: Thu, 30 Apr 2026 05:10:56 +0000 Subject: [PATCH 3/3] fix: handle GitHub-only fleet reconciliation --- src/cli.ts | 2 ++ src/fleet.ts | 20 +++++++++++++ tests/fleet.test.ts | 72 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index 73d81e2..9fce65e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -53,9 +53,11 @@ function formatFleetReport(report: Awaited>): `**Fleet ${report.mode}**`, ...report.results.map((result) => { const changed = result.repoChanges.filter((change) => change.type !== "unchanged").length; + const githubActions = result.githubActions.length; const details = [ `${result.repo}: ${result.status}`, changed > 0 ? `${changed} repo change(s)` : "no repo drift", + githubActions > 0 ? `${githubActions} GitHub action(s)` : undefined, result.pullRequestUrl ? `PR ${result.pullRequestUrl}` : undefined, result.reason ].filter(Boolean); diff --git a/src/fleet.ts b/src/fleet.ts index 80a1426..c1691b5 100644 --- a/src/fleet.ts +++ b/src/fleet.ts @@ -262,6 +262,14 @@ async function reconcileOneRepo( if (!options.applyRepo) { if (options.applyGitHub) { githubActions = await applyGitHub(manifest); + return { + repo: `${manifest.project.owner}/${manifest.project.name}`, + path: repo.path, + status: "applied", + ...(repoDrift ? { reason: "Repo drift detected; run with --apply-repo to write file changes." } : {}), + repoChanges: plannedRepo.changes, + githubActions + }; } return { repo: `${manifest.project.owner}/${manifest.project.name}`, @@ -284,6 +292,18 @@ async function reconcileOneRepo( }; } + if (!repoDrift && options.applyGitHub) { + githubActions = await applyGitHub(manifest); + return { + repo: `${manifest.project.owner}/${manifest.project.name}`, + path: repo.path, + status: "applied", + reason: "No repo drift; applied GitHub reconciliation without opening a PR.", + repoChanges: plannedRepo.changes, + githubActions + }; + } + const startingBranch = await currentBranch(options.runner, repo.path); const branch = branchName(repo.path, options.branchPrefix); try { diff --git a/tests/fleet.test.ts b/tests/fleet.test.ts index 14c0b48..5627195 100644 --- a/tests/fleet.test.ts +++ b/tests/fleet.test.ts @@ -2,7 +2,15 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const applyGitHubMock = vi.hoisted(() => + vi.fn(async () => [{ id: "issue-labels", description: "Synced issue labels." }]) +); + +vi.mock("../src/github/provision.js", () => ({ + applyGitHub: applyGitHubMock +})); import { reconcileFleet } from "../src/fleet.js"; import type { CommandRunner } from "../src/lib/process.js"; @@ -31,6 +39,8 @@ async function writeManifest(repoPath: string, name: string): Promise { } afterEach(async () => { + applyGitHubMock.mockReset(); + applyGitHubMock.mockResolvedValue([{ id: "issue-labels", description: "Synced issue labels." }]); await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); }); @@ -113,6 +123,66 @@ describe("reconcileFleet", () => { expect(calls).toContain("git checkout main"); }); + + it("reports GitHub-only apply runs as applied instead of planned", async () => { + const workspaceRoot = await makeTempDir(); + const repoPath = path.join(workspaceRoot, "github-only"); + await import("node:fs/promises").then(({ mkdir }) => mkdir(repoPath, { recursive: true })); + await writeManifest(repoPath, "github-only"); + + const report = await reconcileFleet({ workspaceRoot, applyGitHub: true }); + + expect(applyGitHubMock).toHaveBeenCalledTimes(1); + expect(report.mode).toBe("apply"); + expect(report.results[0]).toMatchObject({ + repo: "acme/github-only", + status: "applied", + reason: "Repo drift detected; run with --apply-repo to write file changes.", + githubActions: [{ id: "issue-labels", description: "Synced issue labels." }] + }); + }); + + it("applies GitHub drift without opening an empty PR when repo files are already current", async () => { + const workspaceRoot = await makeTempDir(); + const repoPath = path.join(workspaceRoot, "github-current"); + await import("node:fs/promises").then(({ mkdir }) => mkdir(repoPath, { recursive: true })); + await writeManifest(repoPath, "github-current"); + const calls: string[] = []; + const runner: CommandRunner = async (command, args) => { + calls.push(`${command} ${args?.join(" ") ?? ""}`.trim()); + if (command === "git" && args?.join(" ") === "status --porcelain") { + return { stdout: "", stderr: "", exitCode: 0 }; + } + if (command === "git" && args?.join(" ") === "rev-parse --abbrev-ref HEAD") { + return { stdout: "main\n", stderr: "", exitCode: 0 }; + } + return { stdout: "", stderr: "", exitCode: 0 }; + }; + + await reconcileFleet({ workspaceRoot, applyRepo: true, runner }); + calls.length = 0; + applyGitHubMock.mockClear(); + + const report = await reconcileFleet({ + workspaceRoot, + applyRepo: true, + applyGitHub: true, + createPr: true, + runner + }); + + expect(applyGitHubMock).toHaveBeenCalledTimes(1); + expect(report.results[0]).toMatchObject({ + repo: "acme/github-current", + status: "applied", + reason: "No repo drift; applied GitHub reconciliation without opening a PR." + }); + expect(calls).toContain("git status --porcelain"); + expect(calls.some((call) => call.startsWith("git checkout -B "))).toBe(false); + expect(calls).not.toContain("git commit -m chore: reconcile bootstrap-managed files"); + expect(calls.some((call) => call.startsWith("gh pr create"))).toBe(false); + }); + it("can discover org repos and skip local checkouts that are not bootstrapped", async () => { const workspaceRoot = await makeTempDir(); const repoPath = path.join(workspaceRoot, "bootstrapped");