From 9d8f3220052310e3762979d0711e7caecc0f1596 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 22:37:22 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20[nomad-prep]=20P7=20=E2=80=94=20make=20d?= =?UTF-8?q?isinto=20init=20idempotent=20+=20add=20--dry-run=20(#800)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make `disinto init` safe to re-run on the same box: - Store admin token as FORGE_ADMIN_TOKEN in .env; preserve on re-run (previously deleted and recreated every run, churning DB state) - Fix human token creation: use admin_pass for basic-auth since human_user == admin_user (previously used a random password that never matched the actual user password, so HUMAN_TOKEN was never created successfully) - Preserve HUMAN_TOKEN in .env on re-run (same pattern as bot tokens) - Bot tokens were already idempotent (preserved unless --rotate-tokens) Add --dry-run flag that reports every intended action (file writes, API calls, docker commands) based on current state, then exits 0 without touching state. Useful for CI gating and cutover confidence. Update smoke test: - Add dry-run test (verifies exit 0 and no .env modification) - Add idempotency state diff (verifies .env is unchanged on re-run) - Verify FORGE_ADMIN_TOKEN and HUMAN_TOKEN are stored in .env Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/disinto | 84 ++++++++++++++++++++++++++- lib/forge-setup.sh | 136 ++++++++++++++++++++++++++------------------ tests/smoke-init.sh | 50 +++++++++++++++- 3 files changed, 212 insertions(+), 58 deletions(-) diff --git a/bin/disinto b/bin/disinto index b16a7ed..486915a 100755 --- a/bin/disinto +++ b/bin/disinto @@ -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 to role formula TOML (default: formulas/.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 diff --git a/lib/forge-setup.sh b/lib/forge-setup.sh index 192668a..2b7b697 100644 --- a/lib/forge-setup.sh +++ b/lib/forge-setup.sh @@ -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 diff --git a/tests/smoke-init.sh b/tests/smoke-init.sh index e8cd245..306f7ee 100644 --- a/tests/smoke-init.sh +++ b/tests/smoke-init.sh @@ -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 ==="