fix: fix: disinto init must be fully idempotent — safe to re-run on existing factory (#239) #249
1 changed files with 195 additions and 76 deletions
271
bin/disinto
271
bin/disinto
|
|
@ -177,6 +177,12 @@ generate_compose() {
|
|||
local forge_port="${1:-3000}"
|
||||
local compose_file="${FACTORY_ROOT}/docker-compose.yml"
|
||||
|
||||
# Check if compose file already exists
|
||||
if [ -f "$compose_file" ]; then
|
||||
echo "Compose: ${compose_file} (already exists, skipping)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
cat > "$compose_file" <<'COMPOSEEOF'
|
||||
# docker-compose.yml — generated by disinto init
|
||||
# Brings up Forgejo, Woodpecker, and the agent runtime.
|
||||
|
|
@ -818,9 +824,15 @@ setup_forge() {
|
|||
bot_pass="bot-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 20)"
|
||||
token_var="${bot_token_vars[$bot_user]}"
|
||||
|
||||
if ! curl -sf --max-time 5 \
|
||||
# Check if bot user exists
|
||||
local user_exists=false
|
||||
if curl -sf --max-time 5 \
|
||||
-H "Authorization: token ${admin_token}" \
|
||||
"${forge_url}/api/v1/users/${bot_user}" >/dev/null 2>&1; then
|
||||
user_exists=true
|
||||
fi
|
||||
|
||||
if [ "$user_exists" = false ]; then
|
||||
echo "Creating bot user: ${bot_user}"
|
||||
local create_output
|
||||
if ! create_output=$(_forgejo_exec forgejo admin user create \
|
||||
|
|
@ -846,10 +858,38 @@ setup_forge() {
|
|||
echo "Error: bot user '${bot_user}' not found after creation" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " ${bot_user} user created"
|
||||
else
|
||||
echo " ${bot_user} user exists (resetting password for token generation)"
|
||||
# User exists but may not have a known password.
|
||||
# Use admin API to reset the password so we can generate a new token.
|
||||
_forgejo_exec forgejo admin user change-password \
|
||||
--username "${bot_user}" \
|
||||
--password "${bot_pass}" \
|
||||
--must-change-password=false || {
|
||||
echo "Error: failed to reset password for existing bot user '${bot_user}'" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
# Generate token via API (basic auth as the bot user — Forgejo requires
|
||||
# basic auth on POST /users/{username}/tokens, token auth is rejected)
|
||||
# First, try to delete existing tokens to avoid name collision
|
||||
local existing_token_ids
|
||||
existing_token_ids=$(curl -sf \
|
||||
-H "Authorization: token ${admin_token}" \
|
||||
"${forge_url}/api/v1/users/${bot_user}/tokens" 2>/dev/null \
|
||||
| jq -r '.[].id // empty' 2>/dev/null) || existing_token_ids=""
|
||||
|
||||
# Delete any existing tokens for this user
|
||||
if [ -n "$existing_token_ids" ]; then
|
||||
while IFS= read -r tid; do
|
||||
[ -n "$tid" ] && curl -sf -X DELETE \
|
||||
-H "Authorization: token ${admin_token}" \
|
||||
"${forge_url}/api/v1/users/${bot_user}/tokens/${tid}" >/dev/null 2>&1 || true
|
||||
done <<< "$existing_token_ids"
|
||||
fi
|
||||
|
||||
token=$(curl -sf -X POST \
|
||||
-u "${bot_user}:${bot_pass}" \
|
||||
-H "Content-Type: application/json" \
|
||||
|
|
@ -857,16 +897,6 @@ setup_forge() {
|
|||
-d "{\"name\":\"disinto-${bot_user}-token\",\"scopes\":[\"all\"]}" 2>/dev/null \
|
||||
| jq -r '.sha1 // empty') || token=""
|
||||
|
||||
if [ -z "$token" ]; then
|
||||
# Token name collision — create with timestamp suffix
|
||||
token=$(curl -sf -X POST \
|
||||
-u "${bot_user}:${bot_pass}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/users/${bot_user}/tokens" \
|
||||
-d "{\"name\":\"disinto-${bot_user}-$(date +%s)\",\"scopes\":[\"all\"]}" 2>/dev/null \
|
||||
| jq -r '.sha1 // empty') || token=""
|
||||
fi
|
||||
|
||||
if [ -z "$token" ]; then
|
||||
echo "Error: failed to create API token for '${bot_user}'" >&2
|
||||
exit 1
|
||||
|
|
@ -879,7 +909,7 @@ setup_forge() {
|
|||
printf '%s=%s\n' "$token_var" "$token" >> "$env_file"
|
||||
fi
|
||||
export "${token_var}=${token}"
|
||||
echo " ${bot_user} token saved (${token_var})"
|
||||
echo " ${bot_user} token generated and saved (${token_var})"
|
||||
|
||||
# Backwards-compat aliases for dev-bot and review-bot
|
||||
if [ "$bot_user" = "dev-bot" ]; then
|
||||
|
|
@ -995,76 +1025,119 @@ setup_ops_repo() {
|
|||
echo ""
|
||||
echo "── Ops repo setup ─────────────────────────────────────"
|
||||
|
||||
# Check if ops repo already exists on Forgejo
|
||||
if curl -sf --max-time 5 \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${forge_url}/api/v1/repos/${ops_slug}" >/dev/null 2>&1; then
|
||||
echo "Ops repo: ${ops_slug} (already exists on Forgejo)"
|
||||
else
|
||||
# Create ops repo under org (or human user if org creation failed)
|
||||
if ! curl -sf -X POST \
|
||||
# Determine the actual ops repo location by searching across possible namespaces
|
||||
# This handles cases where the repo was created under a different namespace
|
||||
# due to past bugs (e.g., dev-bot/disinto-ops instead of disinto-admin/disinto-ops)
|
||||
local actual_ops_slug=""
|
||||
local -a possible_namespaces=( "$org_name" "dev-bot" "disinto-admin" )
|
||||
local http_code
|
||||
|
||||
for ns in "${possible_namespaces[@]}"; do
|
||||
slug="${ns}/${ops_name}"
|
||||
if curl -sf --max-time 5 \
|
||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||
"${forge_url}/api/v1/repos/${slug}" >/dev/null 2>&1; then
|
||||
actual_ops_slug="$slug"
|
||||
echo "Ops repo: ${slug} (found at ${slug})"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# If not found, try to create it in the configured namespace
|
||||
if [ -z "$actual_ops_slug" ]; then
|
||||
echo "Creating ops repo in namespace: ${org_name}"
|
||||
if curl -sf -X POST \
|
||||
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/orgs/${org_name}/repos" \
|
||||
-d "{\"name\":\"${ops_name}\",\"auto_init\":true,\"default_branch\":\"${primary_branch}\",\"description\":\"Operational data for ${org_name}/${ops_name%-ops}\"}" >/dev/null 2>&1; then
|
||||
# Fallback: use admin API to create repo under the target namespace.
|
||||
# POST /api/v1/users/{username}/repos creates under the authenticated user,
|
||||
# not under {username}. The admin API POST /api/v1/admin/users/{username}/repos
|
||||
# explicitly creates in the target user's namespace regardless of who is authed.
|
||||
curl -sf -X POST \
|
||||
actual_ops_slug="${org_name}/${ops_name}"
|
||||
echo "Ops repo: ${actual_ops_slug} created on Forgejo"
|
||||
else
|
||||
# Fallback: use admin API to create repo under the target namespace
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X POST \
|
||||
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/admin/users/${org_name}/repos" \
|
||||
-d "{\"name\":\"${ops_name}\",\"auto_init\":true,\"default_branch\":\"${primary_branch}\",\"description\":\"Operational data for ${org_name}/${ops_name%-ops}\"}" >/dev/null 2>&1 || true
|
||||
-d "{\"name\":\"${ops_name}\",\"auto_init\":true,\"default_branch\":\"${primary_branch}\",\"description\":\"Operational data for ${org_name}/${ops_name%-ops}\"}" 2>/dev/null || echo "0")
|
||||
if [ "$http_code" = "201" ]; then
|
||||
actual_ops_slug="${org_name}/${ops_name}"
|
||||
echo "Ops repo: ${actual_ops_slug} created on Forgejo (via admin API)"
|
||||
else
|
||||
echo "Error: failed to create ops repo '${actual_ops_slug}' (HTTP ${http_code})" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Add all bot users as collaborators with appropriate permissions
|
||||
# vault branch protection (#77) requires:
|
||||
# - Admin-only merge to main (enforced by admin_enforced: true)
|
||||
# - Bots can push branches and create PRs, but cannot merge
|
||||
local bot_user bot_perm
|
||||
declare -A bot_permissions=(
|
||||
[dev-bot]="write"
|
||||
[review-bot]="read"
|
||||
[planner-bot]="write"
|
||||
[gardener-bot]="write"
|
||||
[vault-bot]="write"
|
||||
[supervisor-bot]="read"
|
||||
[predictor-bot]="read"
|
||||
[architect-bot]="write"
|
||||
)
|
||||
for bot_user in "${!bot_permissions[@]}"; do
|
||||
bot_perm="${bot_permissions[$bot_user]}"
|
||||
curl -sf -X PUT \
|
||||
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/repos/${ops_slug}/collaborators/${bot_user}" \
|
||||
-d "{\"permission\":\"${bot_perm}\"}" >/dev/null 2>&1 || true
|
||||
done
|
||||
# Configure collaborators on the ops repo
|
||||
local bot_user bot_perm
|
||||
declare -A bot_permissions=(
|
||||
[dev-bot]="write"
|
||||
[review-bot]="read"
|
||||
[planner-bot]="write"
|
||||
[gardener-bot]="write"
|
||||
[vault-bot]="write"
|
||||
[supervisor-bot]="read"
|
||||
[predictor-bot]="read"
|
||||
[architect-bot]="write"
|
||||
)
|
||||
|
||||
# Add disinto-admin as admin collaborator
|
||||
curl -sf -X PUT \
|
||||
# Add all bot users as collaborators with appropriate permissions
|
||||
# vault branch protection (#77) requires:
|
||||
# - Admin-only merge to main (enforced by admin_enforced: true)
|
||||
# - Bots can push branches and create PRs, but cannot merge
|
||||
for bot_user in "${!bot_permissions[@]}"; do
|
||||
bot_perm="${bot_permissions[$bot_user]}"
|
||||
if curl -sf -X PUT \
|
||||
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/repos/${ops_slug}/collaborators/disinto-admin" \
|
||||
-d '{"permission":"admin"}' >/dev/null 2>&1 || true
|
||||
"${forge_url}/api/v1/repos/${actual_ops_slug}/collaborators/${bot_user}" \
|
||||
-d "{\"permission\":\"${bot_perm}\"}" >/dev/null 2>&1; then
|
||||
echo " + ${bot_user} = ${bot_perm} collaborator"
|
||||
else
|
||||
echo " ! ${bot_user} = ${bot_perm} (already set or failed)"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Ops repo: ${ops_slug} created on Forgejo"
|
||||
# Add disinto-admin as admin collaborator
|
||||
if curl -sf -X PUT \
|
||||
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${forge_url}/api/v1/repos/${actual_ops_slug}/collaborators/disinto-admin" \
|
||||
-d '{"permission":"admin"}' >/dev/null 2>&1; then
|
||||
echo " + disinto-admin = admin collaborator"
|
||||
else
|
||||
echo " ! disinto-admin = admin (already set or failed)"
|
||||
fi
|
||||
|
||||
# Clone ops repo locally if not present
|
||||
if [ ! -d "${ops_root}/.git" ]; then
|
||||
local auth_url
|
||||
auth_url=$(printf '%s' "$forge_url" | sed "s|://|://dev-bot:${FORGE_TOKEN}@|")
|
||||
local clone_url="${auth_url}/${ops_slug}.git"
|
||||
local clone_url="${auth_url}/${actual_ops_slug}.git"
|
||||
echo "Cloning: ops repo -> ${ops_root}"
|
||||
git clone --quiet "$clone_url" "$ops_root" 2>/dev/null || {
|
||||
if git clone --quiet "$clone_url" "$ops_root" 2>/dev/null; then
|
||||
echo "Ops repo: ${actual_ops_slug} cloned successfully"
|
||||
else
|
||||
echo "Initializing: ops repo at ${ops_root}"
|
||||
mkdir -p "$ops_root"
|
||||
git -C "$ops_root" init --initial-branch="${primary_branch}" -q
|
||||
}
|
||||
# Set remote to the actual ops repo location
|
||||
git -C "$ops_root" remote add origin "${forge_url}/${actual_ops_slug}.git"
|
||||
echo "Ops repo: ${actual_ops_slug} initialized locally"
|
||||
fi
|
||||
else
|
||||
echo "Ops repo: ${ops_root} (already exists locally)"
|
||||
# Verify remote is correct
|
||||
local current_remote
|
||||
current_remote=$(git -C "$ops_root" remote get-url origin 2>/dev/null || true)
|
||||
local expected_remote="${forge_url}/${actual_ops_slug}.git"
|
||||
if [ -n "$current_remote" ] && [ "$current_remote" != "$expected_remote" ]; then
|
||||
echo " Fixing: remote URL from ${current_remote} to ${expected_remote}"
|
||||
git -C "$ops_root" remote set-url origin "$expected_remote"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Seed directory structure
|
||||
|
|
@ -1128,10 +1201,13 @@ OPSEOF
|
|||
git -C "$ops_root" commit -m "chore: seed ops repo structure" -q
|
||||
# Push if remote exists
|
||||
if git -C "$ops_root" remote get-url origin >/dev/null 2>&1; then
|
||||
git -C "$ops_root" push origin "${primary_branch}" -q 2>/dev/null || true
|
||||
if git -C "$ops_root" push origin "${primary_branch}" -q 2>/dev/null; then
|
||||
echo "Seeded: ops repo with initial structure"
|
||||
else
|
||||
echo "Warning: failed to push seed content to ops repo" >&2
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo "Seeded: ops repo with initial structure"
|
||||
fi
|
||||
}
|
||||
|
||||
|
|
@ -1385,9 +1461,11 @@ create_labels() {
|
|||
| grep -o '"name":"[^"]*"' | cut -d'"' -f4) || existing=""
|
||||
|
||||
local name color
|
||||
local created=0 skipped=0 failed=0
|
||||
for name in backlog in-progress blocked tech-debt underspecified vision action prediction/unreviewed prediction/dismissed prediction/actioned; do
|
||||
if echo "$existing" | grep -qx "$name"; then
|
||||
echo " . ${name} (already exists)"
|
||||
skipped=$((skipped + 1))
|
||||
continue
|
||||
fi
|
||||
color="${labels[$name]}"
|
||||
|
|
@ -1396,11 +1474,15 @@ create_labels() {
|
|||
-H "Content-Type: application/json" \
|
||||
"${api}/labels" \
|
||||
-d "{\"name\":\"${name}\",\"color\":\"${color}\"}" >/dev/null 2>&1; then
|
||||
echo " + ${name}"
|
||||
echo " + ${name} (created)"
|
||||
created=$((created + 1))
|
||||
else
|
||||
echo " ! ${name} (failed to create)"
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Labels: ${created} created, ${skipped} skipped, ${failed} failed"
|
||||
}
|
||||
|
||||
# Generate a minimal VISION.md template in the target project.
|
||||
|
|
@ -1473,6 +1555,14 @@ install_cron() {
|
|||
echo "$cron_block"
|
||||
echo ""
|
||||
|
||||
# Check if cron entries already exist
|
||||
local current_crontab
|
||||
current_crontab=$(crontab -l 2>/dev/null || true)
|
||||
if echo "$current_crontab" | grep -q "# disinto: ${name}"; then
|
||||
echo "Cron: skipped (entries for ${name} already installed)"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$auto_yes" = false ] && [ -t 0 ]; then
|
||||
read -rp "Install these cron entries? [y/N] " confirm
|
||||
if [[ ! "$confirm" =~ ^[Yy] ]]; then
|
||||
|
|
@ -1482,8 +1572,12 @@ install_cron() {
|
|||
fi
|
||||
|
||||
# Append to existing crontab
|
||||
{ crontab -l 2>/dev/null || true; printf '%s\n' "$cron_block"; } | crontab -
|
||||
echo "Cron entries installed"
|
||||
if { crontab -l 2>/dev/null || true; printf '%s\n' "$cron_block"; } | crontab -; then
|
||||
echo "Cron entries installed for ${name}"
|
||||
else
|
||||
echo "Error: failed to install cron entries" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Set up Woodpecker CI to use Forgejo as its forge backend.
|
||||
|
|
@ -2058,17 +2152,36 @@ p.write_text(text)
|
|||
if [ -n "${MIRROR_NAMES:-}" ]; then
|
||||
echo "Mirrors: setting up remotes"
|
||||
local mname murl
|
||||
local mirrors_ok=true
|
||||
for mname in $MIRROR_NAMES; do
|
||||
murl=$(eval "echo \"\$MIRROR_$(echo "$mname" | tr '[:lower:]' '[:upper:]')\"") || true
|
||||
[ -z "$murl" ] && continue
|
||||
git -C "$repo_root" remote add "$mname" "$murl" 2>/dev/null \
|
||||
|| git -C "$repo_root" remote set-url "$mname" "$murl" 2>/dev/null || true
|
||||
echo " + ${mname} -> ${murl}"
|
||||
if git -C "$repo_root" remote get-url "$mname" >/dev/null 2>&1; then
|
||||
if git -C "$repo_root" remote set-url "$mname" "$murl"; then
|
||||
echo " + ${mname} -> ${murl} (updated)"
|
||||
else
|
||||
echo " ! ${mname} -> ${murl} (failed to update URL)"
|
||||
mirrors_ok=false
|
||||
fi
|
||||
else
|
||||
if git -C "$repo_root" remote add "$mname" "$murl"; then
|
||||
echo " + ${mname} -> ${murl} (added)"
|
||||
else
|
||||
echo " ! ${mname} -> ${murl} (failed to add remote)"
|
||||
mirrors_ok=false
|
||||
fi
|
||||
fi
|
||||
done
|
||||
# Initial sync: push current primary branch to mirrors
|
||||
source "${FACTORY_ROOT}/lib/mirrors.sh"
|
||||
export PROJECT_REPO_ROOT="$repo_root"
|
||||
mirror_push
|
||||
if [ "$mirrors_ok" = true ]; then
|
||||
source "${FACTORY_ROOT}/lib/mirrors.sh"
|
||||
export PROJECT_REPO_ROOT="$repo_root"
|
||||
if mirror_push; then
|
||||
echo "Mirrors: initial sync complete"
|
||||
else
|
||||
echo "Warning: mirror push failed" >&2
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Encrypt secrets if SOPS + age are available
|
||||
|
|
@ -2107,9 +2220,16 @@ p.write_text(text)
|
|||
# Activate default agents (zero-cost when idle — they only invoke Claude
|
||||
# when there is actual work, so an empty project burns no LLM tokens)
|
||||
mkdir -p "${FACTORY_ROOT}/state"
|
||||
touch "${FACTORY_ROOT}/state/.dev-active"
|
||||
touch "${FACTORY_ROOT}/state/.reviewer-active"
|
||||
touch "${FACTORY_ROOT}/state/.gardener-active"
|
||||
|
||||
# State files are idempotent — create if missing, skip if present
|
||||
for state_file in ".dev-active" ".reviewer-active" ".gardener-active"; do
|
||||
if [ -f "${FACTORY_ROOT}/state/${state_file}" ]; then
|
||||
echo "State: ${state_file} (already active)"
|
||||
else
|
||||
touch "${FACTORY_ROOT}/state/${state_file}"
|
||||
echo "State: ${state_file} (created)"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Done. Project ${project_name} is ready."
|
||||
|
|
@ -2659,7 +2779,8 @@ disinto_hire_an_agent() {
|
|||
local admin_user="disinto-admin"
|
||||
admin_pass="${admin_pass:-admin}"
|
||||
local admin_token=""
|
||||
local admin_token_name="temp-token-$(date +%s)"
|
||||
local admin_token_name
|
||||
admin_token_name="temp-token-$(date +%s)"
|
||||
admin_token=$(curl -sf -X POST \
|
||||
-u "${admin_user}:${admin_pass}" \
|
||||
-H "Content-Type: application/json" \
|
||||
|
|
@ -2765,9 +2886,7 @@ disinto_hire_an_agent() {
|
|||
echo ""
|
||||
echo "Step 2: Creating '${agent_name}/.profile' repo (if not exists)..."
|
||||
|
||||
local repo_exists=false
|
||||
if curl -sf --max-time 5 "${forge_url}/api/v1/repos/${agent_name}/.profile" >/dev/null 2>&1; then
|
||||
repo_exists=true
|
||||
echo " Repo '${agent_name}/.profile' already exists"
|
||||
else
|
||||
# Create the repo using the admin API to ensure it's created in the agent's namespace.
|
||||
|
|
@ -2874,8 +2993,8 @@ EOF
|
|||
git -C "$clone_dir" add -A
|
||||
if ! git -C "$clone_dir" diff --cached --quiet 2>/dev/null; then
|
||||
git -C "$clone_dir" commit -m "chore: initial .profile setup" -q
|
||||
git -C "$clone_dir" push origin main 2>&1 >/dev/null || \
|
||||
git -C "$clone_dir" push origin master 2>&1 >/dev/null || true
|
||||
git -C "$clone_dir" push origin main >/dev/null 2>&1 || \
|
||||
git -C "$clone_dir" push origin master >/dev/null 2>&1 || true
|
||||
echo " Committed: initial .profile setup"
|
||||
else
|
||||
echo " No changes to commit"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue