feat(claude): env-vars refactor + stale-claim cron (Phase 2 finalization)

#11: slash-commands и _autonomous_pickup.md теперь читают persistent User-scope
env vars напрямую (FORGEJO_TOKEN_<ROLE>) вместо file-based из ~/.claude/secrets/.

Один раз: scripts/setup-bot-env.ps1 (запускается user'ом локально, кладёт
токены из vault в Windows User registry). После этого окна grab credentials
через [System.Environment]::GetEnvironmentVariable() — никаких файлов.

Все 5 work-as-*.md обновлены (analyst/backend/frontend/reviewer/qa).

#12: stale-claim cleanup — cron каждые 30 минут.

- scripts/cleanup-stale-claims.sh — bash, parses Forgejo API + Python для JSON
- .forgejo/workflows/stale-claims.yml — cron schedule `*/30 * * * *`

Освобождает issues застрявшие в status/wip >4h:
  - assignees=[]
  - +status/ready -status/wip
  - comment "stale claim released, worker likely crashed"

Использует secret FORGEJO_BOT_QA_TOKEN (минимум прав = write:issue).

Setup: secret нужно завести в Forgejo Actions UI после merge —
git.gendsgn.ru/lekss361/gendesign/-/settings/actions/secrets.

Refs: PRs #608, #609. Closes TODO с PR #608 (stale-claim cron).
This commit is contained in:
lekss361 2026-05-28 00:24:45 +03:00
parent 532b8cdda2
commit 297eb29d65
9 changed files with 366 additions and 70 deletions

View file

@ -15,42 +15,50 @@ created_at: 2026-05-27
> **Это критично.** Без правильной настройки git identity → commits будут писаться > **Это критично.** Без правильной настройки git identity → commits будут писаться
> под user'ом (lekss361), не под ботом. Audit trail сломается. > под user'ом (lekss361), не под ботом. Audit trail сломается.
### Шаг 1 — Получить PAT из vault ### Шаг 1 — Где живут PAT'ы
PAT'ы для всех 5 ботов хранятся в **`meta/00_credentials.md`** (vault, sensitive, PAT'ы хранятся в **двух местах одновременно**:
не в git). Один раз выгрузи их в локальные secrets:
1. **Vault** `meta/00_credentials.md` — sensitive backup (read-only reference)
2. **Windows User-scope env vars** — production-ready, **persistent**:
- `FORGEJO_TOKEN_ANALYST`
- `FORGEJO_TOKEN_BACKEND`
- `FORGEJO_TOKEN_FRONTEND`
- `FORGEJO_TOKEN_REVIEWER`
- `FORGEJO_TOKEN_QA`
- `FORGEJO_URL_BOTS` = `https://git.gendsgn.ru`
- `FORGEJO_REPO_BOTS` = `lekss361/gendesign`
Setup один раз через PowerShell (см. `scripts/setup-bot-env.ps1`). После этого
env vars доступны во **всех** новых shell-сессиях автоматически — никаких
`Get-Content` / файлов.
Проверь что выставлены:
```powershell ```powershell
# Локация — никогда не коммитится (gitignored через ~/.gitignore_global) [System.Environment]::GetEnvironmentVariable("FORGEJO_TOKEN_BACKEND", "User") | Out-Null
New-Item -ItemType Directory -Force ~/.claude/secrets | Out-Null if (-not $?) { Write-Error "❌ Запусти setup-bot-env.ps1 сначала" }
# Для каждого бота — скопируй PAT из vault в файл (открой meta/00_credentials.md,
# секция "Forgejo bot-users", найди PAT для bot-<role>, вставь сюда):
# ~/.claude/secrets/forgejo-bot-analyst.token
# ~/.claude/secrets/forgejo-bot-backend.token
# ~/.claude/secrets/forgejo-bot-frontend.token
# ~/.claude/secrets/forgejo-bot-reviewer.token
# ~/.claude/secrets/forgejo-bot-qa.token
#
# Пример (с placeholder — НЕ commit'ить настоящий token):
# "<PAT-FROM-VAULT>" | Set-Content ~/.claude/secrets/forgejo-bot-<role>.token -NoNewline
``` ```
⚠️ Проверь что `~/.claude/secrets/` есть в `~/.gitignore_global` (или эквивалентном).
### Шаг 2 — Env vars + git identity (per окно) ### Шаг 2 — Env vars + git identity (per окно)
Замени `<role>` на свою роль (`analyst`/`backend`/`frontend`/`reviewer`/`qa`): Замени `<ROLE>` на свою роль (`ANALYST`/`BACKEND`/`FRONTEND`/`REVIEWER`/`QA` — UPPER-case):
```powershell ```powershell
$ROLE = "backend" # ← ИЗМЕНИ ПЕРЕД ЗАПУСКОМ $ROLE = "BACKEND" # ← ИЗМЕНИ ПЕРЕД ЗАПУСКОМ (UPPER-case)
$BOT = "bot-$ROLE" $BOT = "bot-$($ROLE.ToLower())"
# Forgejo API credentials # Resolve token из persistent User env
$env:FORGEJO_TOKEN = (Get-Content ~/.claude/secrets/forgejo-$BOT.token -Raw).Trim() $env:FORGEJO_TOKEN = [System.Environment]::GetEnvironmentVariable("FORGEJO_TOKEN_$ROLE", "User")
$env:BOT_USERNAME = $BOT $env:BOT_USERNAME = $BOT
$env:FORGEJO_URL = "https://git.gendsgn.ru" $env:FORGEJO_URL = [System.Environment]::GetEnvironmentVariable("FORGEJO_URL_BOTS", "User")
$env:FORGEJO_REPO = "lekss361/gendesign" $env:FORGEJO_REPO = [System.Environment]::GetEnvironmentVariable("FORGEJO_REPO_BOTS", "User")
# Sanity: token не пустой
if (-not $env:FORGEJO_TOKEN) {
Write-Error "❌ FORGEJO_TOKEN_$ROLE не выставлен. Запусти scripts/setup-bot-env.ps1"
return
}
# Git identity — КРИТИЧНО, иначе commit author будет user'а (lekss361) # Git identity — КРИТИЧНО, иначе commit author будет user'а (lekss361)
$env:GIT_AUTHOR_NAME = $BOT $env:GIT_AUTHOR_NAME = $BOT

