fix: [nomad-prep] P7 — make disinto init idempotent + add --dry-run (#800) #815

Merged
dev-bot merged 1 commit from fix/issue-800 into main 2026-04-15 23:43:29 +00:00
3 changed files with 212 additions and 58 deletions
Showing only changes of commit 9d8f322005 - Show all commits

View file

@ -85,6 +85,7 @@ Init options:
--build Use local docker build instead of registry images (dev mode)
--yes Skip confirmation prompts
--rotate-tokens Force regeneration of all bot tokens/passwords (idempotent by default)
--dry-run Print every intended action without executing
Hire an agent options:
--formula <path> Path to role formula TOML (default: formulas/<role>.toml)
@ -653,7 +654,7 @@ disinto_init() {
shift
# Parse flags
local branch="" repo_root="" ci_id="0" auto_yes=false forge_url_flag="" bare=false rotate_tokens=false use_build=false
local branch="" repo_root="" ci_id="0" auto_yes=false forge_url_flag="" bare=false rotate_tokens=false use_build=false dry_run=false
while [ $# -gt 0 ]; do
case "$1" in
--branch) branch="$2"; shift 2 ;;
@ -664,6 +665,7 @@ disinto_init() {
--build) use_build=true; shift ;;
--yes) auto_yes=true; shift ;;
--rotate-tokens) rotate_tokens=true; shift ;;
--dry-run) dry_run=true; shift ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
@ -740,6 +742,86 @@ p.write_text(text)
fi
fi
# ── Dry-run mode: report intended actions and exit ─────────────────────────
if [ "$dry_run" = true ]; then
echo ""
echo "── Dry-run: intended actions ────────────────────────────"
local env_file="${FACTORY_ROOT}/.env"
local rr="${repo_root:-/home/${USER}/${project_name}}"
if [ "$bare" = false ]; then
[ -f "${FACTORY_ROOT}/docker-compose.yml" ] \
&& echo "[skip] docker-compose.yml (exists)" \
|| echo "[create] docker-compose.yml"
fi
[ -f "$env_file" ] \
&& echo "[exists] .env" \
|| echo "[create] .env"
# Report token state from .env
if [ -f "$env_file" ]; then
local _var
for _var in FORGE_ADMIN_TOKEN HUMAN_TOKEN FORGE_TOKEN FORGE_REVIEW_TOKEN \
FORGE_PLANNER_TOKEN FORGE_GARDENER_TOKEN FORGE_VAULT_TOKEN \
FORGE_SUPERVISOR_TOKEN FORGE_PREDICTOR_TOKEN FORGE_ARCHITECT_TOKEN; do
if grep -q "^${_var}=" "$env_file" 2>/dev/null; then
echo "[keep] ${_var} (preserved)"
else
echo "[create] ${_var}"
fi
done
else
echo "[create] all tokens and passwords"
fi
echo ""
echo "[ensure] Forgejo admin user 'disinto-admin'"
echo "[ensure] 8 bot users: dev-bot, review-bot, planner-bot, gardener-bot, vault-bot, supervisor-bot, predictor-bot, architect-bot"
echo "[ensure] 2 llama bot users: dev-qwen, dev-qwen-nightly"
echo "[ensure] .profile repos for all bots"
echo "[ensure] repo ${forge_repo} on Forgejo with collaborators"
echo "[run] preflight checks"
[ -d "${rr}/.git" ] \
&& echo "[skip] clone ${rr} (exists)" \
|| echo "[clone] ${repo_url} -> ${rr}"
echo "[push] to local Forgejo"
echo "[ensure] ops repo disinto-admin/${project_name}-ops"
echo "[ensure] branch protection on ${forge_repo}"
[ "$toml_exists" = true ] \
&& echo "[skip] ${toml_path} (exists)" \
|| echo "[create] ${toml_path}"
if [ "$bare" = false ]; then
echo "[ensure] Woodpecker OAuth2 app"
echo "[ensure] Chat OAuth2 app"
echo "[ensure] WOODPECKER_AGENT_SECRET in .env"
fi
echo "[ensure] labels on ${forge_repo}"
[ -f "${rr}/VISION.md" ] \
&& echo "[skip] VISION.md (exists)" \
|| echo "[create] VISION.md"
echo "[copy] issue templates"
echo "[ensure] scheduling (cron or compose polling)"
if [ "$bare" = false ]; then
echo "[start] docker compose stack"
echo "[ensure] Woodpecker token + repo activation"
fi
echo "[ensure] CLAUDE_CONFIG_DIR"
echo "[ensure] state files (.dev-active, .reviewer-active, .gardener-active)"
echo ""
echo "Dry run complete — no changes made."
exit 0
fi
# Generate compose files (unless --bare)
if [ "$bare" = false ]; then
local forge_port

