diff --git a/.cursor/README.md b/.cursor/README.md index c014fc461c9..afe60da714c 100644 --- a/.cursor/README.md +++ b/.cursor/README.md @@ -16,10 +16,12 @@ The Docker build context is `.cursor/` only. The Dockerfile intentionally does n ## Runtime Hooks -- `cloud-agent-install.sh` runs after Cursor checks out the repo. It refreshes nvm, verifies Cursor's multi-repo `mattermost/enterprise` checkout, runs `server` Go dependency hydration, installs webapp dependencies, and runs Playwright `npm ci`. +- `cloud-agent-install.sh` runs after Cursor checks out the repo. It refreshes nvm, locates or clones `mattermost/enterprise`, runs `server` Go dependency hydration, installs webapp dependencies, and runs Playwright `npm ci`. - `cloud-agent-start.sh` materializes `.cursor/cursor.md` as `.cursor/AGENTS.md`, fixes current-session Docker socket access, starts Docker, waits until `docker info` and `docker compose version` succeed, then logs in to Docker Hub when credentials are configured. -The environment declares `github.com/mattermost/enterprise` in `repositoryDependencies` so Cursor can provide it as part of the multi-repo workspace. Cursor currently clones the repositories as siblings, such as `/agent/repos/mattermost` and `/agent/repos/enterprise`, which matches `server/Makefile`'s default `../../enterprise` path. The install hook does not clone, pull, or symlink enterprise. +The environment declares `github.com/mattermost/enterprise` in `repositoryDependencies` so Cursor can provide it as part of the multi-repo workspace and so the Cloud Agent GitHub token can read that private repo. Cursor currently clones the repositories as siblings when you explicitly select the multi-repo environment, such as `/agent/repos/mattermost` and `/agent/repos/enterprise`, which matches `server/Makefile`'s default `../../enterprise` path. + +Git-triggered automations (for example, a PR label) still launch in a single-repo layout. `repositoryDependencies` only adds enterprise to the token scope in that mode; it does not check the repo out. The install hook therefore clones `https://github.com/mattermost/enterprise.git` into `../enterprise` when no usable checkout is present. It uses `ENTERPRISE_BRANCH` when set, otherwise preferring an exact branch matching the Mattermost checkout and falling back to Enterprise's default branch. If the sibling path is not writable or is not a usable work tree (for example a bare repo), the hook clones into `$HOME/enterprise` and writes `BUILD_ENTERPRISE_DIR` to `server/config.override.mk`. `server/Makefile` includes that file before Enterprise detection so `make run-server` honors the fallback path. ## Useful Skips diff --git a/.cursor/cursor.md b/.cursor/cursor.md index ade6117bedc..e1fe4a1c9fb 100644 --- a/.cursor/cursor.md +++ b/.cursor/cursor.md @@ -6,7 +6,8 @@ These instructions apply to Cursor Cloud Agents after `.cursor/scripts/cloud-age - Docker must be available. If `docker info` fails, inspect `/tmp/docker-service-start.log` and `/tmp/dockerd.log`; do not assume a snapshot will provide Docker. - The image includes Go, Node/npm, Docker Compose, and AWS CLI v2. -- Cursor should provide `mattermost/enterprise` through the multi-repo environment. The expected layout is sibling repositories, such as `/agent/repos/mattermost` and `/agent/repos/enterprise`; this matches `server/Makefile`'s default `../../enterprise` path. +- Prefer Cursor's multi-repo layout for `mattermost/enterprise` when it is available: sibling repositories such as `/agent/repos/mattermost` and `/agent/repos/enterprise`, matching `server/Makefile`'s default `../../enterprise` path. +- Git-triggered automations start as a single-repo checkout. If enterprise is missing, `cloud-agent-install.sh` clones it with the token scoped by `repositoryDependencies`, preferring an exact like-named branch and falling back to Enterprise's default branch. If that clone lands somewhere other than `../enterprise`, `BUILD_ENTERPRISE_DIR` is written to `server/config.override.mk`, which `server/Makefile` reads before Enterprise detection. ## Running Mattermost diff --git a/.cursor/scripts/cloud-agent-install.sh b/.cursor/scripts/cloud-agent-install.sh index bb0d97c7ec4..591a94ea2cb 100755 --- a/.cursor/scripts/cloud-agent-install.sh +++ b/.cursor/scripts/cloud-agent-install.sh @@ -12,6 +12,10 @@ is_true() { esac } +is_git_work_tree() { + [ "$(git -C "${1:-}" rev-parse --is-inside-work-tree 2>/dev/null)" = "true" ] +} + ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" cd "$ROOT" @@ -220,6 +224,10 @@ enterprise_build_dir() { esac } +default_enterprise_checkout() { + realpath -m "$ROOT/../enterprise" +} + find_enterprise_checkout() { local candidates=() if [ -n "${ENTERPRISE_CHECKOUT_DIR:-}" ]; then @@ -228,16 +236,22 @@ find_enterprise_checkout() { if [ -n "${ENTERPRISE_DIR:-}" ]; then candidates+=("$ENTERPRISE_DIR") fi + if [ -n "${BUILD_ENTERPRISE_DIR:-}" ]; then + case "$BUILD_ENTERPRISE_DIR" in + /*) candidates+=("$BUILD_ENTERPRISE_DIR") ;; + *) candidates+=("$ROOT/server/$BUILD_ENTERPRISE_DIR") ;; + esac + fi candidates+=( - "$ROOT/../enterprise" + "$(default_enterprise_checkout)" "$ROOT/../../enterprise" "$HOME/enterprise" ) local candidate for candidate in "${candidates[@]}"; do - if git -C "$candidate" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + if is_git_work_tree "$candidate"; then realpath -m "$candidate" return 0 fi @@ -246,18 +260,253 @@ find_enterprise_checkout() { return 1 } -verify_enterprise_checkout() { +enterprise_clone_url() { + printf 'https://github.com/mattermost/enterprise.git\n' +} + +requested_enterprise_branch() { + local branch + if [ -n "${ENTERPRISE_BRANCH:-}" ]; then + branch="$ENTERPRISE_BRANCH" + elif ! branch="$(git -C "$ROOT" symbolic-ref --quiet --short HEAD 2>/dev/null)"; then + return 1 + fi + if ! git check-ref-format --branch "$branch" >/dev/null 2>&1; then + return 1 + fi + + printf '%s\n' "$branch" +} + +enterprise_branch_exists() { + local branch="$1" + local url + url="$(enterprise_clone_url)" + + GIT_TERMINAL_PROMPT=0 git ls-remote --exit-code --heads "$url" "refs/heads/$branch" >/dev/null 2>&1 && return 0 + local status=$? + if [ "$status" -eq 2 ]; then + return 1 + fi + + if ! command -v gh >/dev/null 2>&1; then + return 2 + fi + + local exists + if ! exists="$( + gh api graphql \ + -f query="query(\$qualifiedName: String!) { repository(owner: \"mattermost\", name: \"enterprise\") { ref(qualifiedName: \$qualifiedName) { id } } }" \ + -f qualifiedName="refs/heads/$branch" \ + --jq '.data.repository.ref != null' 2>/dev/null + )"; then + return 2 + fi + + [ "$exists" = "true" ] +} + +matching_enterprise_branch() { + local branch + if ! branch="$(requested_enterprise_branch)"; then + log "No valid Enterprise branch was requested; using Enterprise's default branch." + return 1 + fi + + local status + if enterprise_branch_exists "$branch"; then + printf '%s\n' "$branch" + return 0 + else + status=$? + fi + + if [ "$status" -eq 1 ]; then + log "Enterprise branch $branch does not exist; using Enterprise's default branch." + return 1 + fi + + log "Could not verify Enterprise branch $branch; attempting it before the default branch." + printf '%s\n' "$branch" +} + +can_clone_into() { + local dest="$1" + local parent + parent="$(dirname "$dest")" + if [ ! -d "$parent" ] || [ ! -w "$parent" ] || [ ! -x "$parent" ]; then + return 1 + fi + if [ -L "$dest" ]; then + return 1 + fi + if [ ! -e "$dest" ]; then + return 0 + fi + if is_git_work_tree "$dest"; then + return 0 + fi + if [ ! -d "$dest" ] || [ ! -w "$dest" ] || [ ! -x "$dest" ]; then + return 1 + fi + + local entries + if ! entries="$(ls -A "$dest" 2>/dev/null)"; then + return 1 + fi + [ -z "$entries" ] +} + +publish_enterprise_checkout() { + local source="$1" + local dest="$2" + + if mv -T -n "$source" "$dest" && [ ! -e "$source" ]; then + return 0 + fi + + rm -rf "$source" + if is_git_work_tree "$dest"; then + log "Another process populated Enterprise checkout at $dest; reusing it." + return 0 + fi + + log "Could not publish Enterprise checkout at $dest." + return 1 +} + +clone_enterprise_ref() { + local dest="$1" + local url="$2" + shift 2 + local clone_args=("$@") + local temp + + if ! temp="$(mktemp -d "$(dirname "$dest")/.enterprise-clone.XXXXXX")"; then + return 1 + fi + if GIT_TERMINAL_PROMPT=0 git clone "${clone_args[@]}" "$url" "$temp"; then + publish_enterprise_checkout "$temp" "$dest" + return $? + fi + rm -rf "$temp" + + if ! command -v gh >/dev/null 2>&1; then + return 1 + fi + + log "git clone failed; retrying with GitHub CLI." + if ! temp="$(mktemp -d "$(dirname "$dest")/.enterprise-clone.XXXXXX")"; then + return 1 + fi + if gh repo clone mattermost/enterprise "$temp" -- "${clone_args[@]}"; then + publish_enterprise_checkout "$temp" "$dest" + return $? + fi + rm -rf "$temp" + return 1 +} + +enterprise_clone_dest() { + local sibling + sibling="$(default_enterprise_checkout)" + if can_clone_into "$sibling"; then + printf '%s\n' "$sibling" + return 0 + fi + + realpath -m "$HOME/enterprise" +} + +clone_enterprise_checkout() { + local dest="$1" + local url + url="$(enterprise_clone_url)" + + if [ -z "$dest" ] || [ "$dest" = "/" ] || [ "$(basename "$dest")" != "enterprise" ]; then + log "Refusing unsafe enterprise clone dest: ${dest:-}" + return 1 + fi + + if ! can_clone_into "$dest"; then + log "Refusing unusable enterprise clone dest: $dest" + return 1 + fi + + if [ -e "$dest" ]; then + if is_git_work_tree "$dest"; then + return 0 + fi + rmdir "$dest" + fi + + log "Enterprise checkout missing; cloning mattermost/enterprise into $dest." + log "Git-triggered automations stay single-repo; repositoryDependencies only scopes the GitHub token." + mkdir -p "$(dirname "$dest")" + + local clone_args=(--depth 1 --single-branch) + local branch + if branch="$(matching_enterprise_branch)"; then + clone_args+=(--branch "$branch") + log "Trying matching Enterprise branch $branch." + fi + + if clone_enterprise_ref "$dest" "$url" "${clone_args[@]}"; then + return 0 + fi + + if [ -n "${branch:-}" ]; then + log "Could not clone matching Enterprise branch $branch; retrying with the default branch." + clone_args=(--depth 1 --single-branch) + clone_enterprise_ref "$dest" "$url" "${clone_args[@]}" && return 0 + fi + + log "Failed to clone mattermost/enterprise. repositoryDependencies must include github.com/mattermost/enterprise so the GitHub token can access it." + return 1 +} + +persist_build_enterprise_dir() { + local target="$1" + export BUILD_ENTERPRISE_DIR="$target" + + local default_target + default_target="$(default_enterprise_checkout)" + if [ "$target" = "$default_target" ]; then + return 0 + fi + + local override="$ROOT/server/config.override.mk" + if [ -f "$override" ] && grep -q '^BUILD_ENTERPRISE_DIR' "$override"; then + return 0 + fi + + printf 'BUILD_ENTERPRISE_DIR := %s\n' "$target" >> "$override" + log "Wrote BUILD_ENTERPRISE_DIR to $override so make uses the cloned checkout." +} + +ensure_enterprise_checkout() { if is_true "${CLOUD_AGENT_SKIP_ENTERPRISE:-false}"; then log "Skipping enterprise verification because CLOUD_AGENT_SKIP_ENTERPRISE is set." return 0 fi local target - if ! target="$(find_enterprise_checkout)"; then - log "Enterprise checkout not found. Ensure the Cursor multi-repo environment includes github.com/mattermost/enterprise." + if target="$(find_enterprise_checkout)"; then + persist_build_enterprise_dir "$target" + log "Enterprise checkout ready at $target." + return 0 + fi + + local dest + dest="$(enterprise_clone_dest)" + clone_enterprise_checkout "$dest" + target="$(realpath -m "$dest")" + if ! is_git_work_tree "$target"; then + log "Enterprise clone completed but checkout was not found at $dest." return 1 fi + persist_build_enterprise_dir "$target" log "Enterprise checkout ready at $target." } @@ -320,7 +569,7 @@ hydrate_playwright_dependencies() { ensure_go ensure_node -verify_enterprise_checkout +ensure_enterprise_checkout hydrate_go_dependencies hydrate_webapp_dependencies hydrate_playwright_dependencies diff --git a/AGENTS.md b/AGENTS.md index 83762df84c9..8843a0d85aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,5 @@ When creating a pull request, follow `.github/PULL_REQUEST_TEMPLATE.md` exactly: ## Cursor Cloud Agents -This repository has a checked-in Cloud Agent environment under `.cursor/`. Docker is started by `.cursor/scripts/cloud-agent-start.sh`; if Docker is unavailable in Cloud, treat that as an environment failure rather than falling back to snapshot assumptions. - -The environment declares `mattermost/enterprise` as a Cursor multi-repo dependency. Cursor clones the repositories as siblings, so `server/Makefile` can use its default `../../enterprise` path; the install hook does not clone or symlink enterprise. +Cursor Cloud Agents must follow `.cursor/AGENTS.md`, materialized by `.cursor/scripts/cloud-agent-start.sh`. diff --git a/server/Makefile b/server/Makefile index 9113584f54d..139a2dd0d0e 100644 --- a/server/Makefile +++ b/server/Makefile @@ -1,5 +1,8 @@ ROOT := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) +# Must be read before parse-time Enterprise detection so BUILD_ENTERPRISE_DIR applies. +-include $(ROOT)config.override.mk + ifeq ($(OS),Windows_NT) PLATFORM := Windows else @@ -195,8 +198,6 @@ CONFIG_FILE_PATH ?= ./config/config.json .PHONY: all all: run ## Alias for 'run'. --include config.override.mk - # Make sure not to modify an overridden ENABLED_DOCKER_SERVICES variable DOCKER_SERVICES_OVERRIDE=false ifneq (,$(ENABLED_DOCKER_SERVICES))