View file

@ -9,19 +9,28 @@ description: Запустить окно как auto-analyst (декомпози
## Pre-flight checks (выполни СЕЙЧАС, до /loop) ## Pre-flight checks (выполни СЕЙЧАС, до /loop)
1. **Verify env vars set:** ```powershell
- `$env:FORGEJO_TOKEN` (для bot-analyst — см. `meta/00_credentials.md`) # 1. Resolve credentials из persistent User env
- `$env:BOT_USERNAME = "bot-analyst"` $env:FORGEJO_TOKEN = [System.Environment]::GetEnvironmentVariable("FORGEJO_TOKEN_ANALYST", "User")
- `$env:FORGEJO_URL = "https://git.gendsgn.ru"` $env:BOT_USERNAME = "bot-analyst"
- `$env:FORGEJO_REPO = "lekss361/gendesign"` $env:FORGEJO_URL = [System.Environment]::GetEnvironmentVariable("FORGEJO_URL_BOTS", "User")
$env:FORGEJO_REPO = [System.Environment]::GetEnvironmentVariable("FORGEJO_REPO_BOTS", "User")
2. **Verify identity:** if (-not $env:FORGEJO_TOKEN) {
```bash Write-Error "❌ FORGEJO_TOKEN_ANALYST не выставлен. Запусти scripts/setup-bot-env.ps1"
curl -sH "Authorization: token $FORGEJO_TOKEN" "$FORGEJO_URL/api/v1/user" | jq .login return
# Должен вернуть "bot-analyst" }
```
3. **Если pre-flight FAILS**НЕ запускай /loop. Сообщи user'у что нужно настроить. # 2. Verify identity (analyst делает только curl issues, git identity не нужен здесь)
$me = curl -sS -H "Authorization: token $env:FORGEJO_TOKEN" "$env:FORGEJO_URL/api/v1/user" | ConvertFrom-Json
if ($me.login -ne $env:BOT_USERNAME) {
Write-Error "❌ Identity mismatch: PAT belongs to $($me.login), expected $env:BOT_USERNAME"
return
}
Write-Host "✓ PAT belongs to $($me.login) — analyst persona ready"
```
**Если pre-flight FAILS** — НЕ запускай /loop. Обычно — env vars не выставлены, запусти `setup-bot-env.ps1`.
## Behavior contract ## Behavior contract

View file

@ -9,35 +9,40 @@ description: Запустить окно как auto-backend (pickup scope/backe
## Pre-flight checks (выполни СЕЙЧАС, до /loop) ## Pre-flight checks (выполни СЕЙЧАС, до /loop)
1. **Env vars (см. `.claude/agents/_autonomous_pickup.md` Шаги 1-2):** ```powershell
```powershell # 1. Resolve credentials из persistent User env (выставлены setup-bot-env.ps1)
$env:FORGEJO_TOKEN = Get-Content ~/.claude/secrets/forgejo-bot-backend.token -Raw $env:FORGEJO_TOKEN = [System.Environment]::GetEnvironmentVariable("FORGEJO_TOKEN_BACKEND", "User")
$env:BOT_USERNAME = "bot-backend" $env:BOT_USERNAME = "bot-backend"
$env:FORGEJO_URL = "https://git.gendsgn.ru" $env:FORGEJO_URL = [System.Environment]::GetEnvironmentVariable("FORGEJO_URL_BOTS", "User")
$env:FORGEJO_REPO = "lekss361/gendesign" $env:FORGEJO_REPO = [System.Environment]::GetEnvironmentVariable("FORGEJO_REPO_BOTS", "User")
```
2. **Git identity (КРИТИЧНО — иначе commits под lekss361):** if (-not $env:FORGEJO_TOKEN) {
```powershell Write-Error "❌ FORGEJO_TOKEN_BACKEND не выставлен. Запусти scripts/setup-bot-env.ps1"
$env:GIT_AUTHOR_NAME = $env:BOT_USERNAME return
$env:GIT_AUTHOR_EMAIL = "$($env:BOT_USERNAME)@gendsgn.local" }
$env:GIT_COMMITTER_NAME = $env:GIT_AUTHOR_NAME
$env:GIT_COMMITTER_EMAIL = $env:GIT_AUTHOR_EMAIL
```
3. **Bot-remote для push (audit-log под bot):** # 2. Git identity — КРИТИЧНО, иначе commits под lekss361
```powershell $env:GIT_AUTHOR_NAME = $env:BOT_USERNAME
git remote remove forgejo-bot 2>$null $env:GIT_AUTHOR_EMAIL = "$($env:BOT_USERNAME)@gendsgn.local"
git remote add forgejo-bot "https://$($env:BOT_USERNAME):$($env:FORGEJO_TOKEN)@git.gendsgn.ru/lekss361/gendesign.git" $env:GIT_COMMITTER_NAME = $env:GIT_AUTHOR_NAME
``` $env:GIT_COMMITTER_EMAIL = $env:GIT_AUTHOR_EMAIL
4. **Verify identity:** # 3. Bot-remote для push (audit log под bot, не lekss361)
```bash git remote remove forgejo-bot 2>$null
curl -sH "Authorization: token $FORGEJO_TOKEN" "$FORGEJO_URL/api/v1/user" | jq .login git remote add forgejo-bot "https://$($env:BOT_USERNAME):$($env:FORGEJO_TOKEN)@git.gendsgn.ru/lekss361/gendesign.git"
# Должен вернуть "bot-backend"
```
5. **Если что-то FAILS**НЕ запускай /loop. Скажи user'у что не так. # 4. Verify identity
$me = curl -sS -H "Authorization: token $env:FORGEJO_TOKEN" "$env:FORGEJO_URL/api/v1/user" | ConvertFrom-Json
if ($me.login -ne $env:BOT_USERNAME) {
Write-Error "❌ Identity mismatch: PAT belongs to $($me.login), expected $env:BOT_USERNAME"
return
}
Write-Host "✓ PAT belongs to $($me.login)"
Write-Host "✓ Commits authored as: $env:GIT_AUTHOR_NAME <$env:GIT_AUTHOR_EMAIL>"
Write-Host "✓ Use 'git push forgejo-bot' для push (НЕ forgejo — он lekss361)"
```
**Если что-то FAILS** — НЕ запускай /loop. Скажи user'у (обычно — env vars не выставлены, запусти `setup-bot-env.ps1`).
## Behavior contract ## Behavior contract

View file

@ -9,9 +9,15 @@ description: Запустить окно как auto-frontend (pickup scope/fron
## Pre-flight checks ## Pre-flight checks
См. `.claude/commands/work-as-backend.md` для полного pre-flight — идентично, только: Идентично `work-as-backend.md`, только изменить две строки:
- `BOT_USERNAME = "bot-frontend"`
- PAT из `~/.claude/secrets/forgejo-bot-frontend.token` ```powershell
$env:FORGEJO_TOKEN = [System.Environment]::GetEnvironmentVariable("FORGEJO_TOKEN_FRONTEND", "User")
$env:BOT_USERNAME = "bot-frontend"
# остальные строки (FORGEJO_URL/REPO, git identity, bot-remote, verify) — без изменений
```
См. полный pre-flight в `.claude/commands/work-as-backend.md`.
## Behavior contract ## Behavior contract

View file

@ -9,9 +9,13 @@ description: Запустить окно как auto-qa-tester (Playwright smoke
## Pre-flight checks ## Pre-flight checks
Идентично `work-as-backend.md`, только: Идентично `work-as-backend.md`, только изменить две строки:
- `BOT_USERNAME = "bot-qa"`
- PAT из `~/.claude/secrets/forgejo-bot-qa.token` ```powershell
$env:FORGEJO_TOKEN = [System.Environment]::GetEnvironmentVariable("FORGEJO_TOKEN_QA", "User")
$env:BOT_USERNAME = "bot-qa"
# остальное — см. work-as-backend.md
```
**Дополнительно** — этот bot использует Playwright MCP, проверь что доступен: **Дополнительно** — этот bot использует Playwright MCP, проверь что доступен:

View file

@ -9,9 +9,13 @@ description: Запустить окно как auto-code-reviewer (review + mer
## Pre-flight checks ## Pre-flight checks
Идентично `work-as-backend.md`, только: Идентично `work-as-backend.md`, только изменить две строки:
- `BOT_USERNAME = "bot-reviewer"`
- PAT из `~/.claude/secrets/forgejo-bot-reviewer.token` ```powershell
$env:FORGEJO_TOKEN = [System.Environment]::GetEnvironmentVariable("FORGEJO_TOKEN_REVIEWER", "User")
$env:BOT_USERNAME = "bot-reviewer"
# остальное — см. work-as-backend.md
```
**Дополнительно** — этот bot имеет merge authority, поэтому verify scope более строго: **Дополнительно** — этот bot имеет merge authority, поэтому verify scope более строго:

View file

@ -0,0 +1,35 @@
# Cleanup stale claims for autonomous multi-agent workflow.
#
# Запускается каждые 30 минут (cron). Освобождает issues застрявшие в
# `status/wip` дольше 4 часов. См. `scripts/cleanup-stale-claims.sh`.
#
# Secrets:
# FORGEJO_BOT_QA_TOKEN — bot-qa PAT (write:issue scope нужен).
# Можно использовать любой bot-PAT с write:issue, но bot-qa имеет минимум прав.
name: cleanup-stale-claims
on:
schedule:
# Каждые 30 минут UTC
- cron: '*/30 * * * *'
workflow_dispatch: {} # manual run возможен через UI
jobs:
cleanup:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Run cleanup script
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_BOT_QA_TOKEN }}
FORGEJO_URL: https://git.gendsgn.ru
FORGEJO_REPO: lekss361/gendesign
STALE_HOURS: 4
run: |
chmod +x scripts/cleanup-stale-claims.sh
./scripts/cleanup-stale-claims.sh

View file

@ -0,0 +1,136 @@
#!/usr/bin/env bash
# Cleanup stale claims for autonomous multi-agent workflow.
#
# Освобождает issues застрявшие в `status/wip` дольше 4 часов:
# - убирает assignee
# - меняет label status/wip → status/ready
#
# Why: если worker crashes или window закрывается во время работы, issue
# остаётся wip+assigned forever. Без cleanup queue забивается.
#
# Run: cron каждые 30 минут (см. .forgejo/workflows/stale-claims.yml).
#
# Env vars (требуются):
# FORGEJO_TOKEN — admin или с write:issue scope на repo
# FORGEJO_URL — https://git.gendsgn.ru (без /api/v1)
# FORGEJO_REPO — lekss361/gendesign
# STALE_HOURS — порог в часах (default 4)
#
# Exit codes:
# 0 — success (even if 0 stale found)
# 1 — env vars missing / API error
set -euo pipefail
: "${FORGEJO_TOKEN:?FORGEJO_TOKEN required}"
: "${FORGEJO_URL:?FORGEJO_URL required}"
: "${FORGEJO_REPO:?FORGEJO_REPO required}"
STALE_HOURS="${STALE_HOURS:-4}"
STALE_SECS=$((STALE_HOURS * 3600))
curl_forgejo() {
curl -sS -H "Authorization: token $FORGEJO_TOKEN" \
-H "Content-Type: application/json" \
"$FORGEJO_URL/api/v1/$1" "${@:2}"
}
now_utc_epoch() {
python3 -c 'import datetime; print(int(datetime.datetime.now(datetime.UTC).timestamp()))'
}
iso_to_epoch() {
python3 -c "import datetime; print(int(datetime.datetime.fromisoformat('$1'.replace('Z','+00:00')).timestamp()))"
}
echo "[$(date -Is)] cleanup-stale-claims: scanning status/wip issues (stale threshold: ${STALE_HOURS}h)"
NOW=$(now_utc_epoch)
CUTOFF=$((NOW - STALE_SECS))
# GET all open issues с label status/wip
RESPONSE=$(curl_forgejo "repos/$FORGEJO_REPO/issues?labels=status/wip&state=open&limit=50&page=1")
# Парсим JSON
echo "$RESPONSE" | python3 <<PYEOF
import json, os, subprocess, sys
cutoff = int(os.environ.get("CUTOFF", "0"))
data = json.loads(sys.stdin.read())
if not data:
print(f"[OK] 0 wip issues — nothing to do")
sys.exit(0)
released = 0
errors = 0
for issue in data:
issue_num = issue["number"]
updated_at = issue["updated_at"]
title = issue["title"]
assignees = [a["login"] for a in (issue.get("assignees") or [])]
# ISO to epoch
import datetime
updated_epoch = int(datetime.datetime.fromisoformat(updated_at.replace("Z", "+00:00")).timestamp())
age_secs = int(datetime.datetime.now(datetime.UTC).timestamp()) - updated_epoch
age_hours = age_secs // 3600
if updated_epoch >= cutoff:
print(f" · #{issue_num} '{title[:50]}' — age {age_hours}h, fresh, skip")
continue
print(f" ⚠ #{issue_num} '{title[:50]}' — age {age_hours}h, STALE")
print(f" assignees: {assignees}")
print(f" → releasing claim")
# Build commands
repo = os.environ["FORGEJO_REPO"]
base = os.environ["FORGEJO_URL"] + "/api/v1"
token = os.environ["FORGEJO_TOKEN"]
auth = ["-H", f"Authorization: token {token}", "-H", "Content-Type: application/json"]
try:
# 1. Clear assignees
subprocess.run(
["curl", "-sS", "-X", "PATCH", *auth, "-d", '{"assignees": []}',
f"{base}/repos/{repo}/issues/{issue_num}"],
check=True, capture_output=True,
)
# 2. Add status/ready label
subprocess.run(
["curl", "-sS", "-X", "POST", *auth, "-d", '{"labels": ["status/ready"]}',
f"{base}/repos/{repo}/issues/{issue_num}/labels"],
check=True, capture_output=True,
)
# 3. Remove status/wip label
subprocess.run(
["curl", "-sS", "-X", "DELETE", *auth,
f"{base}/repos/{repo}/issues/{issue_num}/labels/status%2Fwip"],
check=True, capture_output=True,
)
# 4. Post comment for trace
comment_body = json.dumps({
"body": f"⚠️ Stale claim released: issue stuck in status/wip for {age_hours}h "
f"(previously assigned to {', '.join(assignees) or 'none'}). "
f"Label transition: status/wip → status/ready. Worker likely crashed."
})
subprocess.run(
["curl", "-sS", "-X", "POST", *auth, "-d", comment_body,
f"{base}/repos/{repo}/issues/{issue_num}/comments"],
check=True, capture_output=True,
)
released += 1
except subprocess.CalledProcessError as e:
print(f" ✗ FAILED: {e.stderr.decode() if e.stderr else 'unknown'}")
errors += 1
print(f"")
print(f"[OK] Released {released} stale claim(s), {errors} errors")
sys.exit(1 if errors else 0)
PYEOF

89
scripts/setup-bot-env.ps1 Normal file
View file

@ -0,0 +1,89 @@
# setup-bot-env.ps1
# Set Windows User-scope env vars для autonomous multi-agent workflow.
#
# Запускается ОДИН РАЗ на каждой машине пользователя. Не нужен admin.
# Effect: 7 User-scope env vars persistent в registry.
#
# Где взять токены:
# 1. Vault meta/00_credentials.md (sensitive, не в git) — секция "Forgejo bot-users"
# 2. Или сгенерировать новые: см. там же секция "Rotation / revocation"
#
# Безопасность:
# - Скрипт ожидает чтобы ты ВПИСАЛ tokens сам перед запуском (placeholder ниже)
# - НЕ коммить скрипт с реальными токенами!
# - После запуска env vars в registry — можешь удалить локальную копию
# ──────────────────────────────────────────────────────────────────────
# REPLACE PLACEHOLDERS BELOW WITH ACTUAL TOKENS FROM VAULT
# ──────────────────────────────────────────────────────────────────────
$tokens = @{
"FORGEJO_TOKEN_ANALYST" = "REPLACE_ME_FROM_VAULT"
"FORGEJO_TOKEN_BACKEND" = "REPLACE_ME_FROM_VAULT"
"FORGEJO_TOKEN_FRONTEND" = "REPLACE_ME_FROM_VAULT"
"FORGEJO_TOKEN_REVIEWER" = "REPLACE_ME_FROM_VAULT"
"FORGEJO_TOKEN_QA" = "REPLACE_ME_FROM_VAULT"
"FORGEJO_URL_BOTS" = "https://git.gendsgn.ru"
"FORGEJO_REPO_BOTS" = "lekss361/gendesign"
}
# ──────────────────────────────────────────────────────────────────────
# Validation
# ──────────────────────────────────────────────────────────────────────
$placeholderCount = ($tokens.Values | Where-Object { $_ -eq "REPLACE_ME_FROM_VAULT" }).Count
if ($placeholderCount -gt 0) {
Write-Error "$placeholderCount placeholders не заменены. Открой vault meta/00_credentials.md, секция 'Forgejo bot-users', и вставь реальные PAT'ы вместо REPLACE_ME_FROM_VAULT."
exit 1
}
# ──────────────────────────────────────────────────────────────────────
# Apply
# ──────────────────────────────────────────────────────────────────────
Write-Host "=== Setting 7 User-scope env vars (persistent) ===" -ForegroundColor Cyan
foreach ($k in $tokens.Keys) {
[System.Environment]::SetEnvironmentVariable($k, $tokens[$k], "User")
Set-Item -Path "env:$k" -Value $tokens[$k]
if ($k -like "FORGEJO_TOKEN_*") {
$suffix = $tokens[$k].Substring($tokens[$k].Length - 4)
Write-Host " $k = ****$suffix"
} else {
Write-Host " $k = $($tokens[$k])"
}
}
# ──────────────────────────────────────────────────────────────────────
# Verify
# ──────────────────────────────────────────────────────────────────────
Write-Host ""
Write-Host "=== Verify (read back from User scope) ===" -ForegroundColor Cyan
$allOK = $true
foreach ($k in $tokens.Keys) {
$stored = [System.Environment]::GetEnvironmentVariable($k, "User")
if ($stored -eq $tokens[$k]) {
Write-Host " $k`: OK (len=$($stored.Length))"
} else {
Write-Host " $k`: MISMATCH (stored len=$($stored.Length))" -ForegroundColor Red
$allOK = $false
}
}
Write-Host ""
if ($allOK) {
Write-Host "✓ All 7 env vars set successfully." -ForegroundColor Green
Write-Host ""
Write-Host "Запуск окна:"
Write-Host " claude"
Write-Host " # в чате:"
Write-Host " /work-as-backend # любая из 5: analyst/backend/frontend/reviewer/qa"
Write-Host " # pre-flight выполнится сам через env vars"
Write-Host " /loop dynamic # или другой cadence"
} else {
Write-Host "✗ Some env vars failed to set." -ForegroundColor Red
exit 1
}