diff --git a/.claims/claims.json b/.claims/claims.json new file mode 100644 index 0000000..63d4a96 --- /dev/null +++ b/.claims/claims.json @@ -0,0 +1,3 @@ +{ + "claims": [] +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4da50fb --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Copy to .env and fill values +APP_ENV=development +PORT=3000 diff --git a/.github/workflows/standard-ci.yml b/.github/workflows/standard-ci.yml new file mode 100644 index 0000000..172e33d --- /dev/null +++ b/.github/workflows/standard-ci.yml @@ -0,0 +1,49 @@ +name: Standard CI + +on: + pull_request: + push: + branches: [main, master, develop] + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + if: ${{ hashFiles('package.json') != '' }} + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Setup Python + if: ${{ hashFiles('requirements.txt') != '' }} + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install Node deps + if: ${{ hashFiles('package.json') != '' }} + run: | + npm install -g pnpm || true + if [ -f pnpm-lock.yaml ]; then pnpm install --frozen-lockfile; else npm ci || npm install; fi + + - name: Install Python deps + if: ${{ hashFiles('requirements.txt') != '' }} + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest || true + + - name: Validate Claims JSON + run: | + if [ -f .claims/claims.json ]; then + jq . .claims/claims.json >/dev/null + fi + + - name: Run tests + run: | + chmod +x scripts/test.sh || true + if [ -f scripts/test.sh ]; then ./scripts/test.sh; else echo "No scripts/test.sh"; fi diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..8a6d45c --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,7 @@ +# Default owners. Replace with your team handles. +* @OWNER_PLACEHOLDER + +# Example ownership split (edit per repo) +# /frontend/ @frontend-owner +# /backend/ @backend-owner +# /infra/ @platform-owner diff --git a/scripts/check-env.sh b/scripts/check-env.sh new file mode 100755 index 0000000..873f048 --- /dev/null +++ b/scripts/check-env.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +required=(APP_ENV) + +if [[ -f .env ]]; then + # shellcheck disable=SC1091 + source .env +fi + +missing=() +for key in "${required[@]}"; do + if [[ -z "${!key:-}" ]]; then + missing+=("$key") + fi +done + +if (( ${#missing[@]} > 0 )); then + echo "Missing required env vars: ${missing[*]}" >&2 + exit 1 +fi + +echo "Environment looks good" diff --git a/scripts/claim.sh b/scripts/claim.sh new file mode 100755 index 0000000..f157790 --- /dev/null +++ b/scripts/claim.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +set -euo pipefail + +CLAIMS_FILE=".claims/claims.json" +DEFAULT_TTL_HOURS="4" + +usage() { + cat < [ttl_hours] + scripts/claim.sh release + scripts/claim.sh status [path] + scripts/claim.sh prune + +Examples: + scripts/claim.sh claim dev1 src/api 4 + scripts/claim.sh release dev1 src/api + scripts/claim.sh status src +USAGE +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || { + echo "Missing required command: $1" >&2 + exit 1 + } +} + +now_epoch() { + date +%s +} + +ensure_store() { + mkdir -p "$(dirname "$CLAIMS_FILE")" + if [[ ! -f "$CLAIMS_FILE" ]]; then + cat > "$CLAIMS_FILE" < $now))' "$CLAIMS_FILE" > "$tmp" + mv "$tmp" "$CLAIMS_FILE" +} + +claim_path() { + local owner="$1" + local path="$2" + local ttl_hours="${3:-$DEFAULT_TTL_HOURS}" + local now expires + + now="$(now_epoch)" + expires="$(( now + ttl_hours * 3600 ))" + + prune_expired + + local conflict + conflict="$(jq -r --arg p "$path" --arg o "$owner" ' + .claims[] + | select(.owner != $o) + | select((.path | startswith($p)) or ($p | startswith(.path))) + | "\(.owner)|\(.path)|\(.expires_at)" + ' "$CLAIMS_FILE" | head -n1 || true)" + + if [[ -n "$conflict" ]]; then + IFS='|' read -r c_owner c_path c_exp <<<"$conflict" + echo "Claim conflict: '$path' overlaps claim by '$c_owner' on '$c_path' (expires_at=$c_exp)" >&2 + exit 2 + fi + + tmp="$(mktemp)" + jq --arg o "$owner" --arg p "$path" --argjson n "$now" --argjson e "$expires" ' + .claims += [{"owner":$o,"path":$p,"claimed_at":$n,"expires_at":$e}] + ' "$CLAIMS_FILE" > "$tmp" + mv "$tmp" "$CLAIMS_FILE" + + echo "Claimed: owner=$owner path=$path ttl_hours=$ttl_hours" +} + +release_path() { + local owner="$1" + local path="$2" + + tmp="$(mktemp)" + jq --arg o "$owner" --arg p "$path" ' + .claims |= map(select(.owner != $o or .path != $p)) + ' "$CLAIMS_FILE" > "$tmp" + mv "$tmp" "$CLAIMS_FILE" + + echo "Released: owner=$owner path=$path" +} + +status_claims() { + local path="${1:-}" + prune_expired + + if [[ -z "$path" ]]; then + jq '.claims' "$CLAIMS_FILE" + return + fi + + jq --arg p "$path" '.claims | map(select((.path | startswith($p)) or ($p | startswith(.path))))' "$CLAIMS_FILE" +} + +main() { + require_cmd jq + ensure_store + + cmd="${1:-}" + case "$cmd" in + claim) + [[ $# -ge 3 ]] || { usage; exit 1; } + claim_path "$2" "$3" "${4:-$DEFAULT_TTL_HOURS}" + ;; + release) + [[ $# -ge 3 ]] || { usage; exit 1; } + release_path "$2" "$3" + ;; + status) + status_claims "${2:-}" + ;; + prune) + prune_expired + echo "Pruned expired claims" + ;; + *) + usage + exit 1 + ;; + esac +} + +main "$@" diff --git a/scripts/e2e.sh b/scripts/e2e.sh new file mode 100755 index 0000000..a022b93 --- /dev/null +++ b/scripts/e2e.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "[e2e] Running end-to-end checks" + +if [[ -f package.json ]]; then + if command -v pnpm >/dev/null 2>&1; then + pnpm run e2e + else + npm run e2e + fi + exit 0 +fi + +echo "[e2e] No e2e runner configured" diff --git a/scripts/preflight-claim.sh b/scripts/preflight-claim.sh new file mode 100755 index 0000000..3e98762 --- /dev/null +++ b/scripts/preflight-claim.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +OWNER="${1:-${USER:-unknown}}" +TARGET="${2:-.}" +TTL_HOURS="${3:-4}" + +./scripts/claim.sh claim "$OWNER" "$TARGET" "$TTL_HOURS" diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..fb689e8 --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "[setup] Starting project setup" + +if [[ -f package.json ]]; then + if command -v pnpm >/dev/null 2>&1; then + pnpm install + elif command -v npm >/dev/null 2>&1; then + npm install + else + echo "[setup][error] node package manager not found" >&2 + exit 1 + fi +fi + +if [[ -f requirements.txt ]]; then + if [[ ! -d .venv ]]; then + python3 -m venv .venv + fi + # shellcheck disable=SC1091 + source .venv/bin/activate + pip install --upgrade pip + pip install -r requirements.txt +fi + +./scripts/check-env.sh || true + +echo "[setup] Done" diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100755 index 0000000..11402b2 --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "[start] Starting app" + +if [[ -f package.json ]]; then + if command -v pnpm >/dev/null 2>&1; then + exec pnpm start + else + exec npm start + fi +fi + +if [[ -f app.py ]]; then + if [[ -d .venv ]]; then + # shellcheck disable=SC1091 + source .venv/bin/activate + fi + export FLASK_APP=app.py + exec flask run --host 0.0.0.0 --port "${PORT:-5000}" +fi + +echo "[start][error] No known app entrypoint found" >&2 +exit 1 diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..710b72c --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo "[test] Running unit/integration tests" + +if [[ -f package.json ]]; then + if command -v pnpm >/dev/null 2>&1; then + pnpm test + else + npm test + fi + exit 0 +fi + +if compgen -G "tests/*.py" >/dev/null || compgen -G "test_*.py" >/dev/null; then + if [[ -d .venv ]]; then + # shellcheck disable=SC1091 + source .venv/bin/activate + fi + python -m pytest + exit 0 +fi + +echo "[test] No tests detected"