From 0cf71f93a265d7f624977a217ee12f365c51b6d8 Mon Sep 17 00:00:00 2001 From: satyaborg Date: Tue, 16 Jun 2026 21:39:24 +1000 Subject: [PATCH] feat: upgrade-command --- README.md | 1 + devloop | 173 ++++++++++++++++++++++++++++++++++++++++ scripts/devloop_test.sh | 169 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 342 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9474f9e..decc350 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Uninstall with `./scripts/uninstall.sh` (`--dry-run` to preview). | `devloop spec "..."` | Have an agent interview you and write a spec | | `devloop ` | Run a spec | | `devloop --create-pr ` | Run a spec and maintain a draft PR (requires `gh`) | +| `devloop upgrade` | Install the latest released Devloop | | `devloop continue` | Resume a tracked run | | `devloop status` | Show run status | | `devloop clean` | Remove run artifacts | diff --git a/devloop b/devloop index 3a79a23..5002664 100755 --- a/devloop +++ b/devloop @@ -35,6 +35,7 @@ UI_DIM_COLOR="244" UI_BORDER_COLOR="141" UI_BACK=false UI_NOTICE="" +DEVLOOP_UPGRADE_PROMPTED=false EVENT_IDS=() EVENT_TITLES=() @@ -87,6 +88,12 @@ main() { return 0 fi + if [ "${1:-}" = "upgrade" ]; then + shift + upgrade_command "$@" + return $? + fi + if [ "${1:-}" = "spec" ]; then shift spec_command "$@" @@ -95,6 +102,7 @@ main() { if [ "${1:-}" = "menu" ]; then shift + if [ "$#" -eq 0 ]; then maybe_prompt_upgrade; fi interactive_menu "$@" return $? fi @@ -135,6 +143,7 @@ main() { if [ "$#" -eq 0 ] || has_arg "-h" "$@" || has_arg "--help" "$@"; then if [ "$#" -eq 0 ] && [ "$USE_TUI" = true ]; then + maybe_prompt_upgrade interactive_menu return $? fi @@ -172,6 +181,7 @@ Usage: Common commands: devloop doctor + devloop upgrade devloop reports devloop status devloop clean @@ -210,6 +220,7 @@ welcome_tui() { printf ' devloop [options] [max=5]\n\n' gum style --foreground "$UI_ACCENT_COLOR" --bold "Common commands" printf ' %-42s %s\n' "devloop doctor" "check required tools" + printf ' %-42s %s\n' "devloop upgrade" "install the latest release" printf ' %-42s %s\n' "devloop reports" "open previous run reports" printf ' %-42s %s\n' "devloop status" "summarize tracked runs" printf ' %-42s %s\n' "devloop clean" "show safe cleanup candidates" @@ -276,6 +287,168 @@ version_args_only() { [ "$saw_version" = true ] } +devloop_normalize_version() { + local version="$1" + local semver_re='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$' + version="${version#v}" + if ! [[ "$version" =~ $semver_re ]]; then + printf 'invalid version: %s\n' "$1" >&2 + return 1 + fi + printf '%s\n' "$version" +} + +devloop_version_gt() { + local left right left_main right_main left_pre="" right_pre="" + local left_parts right_parts left_ids right_ids + local i left_value right_value left_id right_id left_numeric right_numeric + left="$(devloop_normalize_version "$1")" || return 1 + right="$(devloop_normalize_version "$2")" || return 1 + left="${left%%+*}" + right="${right%%+*}" + left_main="${left%%-*}" + right_main="${right%%-*}" + if [ "$left" != "$left_main" ]; then left_pre="${left#*-}"; fi + if [ "$right" != "$right_main" ]; then right_pre="${right#*-}"; fi + + IFS=. read -r -a left_parts <<< "$left_main" + IFS=. read -r -a right_parts <<< "$right_main" + i=0 + while [ "$i" -lt 3 ]; do + left_value="${left_parts[$i]}" + right_value="${right_parts[$i]}" + if [ "$((10#$left_value))" -gt "$((10#$right_value))" ]; then return 0; fi + if [ "$((10#$left_value))" -lt "$((10#$right_value))" ]; then return 1; fi + i=$((i + 1)) + done + + if [ -z "$left_pre" ] && [ -n "$right_pre" ]; then return 0; fi + if [ -n "$left_pre" ] && [ -z "$right_pre" ]; then return 1; fi + if [ -z "$left_pre" ] && [ -z "$right_pre" ]; then return 1; fi + + IFS=. read -r -a left_ids <<< "$left_pre" + IFS=. read -r -a right_ids <<< "$right_pre" + i=0 + while [ "$i" -lt "${#left_ids[@]}" ] || [ "$i" -lt "${#right_ids[@]}" ]; do + if [ "$i" -ge "${#left_ids[@]}" ]; then return 1; fi + if [ "$i" -ge "${#right_ids[@]}" ]; then return 0; fi + left_id="${left_ids[$i]}" + right_id="${right_ids[$i]}" + left_numeric=false + right_numeric=false + if [[ "$left_id" =~ ^[0-9]+$ ]]; then left_numeric=true; fi + if [[ "$right_id" =~ ^[0-9]+$ ]]; then right_numeric=true; fi + if [ "$left_numeric" = true ] && [ "$right_numeric" = true ]; then + if [ "$((10#$left_id))" -gt "$((10#$right_id))" ]; then return 0; fi + if [ "$((10#$left_id))" -lt "$((10#$right_id))" ]; then return 1; fi + elif [ "$left_numeric" = true ]; then + return 1 + elif [ "$right_numeric" = true ]; then + return 0 + else + if [[ "$left_id" > "$right_id" ]]; then return 0; fi + if [[ "$left_id" < "$right_id" ]]; then return 1; fi + fi + i=$((i + 1)) + done + return 1 +} + +devloop_resolve_latest_version() { + local github_repo github_api_url response source_path version + github_repo="${DEVLOOP_GITHUB_REPO:-satyaborg/devloop}" + github_api_url="${DEVLOOP_GITHUB_API_URL:-https://api.github.com/repos/$github_repo/releases/latest}" + case "$github_api_url" in + file://*) + source_path="${github_api_url#file://}" + if [ ! -f "$source_path" ]; then + printf 'missing latest release fixture: %s\n' "$source_path" >&2 + return 1 + fi + response="$(<"$source_path")" + ;; + *) + if ! command -v curl >/dev/null 2>&1; then + printf 'missing curl; cannot resolve latest Devloop release\n' >&2 + return 1 + fi + response="$(curl -fsSL "$github_api_url")" || { + printf 'failed to resolve latest Devloop release\n' >&2 + return 1 + } + ;; + esac + version="$(printf '%s\n' "$response" | sed -nE 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v?([^"]+)".*/\1/p' | head -n 1)" + if [ -z "$version" ]; then + printf 'latest release response did not include tag_name\n' >&2 + return 1 + fi + devloop_normalize_version "$version" +} + +devloop_upgrade_install() { + local latest="$1" + local current="$2" + local installer="$ROOT_DIR/scripts/install.remote.sh" + if [ ! -f "$installer" ]; then + printf 'error: remote installer not found: %s\n' "$installer" >&2 + return 1 + fi + printf 'upgrading devloop %s -> %s\n' "$current" "$latest" + bash "$installer" --yes --version "$latest" +} + +upgrade_command() { + local resolved latest current + if [ "$#" -gt 0 ]; then + case "$1" in + -h|--help) + printf '%s\n' "usage: devloop upgrade" + return 0 + ;; + *) + printf '%s\n' "usage: devloop upgrade" >&2 + return 2 + ;; + esac + fi + current="$(devloop_normalize_version "$DEVLOOP_VERSION" 2>/dev/null || printf '%s\n' "$DEVLOOP_VERSION")" + if ! resolved="$(devloop_resolve_latest_version 2>&1)"; then + printf '%s\n' "error: could not resolve latest Devloop release" >&2 + if [ -n "$resolved" ]; then printf '%s\n' "$resolved" >&2; fi + return 1 + fi + latest="$resolved" + if ! devloop_version_gt "$latest" "$current"; then + printf 'devloop %s is already latest\n' "$current" + return 0 + fi + devloop_upgrade_install "$latest" "$current" +} + +devloop_prompt_tty_ready() { + [ -t 0 ] && [ -t 1 ] +} + +maybe_prompt_upgrade() { + local resolved latest current + if [ "$DEVLOOP_UPGRADE_PROMPTED" = true ]; then return 0; fi + DEVLOOP_UPGRADE_PROMPTED=true + devloop_prompt_tty_ready || return 0 + current="$(devloop_normalize_version "$DEVLOOP_VERSION" 2>/dev/null || printf '%s\n' "$DEVLOOP_VERSION")" + resolved="$(devloop_resolve_latest_version 2>/dev/null)" || return 0 + latest="$resolved" + devloop_version_gt "$latest" "$current" || return 0 + printf 'upgrade available: devloop %s -> %s\n' "$current" "$latest" >&2 + if ui_confirm "Upgrade Devloop to $latest now?"; then + if ! devloop_upgrade_install "$latest" "$current"; then + UI_NOTICE="upgrade failed; continuing without upgrade" + fi + else + UI_NOTICE="upgrade skipped" + fi +} + trim_string() { printf '%s\n' "$1" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' } diff --git a/scripts/devloop_test.sh b/scripts/devloop_test.sh index 8f8f71f..a289c16 100755 --- a/scripts/devloop_test.sh +++ b/scripts/devloop_test.sh @@ -56,6 +56,7 @@ contains "$help" "devloop doctor" "help" contains "$help" "devloop reports" "help" contains "$help" "devloop status" "help" contains "$help" "devloop clean" "help" +contains "$help" "devloop upgrade" "help" contains "$help" "--create-pr" "help" contains "$help" "draft PR during the loop" "help" contains "$help" "--no-shell" "help" @@ -79,6 +80,7 @@ contains "$readme_text" "curl -fsSL https://devloop.sh/install | bash" "README r contains "$readme_text" "git clone https://github.com/satyaborg/devloop.git" "README source install" contains "$readme_text" "cd devloop" "README source install" contains "$readme_text" "./scripts/install.sh" "README source install" +contains "$readme_text" "\`devloop upgrade\`" "README command table" ok "README install docs" skill_path="$("$REPO_ROOT/devloop" spec --skill-path)" @@ -279,6 +281,7 @@ make_remote_release() { mkdir -p "$fixture/devloop-$version/scripts" "$release_dir" cp "$REPO_ROOT/devloop" "$fixture/devloop-$version/devloop" cp "$SCRIPTS_DIR/skill_helpers.sh" "$fixture/devloop-$version/scripts/skill_helpers.sh" + cp "$REMOTE_INSTALLER" "$fixture/devloop-$version/scripts/install.remote.sh" cp -R "$REPO_ROOT/skills" "$fixture/devloop-$version/skills" printf '%s\n' "$version" > "$fixture/devloop-$version/VERSION" tar -C "$fixture" -czf "$archive" "devloop-$version" @@ -334,7 +337,8 @@ USE_TUI="$old_use_tui" gum() { return 0; } old_use_tui="$USE_TUI" USE_TUI=true -welcome_tui >/dev/null +tui_help="$(welcome_tui)" +contains "$tui_help" "devloop upgrade" "TUI help" USE_TUI="$old_use_tui" unset -f gum @@ -419,6 +423,17 @@ equals "$(agent_choice_value "Claude Code")" "claude" "agent_choice_value" equals "$(parse_bool yes)" "true" "parse_bool true" equals "$(parse_bool 0)" "false" "parse_bool false" if parse_bool maybe >/dev/null 2>&1; then fail "parse_bool accepted invalid value"; fi +equals "$(devloop_normalize_version v1.2.3)" "1.2.3" "devloop version normalize" +equals "$(devloop_normalize_version 1.2.3-alpha.1+build.7)" "1.2.3-alpha.1+build.7" "devloop version normalize prerelease" +if devloop_normalize_version 01.2.3 >/dev/null 2>&1; then fail "devloop version accepted leading zero"; fi +devloop_version_gt 1.2.4 1.2.3 || fail "devloop version comparison rejected newer patch" +devloop_version_gt 1.3.0 1.2.9 || fail "devloop version comparison rejected newer minor" +devloop_version_gt 2.0.0 1.9.9 || fail "devloop version comparison rejected newer major" +devloop_version_gt 1.2.3 1.2.3-alpha.1 || fail "devloop version comparison rejected release over prerelease" +devloop_version_gt 1.2.3-alpha.2 1.2.3-alpha.1 || fail "devloop version comparison rejected prerelease identifier" +if devloop_version_gt 1.2.3 1.2.3; then fail "devloop version comparison accepted equal version"; fi +if devloop_version_gt 1.2.3-alpha.1 1.2.3; then fail "devloop version comparison accepted prerelease over release"; fi +if devloop_prompt_tty_ready; then fail "upgrade prompt tty check accepted non-tty test shell"; fi frontmatter_text=$'---\ntype: fix!\nslug: "Chat Retry"\nbreaking: true\nempty: null\n---\n# Title' equals "$(frontmatter_value type "$frontmatter_text")" "fix!" "frontmatter type" @@ -621,6 +636,27 @@ if ! menu_default_output="$( interactive_menu )"; then fail "menu default choice failed"; fi equals "$menu_default_output" "create" "menu starts with create spec" +if ! menu_dispatch_output="$( + maybe_prompt_upgrade() { printf '%s\n' "prompt"; } + interactive_menu() { printf '%s\n' "menu"; } + USE_TUI=true + main menu +)"; then fail "menu dispatch upgrade prompt failed"; fi +equals "$menu_dispatch_output" $'prompt\nmenu' "menu dispatch prompts before menu" +if ! no_arg_dispatch_output="$( + maybe_prompt_upgrade() { printf '%s\n' "prompt"; } + interactive_menu() { printf '%s\n' "menu"; } + USE_TUI=true + main +)"; then fail "no-arg dispatch upgrade prompt failed"; fi +equals "$no_arg_dispatch_output" $'prompt\nmenu' "no-arg dispatch prompts before menu" +if ! explicit_work_output="$( + maybe_prompt_upgrade() { printf '%s\n' "prompt"; } + run_command() { printf 'run'; } + USE_TUI=true + main "$criteria_file" +)"; then fail "explicit work dispatch failed"; fi +equals "$explicit_work_output" "run" "explicit work skips upgrade prompt" if ! create_spec_output="$( ui_choose() { printf '%s\n' "Codex"; } ui_input() { printf '%s\n' "unexpected input"; return 1; } @@ -834,6 +870,137 @@ for tool in gum fzf codex claude; do chmod +x "$remote_tool_bin/$tool" done remote_path="$remote_tool_bin:/usr/bin:/bin:/usr/sbin:/sbin" + +upgrade_root="$work/upgrade-root" +upgrade_bin="$work/upgrade-bin" +upgrade_home="$work/upgrade-home" +if ! upgrade_output="$( + HOME="$upgrade_home" PATH="$remote_path" DEVLOOP_GITHUB_API_URL="file://$latest_api_file" DEVLOOP_RELEASE_BASE_URL="$remote_release_base" DEVLOOP_INSTALL_DIR="$upgrade_root" DEVLOOP_BIN_DIR="$upgrade_bin" \ + main upgrade 2>&1 +)"; then + printf '%s\n' "$upgrade_output" >&2 + fail "devloop upgrade failed" +fi +contains "$upgrade_output" "upgrading devloop $version -> $remote_version" "devloop upgrade" +contains "$upgrade_output" "verified checksum" "devloop upgrade checksum" +contains "$upgrade_output" "devloop $remote_version installed" "devloop upgrade install" +[[ -L "$upgrade_bin/devloop" ]] || fail "devloop upgrade did not create symlink" +equals "$(readlink "$upgrade_bin/devloop")" "$upgrade_root/$remote_version/devloop" "devloop upgrade symlink target" +equals "$("$upgrade_bin/devloop" --version)" "devloop $remote_version" "devloop upgrade installed version" +[[ -f "$upgrade_home/.agents/skills/devloop-review/.devloop-checksum" ]] || fail "devloop upgrade did not install Codex skills" +[[ -f "$upgrade_home/.claude/skills/devloop-review/.devloop-checksum" ]] || fail "devloop upgrade did not install Claude skills" +ok "devloop upgrade installs newer release" + +current_api_file="$work/current-release.json" +printf '{"tag_name":"v%s"}\n' "$version" > "$current_api_file" +current_upgrade_root="$work/current-upgrade-root" +current_upgrade_bin="$work/current-upgrade-bin" +if ! current_upgrade_output="$( + HOME="$work/current-upgrade-home" PATH="$remote_path" DEVLOOP_GITHUB_API_URL="file://$current_api_file" DEVLOOP_RELEASE_BASE_URL="$remote_release_base" DEVLOOP_INSTALL_DIR="$current_upgrade_root" DEVLOOP_BIN_DIR="$current_upgrade_bin" \ + main upgrade 2>&1 +)"; then + printf '%s\n' "$current_upgrade_output" >&2 + fail "devloop upgrade current version failed" +fi +contains "$current_upgrade_output" "devloop $version is already latest" "devloop upgrade current" +[[ ! -e "$current_upgrade_bin/devloop" ]] || fail "devloop upgrade reinstalled already-current version" +ok "devloop upgrade already current" + +if resolver_failure_output="$( + HOME="$work/resolver-failure-home" PATH="$remote_path" DEVLOOP_GITHUB_API_URL="file://$work/missing-latest.json" DEVLOOP_RELEASE_BASE_URL="$remote_release_base" DEVLOOP_INSTALL_DIR="$work/resolver-failure-root" DEVLOOP_BIN_DIR="$work/resolver-failure-bin" \ + main upgrade 2>&1 +)"; then + printf '%s\n' "$resolver_failure_output" >&2 + fail "devloop upgrade accepted missing latest version" +fi +contains "$resolver_failure_output" "could not resolve latest Devloop release" "devloop upgrade resolver failure" +[[ ! -e "$work/resolver-failure-bin/devloop" ]] || fail "resolver failure created devloop symlink" +ok "devloop upgrade resolver failure" + +prompt_accept_root="$work/prompt-accept-root" +prompt_accept_bin="$work/prompt-accept-bin" +prompt_accept_home="$work/prompt-accept-home" +if ! prompt_accept_output="$( + devloop_prompt_tty_ready() { return 0; } + ui_confirm() { return 0; } + interactive_menu() { printf '%s\n' "menu after prompt"; } + DEVLOOP_UPGRADE_PROMPTED=false + USE_TUI=true + HOME="$prompt_accept_home" PATH="$remote_path" DEVLOOP_GITHUB_API_URL="file://$latest_api_file" DEVLOOP_RELEASE_BASE_URL="$remote_release_base" DEVLOOP_INSTALL_DIR="$prompt_accept_root" DEVLOOP_BIN_DIR="$prompt_accept_bin" \ + main menu 2>&1 +)"; then + printf '%s\n' "$prompt_accept_output" >&2 + fail "automatic upgrade prompt accept failed" +fi +contains "$prompt_accept_output" "upgrade available: devloop $version -> $remote_version" "automatic upgrade prompt accept" +contains "$prompt_accept_output" "menu after prompt" "automatic upgrade prompt accept menu" +equals "$(readlink "$prompt_accept_bin/devloop")" "$prompt_accept_root/$remote_version/devloop" "automatic upgrade prompt accept symlink" +ok "automatic upgrade prompt accept" + +prompt_decline_root="$work/prompt-decline-root" +prompt_decline_bin="$work/prompt-decline-bin" +mkdir -p "$prompt_decline_bin" +ln -s "$REPO_ROOT/devloop" "$prompt_decline_bin/devloop" +prompt_decline_target="$(readlink "$prompt_decline_bin/devloop")" +if ! prompt_decline_output="$( + devloop_prompt_tty_ready() { return 0; } + ui_confirm() { return 1; } + interactive_menu() { printf '%s\n' "menu after decline"; } + DEVLOOP_UPGRADE_PROMPTED=false + USE_TUI=true + HOME="$work/prompt-decline-home" PATH="$remote_path" DEVLOOP_GITHUB_API_URL="file://$latest_api_file" DEVLOOP_RELEASE_BASE_URL="$remote_release_base" DEVLOOP_INSTALL_DIR="$prompt_decline_root" DEVLOOP_BIN_DIR="$prompt_decline_bin" \ + main menu 2>&1 +)"; then + printf '%s\n' "$prompt_decline_output" >&2 + fail "automatic upgrade prompt decline failed" +fi +contains "$prompt_decline_output" "upgrade available: devloop $version -> $remote_version" "automatic upgrade prompt decline" +contains "$prompt_decline_output" "menu after decline" "automatic upgrade prompt decline menu" +equals "$(readlink "$prompt_decline_bin/devloop")" "$prompt_decline_target" "automatic upgrade prompt decline symlink" +[[ ! -e "$prompt_decline_root/$remote_version" ]] || fail "automatic upgrade prompt decline installed release" +ok "automatic upgrade prompt decline" + +prompt_skip_root="$work/prompt-skip-root" +prompt_skip_bin="$work/prompt-skip-bin" +if ! prompt_skip_output="$( + DEVLOOP_UPGRADE_PROMPTED=false + HOME="$work/prompt-skip-home" PATH="$remote_path" DEVLOOP_GITHUB_API_URL="file://$latest_api_file" DEVLOOP_RELEASE_BASE_URL="$remote_release_base" DEVLOOP_INSTALL_DIR="$prompt_skip_root" DEVLOOP_BIN_DIR="$prompt_skip_bin" \ + maybe_prompt_upgrade 2>&1 +)"; then + printf '%s\n' "$prompt_skip_output" >&2 + fail "automatic upgrade prompt non-tty skip failed" +fi +equals "$prompt_skip_output" "" "automatic upgrade prompt non-tty skip" +[[ ! -e "$prompt_skip_bin/devloop" ]] || fail "automatic upgrade prompt non-tty skip installed release" + +if ! prompt_current_output="$( + devloop_prompt_tty_ready() { return 0; } + ui_confirm() { printf '%s\n' "unexpected prompt"; return 1; } + DEVLOOP_UPGRADE_PROMPTED=false + HOME="$work/prompt-current-home" PATH="$remote_path" DEVLOOP_GITHUB_API_URL="file://$current_api_file" DEVLOOP_RELEASE_BASE_URL="$remote_release_base" DEVLOOP_INSTALL_DIR="$work/prompt-current-root" DEVLOOP_BIN_DIR="$work/prompt-current-bin" \ + maybe_prompt_upgrade 2>&1 +)"; then + printf '%s\n' "$prompt_current_output" >&2 + fail "automatic upgrade prompt current skip failed" +fi +equals "$prompt_current_output" "" "automatic upgrade prompt current skip" + +if ! prompt_failure_output="$( + devloop_prompt_tty_ready() { return 0; } + ui_confirm() { printf '%s\n' "unexpected prompt"; return 1; } + interactive_menu() { printf '%s\n' "menu after failed check"; } + DEVLOOP_UPGRADE_PROMPTED=false + USE_TUI=true + HOME="$work/prompt-failure-home" PATH="$remote_path" DEVLOOP_GITHUB_API_URL="file://$work/missing-prompt-latest.json" DEVLOOP_RELEASE_BASE_URL="$remote_release_base" DEVLOOP_INSTALL_DIR="$work/prompt-failure-root" DEVLOOP_BIN_DIR="$work/prompt-failure-bin" \ + main menu 2>&1 +)"; then + printf '%s\n' "$prompt_failure_output" >&2 + fail "automatic upgrade prompt resolver skip failed" +fi +contains "$prompt_failure_output" "menu after failed check" "automatic upgrade prompt resolver skip" +not_contains "$prompt_failure_output" "unexpected prompt" "automatic upgrade prompt resolver skip" +ok "automatic upgrade prompt skip paths" + remote_home="$work/remote-home" remote_install_output="$( HOME="$remote_home" PATH="$remote_path" bash "$REMOTE_INSTALLER" \