fix: Encrypt secrets at rest with SOPS + age (#613)

- lib/env.sh: Two-tier secret loader (SOPS .env.enc > plaintext .env),
  remove ~/.netrc fallback
- bin/disinto: Add age key generation and SOPS encryption during init,
  remove write_netrc(), add `disinto secrets` subcommand (edit/show/migrate),
  add sops+age to preflight warnings
- .env.example: Annotate vars as [SECRET] or [CONFIG]
- .gitignore: Allow .env.enc and .sops.yaml to be committed
- BOOTSTRAP.md: Document SOPS + age setup, key backup, secret management
- AGENTS.md: Update AD-005 and coding conventions for .env.enc

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
openhands 2026-03-23 18:58:33 +00:00
parent 28cdec3e7b
commit 5ccf09b28d
6 changed files with 210 additions and 66 deletions

View file

@ -1,6 +1,12 @@
# Disinto — Environment Configuration # Disinto — Environment Configuration
# Copy to .env and fill in your values. # Copy to .env and fill in your values.
# NEVER commit .env to the repo. # NEVER commit .env to the repo.
#
# With SOPS + age installed, `disinto init` encrypts secrets into .env.enc
# and removes plaintext .env. To migrate an existing .env: `disinto secrets migrate`
#
# Variables marked [SECRET] are credentials that grant access if leaked.
# Variables marked [CONFIG] are non-sensitive and safe in plaintext.
# ── Per-project config ──────────────────────────────────────────────────── # ── Per-project config ────────────────────────────────────────────────────
# Project-specific settings (FORGE_REPO, PROJECT_REPO_ROOT, PRIMARY_BRANCH, # Project-specific settings (FORGE_REPO, PROJECT_REPO_ROOT, PRIMARY_BRANCH,
@ -8,23 +14,12 @@
# for an example. Do NOT set them here; they leak into every session. # for an example. Do NOT set them here; they leak into every session.
# ── Forge (Forgejo) ───────────────────────────────────────────────────── # ── Forge (Forgejo) ─────────────────────────────────────────────────────
# Base URL for the local Forgejo instance. disinto init provisions this. FORGE_URL=http://localhost:3000 # [CONFIG] local Forgejo instance
FORGE_URL=http://localhost:3000
# ── Auth tokens ─────────────────────────────────────────────────────────── # ── Auth tokens ───────────────────────────────────────────────────────────
# Dev-agent token: push branches, create PRs, merge PRs. FORGE_TOKEN= # [SECRET] dev-bot API token
# Use the dedicated bot account (e.g. dev-bot). FORGE_REVIEW_TOKEN= # [SECRET] review-bot API token
# Branch protection: this account must be in the merge whitelist. FORGE_BOT_USERNAMES= # [CONFIG] comma-separated bot usernames
FORGE_TOKEN=
# Review-agent token: post review comments and submit formal approvals.
# Use the review bot account (e.g. review-bot).
# Branch protection: this account must be in the approvals whitelist.
FORGE_REVIEW_TOKEN=
# Comma-separated forge usernames to filter from issue comments.
# The token owner is auto-detected; add extra bot accounts here if needed.
FORGE_BOT_USERNAMES=
# ── Backwards compatibility ─────────────────────────────────────────────── # ── Backwards compatibility ───────────────────────────────────────────────
# If CODEBERG_TOKEN is set but FORGE_TOKEN is not, env.sh falls back to # If CODEBERG_TOKEN is set but FORGE_TOKEN is not, env.sh falls back to
@ -32,26 +27,25 @@ FORGE_BOT_USERNAMES=
# CODEBERG_BOT_USERNAMES). No action needed for existing deployments. # CODEBERG_BOT_USERNAMES). No action needed for existing deployments.
# ── Woodpecker CI ───────────────────────────────────────────────────────── # ── Woodpecker CI ─────────────────────────────────────────────────────────
WOODPECKER_TOKEN= WOODPECKER_TOKEN= # [SECRET] Woodpecker API token
WOODPECKER_SERVER=http://localhost:8000 WOODPECKER_SERVER=http://localhost:8000 # [CONFIG] Woodpecker server URL
# WOODPECKER_REPO_ID — now per-project, set in projects/*.toml [ci] section # WOODPECKER_REPO_ID — now per-project, set in projects/*.toml [ci] section
# Woodpecker Postgres (for direct DB queries) # Woodpecker Postgres (for direct DB queries)
WOODPECKER_DB_PASSWORD= WOODPECKER_DB_PASSWORD= # [SECRET] Postgres password
WOODPECKER_DB_USER=woodpecker WOODPECKER_DB_USER=woodpecker # [CONFIG] Postgres user
WOODPECKER_DB_HOST=127.0.0.1 WOODPECKER_DB_HOST=127.0.0.1 # [CONFIG] Postgres host
WOODPECKER_DB_NAME=woodpecker WOODPECKER_DB_NAME=woodpecker # [CONFIG] Postgres database name
# ── Matrix (optional — real-time notifications & escalation replies) ────── # ── Matrix (optional — real-time notifications & escalation replies) ──────
MATRIX_HOMESERVER=http://localhost:8008 # Dendrite/Synapse URL MATRIX_HOMESERVER=http://localhost:8008 # [CONFIG] Dendrite/Synapse URL
MATRIX_BOT_USER=@factory:your.server # bot's Matrix user ID MATRIX_BOT_USER=@factory:your.server # [CONFIG] bot's Matrix user ID
MATRIX_TOKEN= # bot's access token MATRIX_TOKEN= # [SECRET] bot's access token
MATRIX_ROOM_ID= # coordination room ID (!xxx:your.server) MATRIX_ROOM_ID= # [CONFIG] coordination room ID
# ── Project-specific secrets ────────────────────────────────────────────── # ── Project-specific secrets ──────────────────────────────────────────────
# Store all project secrets here so formulas reference env vars, never hardcode. # Store all project secrets here so formulas reference env vars, never hardcode.
# Example: BASE_RPC_URL for on-chain evolution scripts. BASE_RPC_URL= # [SECRET] on-chain RPC endpoint
BASE_RPC_URL=
# ── Tuning ──────────────────────────────────────────────────────────────── # ── Tuning ────────────────────────────────────────────────────────────────
CLAUDE_TIMEOUT=7200 # max seconds per Claude invocation CLAUDE_TIMEOUT=7200 # [CONFIG] max seconds per Claude invocation

6
.gitignore vendored
View file

@ -1,6 +1,10 @@
# Secrets # Plaintext secrets (never commit)
.env .env
# Encrypted secrets — safe to commit (.env.enc is SOPS-encrypted)
!.env.enc
!.sops.yaml
# Per-box project config (generated by disinto init) # Per-box project config (generated by disinto init)
projects/*.toml projects/*.toml

View file

@ -50,7 +50,7 @@ disinto/
- All scripts start with `#!/usr/bin/env bash` and `set -euo pipefail` - All scripts start with `#!/usr/bin/env bash` and `set -euo pipefail`
- Source shared environment: `source "$(dirname "$0")/../lib/env.sh"` - Source shared environment: `source "$(dirname "$0")/../lib/env.sh"`
- Log to `$LOGFILE` using the `log()` function from env.sh or defined locally - Log to `$LOGFILE` using the `log()` function from env.sh or defined locally
- Never hardcode secrets — all come from `.env` or TOML project files - Never hardcode secrets — all come from `.env.enc` (or `.env` fallback) or TOML project files
- Never embed secrets in issue bodies, PR descriptions, or comments — use env var references (e.g. `$BASE_RPC_URL`) - Never embed secrets in issue bodies, PR descriptions, or comments — use env var references (e.g. `$BASE_RPC_URL`)
- ShellCheck must pass (CI runs `shellcheck` on all `.sh` files) - ShellCheck must pass (CI runs `shellcheck` on all `.sh` files)
- Avoid duplicate code — shared helpers go in `lib/` - Avoid duplicate code — shared helpers go in `lib/`
@ -129,7 +129,7 @@ Humans write these. Agents read and enforce them.
| AD-002 | Single-threaded pipeline per project. | One dev issue at a time. No new work while a PR awaits CI or review. Prevents merge conflicts and keeps context clear. | | AD-002 | Single-threaded pipeline per project. | One dev issue at a time. No new work while a PR awaits CI or review. Prevents merge conflicts and keeps context clear. |
| AD-003 | The runtime creates and destroys, the formula preserves. | Runtime manages worktrees/sessions/temp. Formulas commit knowledge to git before signaling done. | | AD-003 | The runtime creates and destroys, the formula preserves. | Runtime manages worktrees/sessions/temp. Formulas commit knowledge to git before signaling done. |
| AD-004 | Event-driven > polling > fixed delays. | Never `waitForTimeout` or hardcoded sleep. Use phase files, webhooks, or poll loops with backoff. | | AD-004 | Event-driven > polling > fixed delays. | Never `waitForTimeout` or hardcoded sleep. Use phase files, webhooks, or poll loops with backoff. |
| AD-005 | Secrets via env var indirection, never in issue bodies. | Issue bodies become code. Secrets go in `.env` or TOML project files, referenced as `$VAR_NAME`. | | AD-005 | Secrets via env var indirection, never in issue bodies. | Issue bodies become code. Secrets go in `.env.enc` (SOPS-encrypted) or fall back to `.env`, referenced as `$VAR_NAME`. |
**Who enforces what:** **Who enforces what:**
- **Gardener** checks open backlog issues against ADs during grooming; closes violations with a comment referencing the AD number. - **Gardener** checks open backlog issues against ADs during grooming; closes violations with a comment referencing the AD number.

View file

@ -31,7 +31,39 @@ This will:
No external accounts or tokens needed. No external accounts or tokens needed.
## 1. Configure `.env` ## 1. Secret Management (SOPS + age)
Disinto encrypts secrets at rest using [SOPS](https://github.com/getsops/sops) with [age](https://age-encryption.org/) encryption. When `sops` and `age` are installed, `disinto init` automatically:
1. Generates an age key at `~/.config/sops/age/keys.txt` (if none exists)
2. Creates `.sops.yaml` pinning the age public key
3. Encrypts all secrets into `.env.enc` (safe to commit)
4. Removes the plaintext `.env`
**Install the tools:**
```bash
# age (key generation)
apt install age # Debian/Ubuntu
brew install age # macOS
# sops (encryption/decryption)
# Download from https://github.com/getsops/sops/releases
```
**The age private key** at `~/.config/sops/age/keys.txt` is the single file that must be protected. Back it up securely — without it, `.env.enc` cannot be decrypted. LUKS disk encryption on the VPS protects this key at rest.
**Managing secrets after setup:**
```bash
disinto secrets edit # Opens .env.enc in $EDITOR, re-encrypts on save
disinto secrets show # Prints decrypted secrets (for debugging)
disinto secrets migrate # Converts existing plaintext .env -> .env.enc
```
**Fallback:** If `sops`/`age` are not installed, `disinto init` writes secrets to a plaintext `.env` file with a warning. All agents load secrets transparently — `lib/env.sh` checks for `.env.enc` first, then falls back to `.env`.
## 2. Configure `.env`
```bash ```bash
cp .env.example .env cp .env.example .env
@ -70,7 +102,7 @@ CLAUDE_TIMEOUT=7200 # seconds per Claude invocation
If you have an existing deployment using `CODEBERG_TOKEN` / `REVIEW_BOT_TOKEN` in `.env`, those still work — `env.sh` falls back to the old names automatically. No migration needed. If you have an existing deployment using `CODEBERG_TOKEN` / `REVIEW_BOT_TOKEN` in `.env`, those still work — `env.sh` falls back to the old names automatically. No migration needed.
## 2. Configure Project TOML ## 3. Configure Project TOML
Each project needs a `projects/<name>.toml` file with box-specific settings Each project needs a `projects/<name>.toml` file with box-specific settings
(absolute paths, Woodpecker CI IDs, Matrix credentials, forge URL). These files are (absolute paths, Woodpecker CI IDs, Matrix credentials, forge URL). These files are
@ -98,7 +130,7 @@ forge_url = "http://localhost:3000"
The repo ships `projects/*.toml.example` templates showing the expected The repo ships `projects/*.toml.example` templates showing the expected
structure. See any `.toml.example` file for the full field reference. structure. See any `.toml.example` file for the full field reference.
## 3. Claude Code Global Settings ## 4. Claude Code Global Settings
Configure `~/.claude/settings.json` with **only** permissions and `skipDangerousModePermissionPrompt`. Do not add hooks to the global settings — `agent-session.sh` injects per-worktree hooks automatically. Configure `~/.claude/settings.json` with **only** permissions and `skipDangerousModePermissionPrompt`. Do not add hooks to the global settings — `agent-session.sh` injects per-worktree hooks automatically.
@ -124,7 +156,7 @@ claude --dangerously-skip-permissions
# Exit after it initializes successfully # Exit after it initializes successfully
``` ```
## 4. File Ownership ## 5. File Ownership
Everything under `/home/debian` must be owned by `debian:debian`. Root-owned files cause permission errors when agents run as the `debian` user. Everything under `/home/debian` must be owned by `debian:debian`. Root-owned files cause permission errors when agents run as the `debian` user.
@ -139,7 +171,7 @@ Verify no root-owned files exist in agent temp directories:
find /tmp/dev-* /tmp/harb-* /tmp/review-* -not -user debian 2>/dev/null find /tmp/dev-* /tmp/harb-* /tmp/review-* -not -user debian 2>/dev/null
``` ```
## 4b. Woodpecker CI + Forgejo Integration ## 5b. Woodpecker CI + Forgejo Integration
`disinto init` automatically configures Woodpecker to use the local Forgejo instance as its forge backend if `WOODPECKER_SERVER` is set in `.env`. This includes: `disinto init` automatically configures Woodpecker to use the local Forgejo instance as its forge backend if `WOODPECKER_SERVER` is set in `.env`. This includes:
@ -184,7 +216,7 @@ curl -X POST \
Woodpecker will now trigger pipelines on pushes to Forgejo and push commit status back. Disinto queries Woodpecker directly for CI status (with a forge API fallback), so pipeline results are visible even if Woodpecker's status push to Forgejo is delayed. Woodpecker will now trigger pipelines on pushes to Forgejo and push commit status back. Disinto queries Woodpecker directly for CI status (with a forge API fallback), so pipeline results are visible even if Woodpecker's status push to Forgejo is delayed.
## 5. Prepare the Target Repo ## 6. Prepare the Target Repo
### Required: CI pipeline ### Required: CI pipeline
@ -268,7 +300,7 @@ entire repo as "new", generating a noisy first-run diff.
See `formulas/run-planner.toml` (agents-update step) for the full AGENTS.md conventions. See `formulas/run-planner.toml` (agents-update step) for the full AGENTS.md conventions.
## 6. Write Good Issues ## 7. Write Good Issues
Dev-agent works best with issues that have: Dev-agent works best with issues that have:
@ -283,7 +315,7 @@ Dev-agent works best with issues that have:
Dev-agent checks that all referenced issues are closed (= merged) before starting work. If any are open, the issue is skipped and checked again next cycle. Dev-agent checks that all referenced issues are closed (= merged) before starting work. If any are open, the issue is skipped and checked again next cycle.
## 7. Install Cron ## 8. Install Cron
```bash ```bash
crontab -e crontab -e
@ -342,7 +374,7 @@ FACTORY_ROOT=/home/you/disinto
The staggered offsets prevent agents from competing for resources. Each project gets its own lock file (`/tmp/dev-agent-{name}.lock`) derived from the `name` field in its TOML, so concurrent runs across projects are safe. The staggered offsets prevent agents from competing for resources. Each project gets its own lock file (`/tmp/dev-agent-{name}.lock`) derived from the `name` field in its TOML, so concurrent runs across projects are safe.
## 8. Verify ## 9. Verify
```bash ```bash
# Should complete with "all clear" (no problems to fix) # Should complete with "all clear" (no problems to fix)
@ -363,7 +395,7 @@ tail -30 dev/dev-agent.log
tail -30 review/review.log tail -30 review/review.log
``` ```
## 9. Optional: Matrix Notifications ## 10. Optional: Matrix Notifications
If you want real-time notifications and human-in-the-loop escalation: If you want real-time notifications and human-in-the-loop escalation:

View file

@ -5,6 +5,7 @@
# Commands: # Commands:
# disinto init <repo-url> [options] Bootstrap a new project # disinto init <repo-url> [options] Bootstrap a new project
# disinto status Show factory status # disinto status Show factory status
# disinto secrets <edit|show|migrate> Manage encrypted secrets
# #
# Usage: # Usage:
# disinto init https://github.com/user/repo # disinto init https://github.com/user/repo
@ -25,6 +26,7 @@ disinto — autonomous code factory CLI
Usage: Usage:
disinto init <repo-url> [options] Bootstrap a new project disinto init <repo-url> [options] Bootstrap a new project
disinto status Show factory status disinto status Show factory status
disinto secrets <edit|show|migrate> Manage encrypted secrets (.env.enc)
Init options: Init options:
--branch <name> Primary branch (default: auto-detect) --branch <name> Primary branch (default: auto-detect)
@ -62,26 +64,73 @@ clone_url_from_slug() {
printf '%s/%s.git' "$forge_url" "$slug" printf '%s/%s.git' "$forge_url" "$slug"
} }
# Write (or update) credentials in ~/.netrc for a given host. # Ensure an age key exists; generate one if missing.
write_netrc() { # Exports AGE_PUBLIC_KEY on success.
local host="$1" login="$2" token="$3" ensure_age_key() {
local netrc="${HOME}/.netrc" local key_dir="${HOME}/.config/sops/age"
local key_file="${key_dir}/keys.txt"
# Remove existing entry for this host if present if [ -f "$key_file" ]; then
if [ -f "$netrc" ]; then AGE_PUBLIC_KEY="$(age-keygen -y "$key_file" 2>/dev/null)"
local tmp export AGE_PUBLIC_KEY
tmp=$(mktemp) return 0
awk -v h="$host" '
$0 ~ "^machine " h { skip=1; next }
/^machine / { skip=0 }
!skip
' "$netrc" > "$tmp"
mv "$tmp" "$netrc"
fi fi
# Append new entry if ! command -v age-keygen &>/dev/null; then
printf 'machine %s\nlogin %s\npassword %s\n' "$host" "$login" "$token" >> "$netrc" return 1
chmod 600 "$netrc" fi
mkdir -p "$key_dir"
age-keygen -o "$key_file" 2>/dev/null
chmod 600 "$key_file"
AGE_PUBLIC_KEY="$(age-keygen -y "$key_file" 2>/dev/null)"
export AGE_PUBLIC_KEY
echo "Generated age key: ${key_file}"
}
# Write .sops.yaml pinning the age recipient for .env.enc files.
write_sops_yaml() {
local pub_key="$1"
cat > "${FACTORY_ROOT}/.sops.yaml" <<EOF
creation_rules:
- path_regex: \.env\.enc$
age: "${pub_key}"
EOF
}
# Encrypt a dotenv file to .env.enc using SOPS + age.
# Usage: encrypt_env_file <input> <output>
encrypt_env_file() {
local input="$1" output="$2"
sops -e --input-type dotenv --output-type dotenv "$input" > "$output"
}
# Store secrets into .env.enc (encrypted) if SOPS + age available, else .env (plaintext).
# Reads existing .env, updates/adds vars, writes back.
write_secrets_encrypted() {
local env_file="${FACTORY_ROOT}/.env"
local enc_file="${FACTORY_ROOT}/.env.enc"
if command -v sops &>/dev/null && command -v age-keygen &>/dev/null; then
if ensure_age_key; then
# Write .sops.yaml if missing
if [ ! -f "${FACTORY_ROOT}/.sops.yaml" ]; then
write_sops_yaml "$AGE_PUBLIC_KEY"
fi
# Encrypt the plaintext .env to .env.enc
if [ -f "$env_file" ]; then
encrypt_env_file "$env_file" "$enc_file"
rm -f "$env_file"
echo "Secrets encrypted to .env.enc (plaintext .env removed)"
return 0
fi
fi
fi
# Fallback: keep plaintext .env
echo "Warning: sops/age not available — secrets stored in plaintext .env" >&2
return 0
} }
FORGEJO_DATA_DIR="${HOME}/.disinto/forgejo" FORGEJO_DATA_DIR="${HOME}/.disinto/forgejo"
@ -385,6 +434,14 @@ preflight_check() {
if ! command -v docker &>/dev/null; then if ! command -v docker &>/dev/null; then
echo "Warning: docker not found (needed for Forgejo provisioning)" >&2 echo "Warning: docker not found (needed for Forgejo provisioning)" >&2
fi fi
if ! command -v sops &>/dev/null; then
echo "Warning: sops not found (secrets will be stored in plaintext .env)" >&2
echo " Install: https://github.com/getsops/sops/releases" >&2
fi
if ! command -v age-keygen &>/dev/null; then
echo "Warning: age not found (needed for secret encryption with SOPS)" >&2
echo " Install: apt install age / brew install age" >&2
fi
if [ "$errors" -gt 0 ]; then if [ "$errors" -gt 0 ]; then
echo "" >&2 echo "" >&2
@ -836,6 +893,9 @@ p.write_text(text)
# Install cron jobs # Install cron jobs
install_cron "$project_name" "$toml_path" "$auto_yes" install_cron "$project_name" "$toml_path" "$auto_yes"
# Encrypt secrets if SOPS + age are available
write_secrets_encrypted
echo "" echo ""
echo "Done. Project ${project_name} is ready." echo "Done. Project ${project_name} is ready."
echo " Config: ${toml_path}" echo " Config: ${toml_path}"
@ -919,11 +979,64 @@ with open(sys.argv[1], 'rb') as f:
fi fi
} }
# ── secrets command ────────────────────────────────────────────────────────────
disinto_secrets() {
local subcmd="${1:-}"
local enc_file="${FACTORY_ROOT}/.env.enc"
local env_file="${FACTORY_ROOT}/.env"
case "$subcmd" in
edit)
if [ ! -f "$enc_file" ]; then
echo "Error: ${enc_file} not found. Run 'disinto secrets migrate' first." >&2
exit 1
fi
sops "$enc_file"
;;
show)
if [ ! -f "$enc_file" ]; then
echo "Error: ${enc_file} not found." >&2
exit 1
fi
sops -d "$enc_file"
;;
migrate)
if [ ! -f "$env_file" ]; then
echo "Error: ${env_file} not found — nothing to migrate." >&2
exit 1
fi
if ! command -v sops &>/dev/null || ! command -v age-keygen &>/dev/null; then
echo "Error: sops and age are required for migration." >&2
echo " Install sops: https://github.com/getsops/sops/releases" >&2
echo " Install age: apt install age / brew install age" >&2
exit 1
fi
if ! ensure_age_key; then
echo "Error: failed to generate age key" >&2
exit 1
fi
if [ ! -f "${FACTORY_ROOT}/.sops.yaml" ]; then
write_sops_yaml "$AGE_PUBLIC_KEY"
echo "Created: .sops.yaml"
fi
encrypt_env_file "$env_file" "$enc_file"
rm -f "$env_file"
echo "Migrated: .env -> .env.enc (plaintext removed)"
;;
*)
echo "Usage: disinto secrets <edit|show|migrate>" >&2
exit 1
;;
esac
}
# ── Main dispatch ──────────────────────────────────────────────────────────── # ── Main dispatch ────────────────────────────────────────────────────────────
case "${1:-}" in case "${1:-}" in
init) shift; disinto_init "$@" ;; init) shift; disinto_init "$@" ;;
status) shift; disinto_status "$@" ;; status) shift; disinto_status "$@" ;;
secrets) shift; disinto_secrets "$@" ;;
-h|--help) usage ;; -h|--help) usage ;;
*) usage ;; *) usage ;;
esac esac

View file

@ -7,8 +7,12 @@ set -euo pipefail
# Resolve script root (parent of lib/) # Resolve script root (parent of lib/)
FACTORY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" FACTORY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Load .env if present # Load secrets: prefer .env.enc (SOPS-encrypted), fall back to plaintext .env
if [ -f "$FACTORY_ROOT/.env" ]; then if [ -f "$FACTORY_ROOT/.env.enc" ] && command -v sops &>/dev/null; then
set -a
eval "$(sops -d --output-type dotenv "$FACTORY_ROOT/.env.enc" 2>/dev/null)" || true
set +a
elif [ -f "$FACTORY_ROOT/.env" ]; then
set -a set -a
# shellcheck source=/dev/null # shellcheck source=/dev/null
source "$FACTORY_ROOT/.env" source "$FACTORY_ROOT/.env"
@ -24,13 +28,10 @@ if [ -n "${PROJECT_TOML:-}" ] && [ -f "$PROJECT_TOML" ]; then
source "${FACTORY_ROOT}/lib/load-project.sh" "$PROJECT_TOML" source "${FACTORY_ROOT}/lib/load-project.sh" "$PROJECT_TOML"
fi fi
# Forge token: new FORGE_TOKEN > legacy CODEBERG_TOKEN > ~/.netrc # Forge token: new FORGE_TOKEN > legacy CODEBERG_TOKEN
if [ -z "${FORGE_TOKEN:-}" ]; then if [ -z "${FORGE_TOKEN:-}" ]; then
FORGE_TOKEN="${CODEBERG_TOKEN:-}" FORGE_TOKEN="${CODEBERG_TOKEN:-}"
fi fi
if [ -z "${FORGE_TOKEN:-}" ]; then
FORGE_TOKEN="$(awk '/codeberg.org/{getline;getline;print $2}' ~/.netrc 2>/dev/null || true)"
fi
export FORGE_TOKEN export FORGE_TOKEN
export CODEBERG_TOKEN="${FORGE_TOKEN}" # backwards compat export CODEBERG_TOKEN="${FORGE_TOKEN}" # backwards compat