View file

@ -212,8 +212,8 @@ setup_forge() {
# Create human user (disinto-admin) as site admin if it doesn't exist
local human_user="disinto-admin"
local human_pass
human_pass="admin-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 20)"
# human_user == admin_user; reuse admin_pass for basic-auth operations
local human_pass="$admin_pass"
if ! curl -sf --max-time 5 -H "Authorization: token ${FORGE_TOKEN:-}" "${forge_url}/api/v1/users/${human_user}" >/dev/null 2>&1; then
echo "Creating human user: ${human_user}"
@ -245,63 +245,89 @@ setup_forge() {
echo "Human user: ${human_user} (already exists)"
fi
# Delete existing admin token if present (token sha1 is only returned at creation time)
local existing_token_id
existing_token_id=$(curl -sf \
-u "${admin_user}:${admin_pass}" \
"${forge_url}/api/v1/users/${admin_user}/tokens" 2>/dev/null \
| jq -r '.[] | select(.name == "disinto-admin-token") | .id') || existing_token_id=""
if [ -n "$existing_token_id" ]; then
curl -sf -X DELETE \
-u "${admin_user}:${admin_pass}" \
"${forge_url}/api/v1/users/${admin_user}/tokens/${existing_token_id}" >/dev/null 2>&1 || true
# Preserve admin token if already stored in .env (idempotent re-run)
local admin_token=""
if _token_exists_in_env "FORGE_ADMIN_TOKEN" "$env_file" && [ "$rotate_tokens" = false ]; then
admin_token=$(grep '^FORGE_ADMIN_TOKEN=' "$env_file" | head -1 | cut -d= -f2-)
[ -n "$admin_token" ] && echo "Admin token: preserved (use --rotate-tokens to force)"
fi
# Create admin token (fresh, so sha1 is returned)
local admin_token
admin_token=$(curl -sf -X POST \
-u "${admin_user}:${admin_pass}" \
-H "Content-Type: application/json" \
"${forge_url}/api/v1/users/${admin_user}/tokens" \
-d '{"name":"disinto-admin-token","scopes":["all"]}' 2>/dev/null \
| jq -r '.sha1 // empty') || admin_token=""
if [ -z "$admin_token" ]; then
echo "Error: failed to obtain admin API token" >&2
exit 1
fi
# Get or create human user token
local human_token=""
# Delete existing human token if present (token sha1 is only returned at creation time)
local existing_human_token_id
existing_human_token_id=$(curl -sf \
-u "${human_user}:${human_pass}" \
"${forge_url}/api/v1/users/${human_user}/tokens" 2>/dev/null \
| jq -r '.[] | select(.name == "disinto-human-token") | .id') || existing_human_token_id=""
if [ -n "$existing_human_token_id" ]; then
curl -sf -X DELETE \
-u "${human_user}:${human_pass}" \
"${forge_url}/api/v1/users/${human_user}/tokens/${existing_human_token_id}" >/dev/null 2>&1 || true
fi
# Create human token (fresh, so sha1 is returned)
human_token=$(curl -sf -X POST \
-u "${human_user}:${human_pass}" \
-H "Content-Type: application/json" \
"${forge_url}/api/v1/users/${human_user}/tokens" \
-d '{"name":"disinto-human-token","scopes":["all"]}' 2>/dev/null \
| jq -r '.sha1 // empty') || human_token=""
if [ -n "$human_token" ]; then
# Store human token in .env
if grep -q '^HUMAN_TOKEN=' "$env_file" 2>/dev/null; then
sed -i "s|^HUMAN_TOKEN=.*|HUMAN_TOKEN=${human_token}|" "$env_file"
else
printf 'HUMAN_TOKEN=%s\n' "$human_token" >> "$env_file"
# Delete existing admin token if present (token sha1 is only returned at creation time)
local existing_token_id
existing_token_id=$(curl -sf \
-u "${admin_user}:${admin_pass}" \
"${forge_url}/api/v1/users/${admin_user}/tokens" 2>/dev/null \
| jq -r '.[] | select(.name == "disinto-admin-token") | .id') || existing_token_id=""
if [ -n "$existing_token_id" ]; then
curl -sf -X DELETE \
-u "${admin_user}:${admin_pass}" \
"${forge_url}/api/v1/users/${admin_user}/tokens/${existing_token_id}" >/dev/null 2>&1 || true
fi
# Create admin token (fresh, so sha1 is returned)
admin_token=$(curl -sf -X POST \
-u "${admin_user}:${admin_pass}" \
-H "Content-Type: application/json" \
"${forge_url}/api/v1/users/${admin_user}/tokens" \
-d '{"name":"disinto-admin-token","scopes":["all"]}' 2>/dev/null \
| jq -r '.sha1 // empty') || admin_token=""
if [ -z "$admin_token" ]; then
echo "Error: failed to obtain admin API token" >&2
exit 1
fi
# Store admin token for idempotent re-runs
if grep -q '^FORGE_ADMIN_TOKEN=' "$env_file" 2>/dev/null; then
sed -i "s|^FORGE_ADMIN_TOKEN=.*|FORGE_ADMIN_TOKEN=${admin_token}|" "$env_file"
else
printf 'FORGE_ADMIN_TOKEN=%s\n' "$admin_token" >> "$env_file"
fi
echo "Admin token: generated and saved (FORGE_ADMIN_TOKEN)"
fi
# Get or create human user token (human_user == admin_user; use admin_pass)
local human_token=""
if _token_exists_in_env "HUMAN_TOKEN" "$env_file" && [ "$rotate_tokens" = false ]; then
human_token=$(grep '^HUMAN_TOKEN=' "$env_file" | head -1 | cut -d= -f2-)
if [ -n "$human_token" ]; then
export HUMAN_TOKEN="$human_token"
echo " Human token preserved (use --rotate-tokens to force)"
fi
fi
if [ -z "$human_token" ]; then
# Delete existing human token if present (token sha1 is only returned at creation time)
local existing_human_token_id
existing_human_token_id=$(curl -sf \
-u "${admin_user}:${admin_pass}" \
"${forge_url}/api/v1/users/${human_user}/tokens" 2>/dev/null \
| jq -r '.[] | select(.name == "disinto-human-token") | .id') || existing_human_token_id=""
if [ -n "$existing_human_token_id" ]; then
curl -sf -X DELETE \
-u "${admin_user}:${admin_pass}" \
"${forge_url}/api/v1/users/${human_user}/tokens/${existing_human_token_id}" >/dev/null 2>&1 || true
fi
# Create human token (use admin_pass since human_user == admin_user)
human_token=$(curl -sf -X POST \
-u "${admin_user}:${admin_pass}" \
-H "Content-Type: application/json" \
"${forge_url}/api/v1/users/${human_user}/tokens" \
-d '{"name":"disinto-human-token","scopes":["all"]}' 2>/dev/null \
| jq -r '.sha1 // empty') || human_token=""
if [ -n "$human_token" ]; then
# Store human token in .env
if grep -q '^HUMAN_TOKEN=' "$env_file" 2>/dev/null; then
sed -i "s|^HUMAN_TOKEN=.*|HUMAN_TOKEN=${human_token}|" "$env_file"
else
printf 'HUMAN_TOKEN=%s\n' "$human_token" >> "$env_file"
fi
export HUMAN_TOKEN="$human_token"
echo " Human token generated and saved (HUMAN_TOKEN)"
fi
export HUMAN_TOKEN="$human_token"
echo " Human token saved (HUMAN_TOKEN)"
fi
# Create bot users and tokens

View file

@ -29,7 +29,8 @@ cleanup() {
pkill -f "mock-forgejo.py" 2>/dev/null || true
rm -rf "$MOCK_BIN" /tmp/smoke-test-repo \
"${FACTORY_ROOT}/projects/smoke-repo.toml" \
/tmp/smoke-claude-shared /tmp/smoke-home-claude
/tmp/smoke-claude-shared /tmp/smoke-home-claude \
/tmp/smoke-env-before-rerun /tmp/smoke-env-before-dryrun
# Restore .env only if we created the backup
if [ -f "${FACTORY_ROOT}/.env.smoke-backup" ]; then
mv "${FACTORY_ROOT}/.env.smoke-backup" "${FACTORY_ROOT}/.env"
@ -178,8 +179,30 @@ else
fail "disinto init exited non-zero"
fi
# ── Idempotency test: run init again ───────────────────────────────────────
# ── Dry-run test: must not modify state ────────────────────────────────────
echo "=== Dry-run test ==="
cp "${FACTORY_ROOT}/.env" /tmp/smoke-env-before-dryrun
if bash "${FACTORY_ROOT}/bin/disinto" init \
"${TEST_SLUG}" \
--bare --yes --dry-run \
--forge-url "$FORGE_URL" \
--repo-root "/tmp/smoke-test-repo" 2>&1 | grep -q "Dry run complete"; then
pass "disinto init --dry-run exited successfully"
else
fail "disinto init --dry-run did not complete"
fi
# Verify --dry-run did not modify .env
if diff -q /tmp/smoke-env-before-dryrun "${FACTORY_ROOT}/.env" >/dev/null 2>&1; then
pass "dry-run: .env unchanged"
else
fail "dry-run: .env was modified (should be read-only)"
fi
rm -f /tmp/smoke-env-before-dryrun
# ── Idempotency test: run init again, verify .env is stable ────────────────
echo "=== Idempotency test: running disinto init again ==="
cp "${FACTORY_ROOT}/.env" /tmp/smoke-env-before-rerun
if bash "${FACTORY_ROOT}/bin/disinto" init \
"${TEST_SLUG}" \
--bare --yes \
@ -190,6 +213,29 @@ else
fail "disinto init (re-run) exited non-zero"
fi
# Verify .env is stable across re-runs (no token churn)
if diff -q /tmp/smoke-env-before-rerun "${FACTORY_ROOT}/.env" >/dev/null 2>&1; then
pass "idempotency: .env unchanged on re-run"
else
fail "idempotency: .env changed on re-run (token churn detected)"
diff /tmp/smoke-env-before-rerun "${FACTORY_ROOT}/.env" >&2 || true
fi
rm -f /tmp/smoke-env-before-rerun
# Verify FORGE_ADMIN_TOKEN is stored in .env
if grep -q '^FORGE_ADMIN_TOKEN=' "${FACTORY_ROOT}/.env"; then
pass ".env contains FORGE_ADMIN_TOKEN"
else
fail ".env missing FORGE_ADMIN_TOKEN"
fi
# Verify HUMAN_TOKEN is stored in .env
if grep -q '^HUMAN_TOKEN=' "${FACTORY_ROOT}/.env"; then
pass ".env contains HUMAN_TOKEN"
else
fail ".env missing HUMAN_TOKEN"
fi
# ── 4. Verify Forgejo state ─────────────────────────────────────────────────
echo "=== 4/6 Verifying Forgejo state ==="