diff --git a/AGENTS.md b/AGENTS.md index 9a10010..95e9bc4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,14 +2,14 @@ ## Project Structure & Module Organization -This is a Bash CLI project. The active runtime is the root `devloop` executable. `VERSION` is the single version source, `release.sh` cuts local release commits and annotated tags, `install.sh` links the CLI into a local bin directory and installs bundled skills into `~/.agents/skills` and `~/.claude/skills`, `tests/devloop_test.sh` covers the shell runtime, `skills/devloop-spec/SKILL.md` is the spec-generation skill, `skills/devloop-review/SKILL.md` is the review skill, and `skills/devloop-spec/references/spec-template.md` is the starter spec. Generated runtime output belongs under `.codex/` in target repositories and should not be committed here. +This is a Bash CLI project. The active runtime is the root `devloop` executable. `VERSION` is the single version source, `scripts/release.sh` cuts local release commits and annotated tags, `scripts/install.sh` links the CLI into a local bin directory and installs bundled skills into `~/.agents/skills` and `~/.claude/skills`, `scripts/devloop_test.sh` covers the shell runtime, `skills/devloop-spec/SKILL.md` is the spec-generation skill, `skills/devloop-review/SKILL.md` is the review skill, and `skills/devloop-spec/references/spec-template.md` is the starter spec. Generated runtime output belongs under `.codex/` in target repositories and should not be committed here. ## Build, Test, and Development Commands -- `bash tests/devloop_test.sh`: run the shell test suite. -- `./install.sh`: link `devloop` into `~/.local/bin` or `DEVLOOP_BIN_DIR`. +- `bash scripts/devloop_test.sh`: run the shell test suite. +- `./scripts/install.sh`: link `devloop` into `~/.local/bin` or `DEVLOOP_BIN_DIR`. - `./devloop --plain .specs/change.md`: example local CLI invocation from a target git worktree. -- `./release.sh patch --dry-run`: validate the release path without changing files. +- `./scripts/release.sh patch --dry-run`: validate the release path without changing files. ## Coding Style & Naming Conventions @@ -17,11 +17,11 @@ Use readable Bash with small named functions, quoted expansions, explicit status ## Testing Guidelines -Tests use `tests/devloop_test.sh`. Keep behavior fixture-style where possible: assert generated files, git state, status codes, and user-visible output. +Tests use `scripts/devloop_test.sh`. Keep behavior fixture-style where possible: assert generated files, git state, status codes, and user-visible output. ## Commit & Pull Request Guidelines -Git history follows Conventional Commits, for example `fix: surface devloop commit failures` and `chore: cover shell runtime`. Use concise subjects in imperative style and keep unrelated changes in separate commits. Pull requests should include a short problem summary, the implementation approach, and test evidence (`bash tests/devloop_test.sh`). +Git history follows Conventional Commits, for example `fix: surface devloop commit failures` and `chore: cover shell runtime`. Use concise subjects in imperative style and keep unrelated changes in separate commits. Pull requests should include a short problem summary, the implementation approach, and test evidence (`bash scripts/devloop_test.sh`). ## Agent-Specific Instructions diff --git a/README.md b/README.md index 8a7b03f..0668bf4 100644 --- a/README.md +++ b/README.md @@ -8,16 +8,48 @@ By default, Codex makes the change, Claude Code reviews it, and Codex retries un ## Install +Install Devloop with the remote installer: + +```sh +curl -fsSL https://devloop.sh/install | bash +``` + +The installer downloads a tagged GitHub release asset, verifies its `.sha256` checksum, installs it under `~/.local/share/devloop//`, links `~/.local/bin/devloop`, and installs the bundled Agent Skills for Codex under `~/.agents/skills` and Claude Code under `~/.claude/skills`. + +Install a specific version or preview the install: + +```sh +curl -fsSL https://devloop.sh/install | bash -s -- --version 0.2.0 +curl -fsSL https://devloop.sh/install | bash -s -- --dry-run +curl -fsSL https://devloop.sh/install | bash -s -- --no-skills +``` + +Inspect before running: + +```sh +curl -fsSL https://devloop.sh/install -o install.sh +less install.sh +bash install.sh +``` + +The remote installer checks for `gum`, `fzf`, `codex`, and `claude`. It does not install Codex or Claude Code. If `gum` or `fzf` is missing, it only installs them with Homebrew after explicit confirmation; otherwise it prints the `brew install gum fzf` command to run yourself. + +If `~/.local/bin` is not on `PATH`, the installer prints the shell profile line to add. It does not edit shell profile files. + Required dependencies: Bash, git, Homebrew, `codex`, `claude`, `gum`, and `fzf`. -`install.sh` installs missing `gum` and `fzf` with Homebrew. Install the Codex and Claude Code CLIs before running a loop, then verify everything with `devloop doctor`. +`scripts/install.sh` installs missing `gum` and `fzf` with Homebrew. Install the Codex and Claude Code CLIs before running a loop, then verify everything with `devloop doctor`. The GitHub CLI is optional. Install `gh` and run `gh auth login` only when you want PR-backed loops. +For source checkout development, use the local installer: + ```sh git clone https://github.com/satyaborg/devloop.git cd devloop -./install.sh +./scripts/install.sh ``` +`./scripts/install.sh` symlinks the checkout executable, installs missing `gum` and `fzf` with Homebrew when available, installs bundled skills, and finishes with `try: devloop doctor`. + Check the installed version: ```sh @@ -30,14 +62,21 @@ Run without installing: ./devloop --help ``` -`install.sh` also installs the bundled Agent Skills globally for Codex under `~/.agents/skills` -and Claude Code under `~/.claude/skills`. After install or update, verify the local setup: ```sh devloop doctor ``` +Uninstall a remote install: + +```sh +rm -f ~/.local/bin/devloop +rm -rf ~/.local/share/devloop +rm -rf ~/.agents/skills/devloop-spec ~/.agents/skills/devloop-review +rm -rf ~/.claude/skills/devloop-spec ~/.claude/skills/devloop-review +``` + ## Quick Start Create a spec: @@ -146,7 +185,7 @@ When stdout is a terminal, running `devloop` without arguments opens a menu: Nested menu screens keep `Back` as the final option, and Esc/cancel also returns to the previous menu without exiting Devloop. Interactive screens redraw in place instead of appending a fresh UI after each selection. -`gum` powers the branded help screen, prompts, confirmations, status output, paging, and setup screens. `fzf` powers searchable pickers for specs, tracks, and reports. Both are required and installed by `install.sh` when missing. +`gum` powers the branded help screen, prompts, confirmations, status output, paging, and setup screens. `fzf` powers searchable pickers for specs, tracks, and reports. Both are required and installed by `scripts/install.sh` when missing. ## What Devloop Does @@ -178,18 +217,18 @@ If present, `.devloop/verify` is executed from the run worktree with the pass nu ## Development ```sh -bash -n devloop install.sh release.sh skill_helpers.sh -shellcheck devloop install.sh skill_helpers.sh release.sh tests/devloop_test.sh -bash tests/devloop_test.sh +bash -n devloop scripts/install.sh scripts/release.sh scripts/skill_helpers.sh scripts/install.remote.sh scripts/devloop_test.sh +shellcheck devloop scripts/install.sh scripts/skill_helpers.sh scripts/release.sh scripts/install.remote.sh scripts/devloop_test.sh +bash scripts/devloop_test.sh ./devloop --help ./devloop --version tmp="$(mktemp -d)" -DEVLOOP_BIN_DIR="$tmp/bin" HOME="$tmp/home" ./install.sh +DEVLOOP_BIN_DIR="$tmp/bin" HOME="$tmp/home" ./scripts/install.sh PATH="$tmp/bin:$PATH" HOME="$tmp/home" devloop doctor ``` The supported runtime is the root [`devloop`](devloop) Bash script. -The shell suite enforces 100% project function coverage for `devloop`, `skill_helpers.sh`, and `release.sh`. +The shell suite enforces 100% project function coverage for `devloop`, `scripts/skill_helpers.sh`, and `scripts/release.sh`. ## Versioning and Release @@ -205,11 +244,11 @@ brew install gh Cut a release from a clean tree by choosing the bump: ```sh -./release.sh patch --dry-run -./release.sh patch --publish +./scripts/release.sh patch --dry-run +./scripts/release.sh patch --publish ``` -Use `patch`, `minor`, or `major`. The script reads the current [`VERSION`](VERSION), computes the next SemVer version, updates `VERSION` and [`CHANGELOG.md`](CHANGELOG.md), runs `bash tests/devloop_test.sh`, commits `chore: release `, and creates an annotated `v` tag. Add `--publish` to push the release branch and tag, then create the GitHub Release. Use `--push` only when you want to publish the git refs without creating a GitHub Release. By default, published releases must run from `main`. +Use `patch`, `minor`, or `major`. The script reads the current [`VERSION`](VERSION), computes the next SemVer version, updates `VERSION` and [`CHANGELOG.md`](CHANGELOG.md), runs `bash scripts/devloop_test.sh`, commits `chore: release `, and creates an annotated `v` tag. Add `--publish` to push the release branch and tag, then create the GitHub Release. Use `--push` only when you want to publish the git refs without creating a GitHub Release. By default, published releases must run from `main`. ## License diff --git a/devloop b/devloop index e8d5b76..090be34 100755 --- a/devloop +++ b/devloop @@ -18,7 +18,7 @@ while [ -L "$SCRIPT_PATH" ]; do esac done ROOT_DIR="$(cd -P "$(dirname "$SCRIPT_PATH")" >/dev/null 2>&1 && pwd)" -source "$ROOT_DIR/skill_helpers.sh" +source "$ROOT_DIR/scripts/skill_helpers.sh" DEVLOOP_VERSION="$(sed -n '1p' "$ROOT_DIR/VERSION" 2>/dev/null || true)" if [ -z "$DEVLOOP_VERSION" ]; then DEVLOOP_VERSION="0.0.0-dev"; fi diff --git a/tests/devloop_test.sh b/scripts/devloop_test.sh similarity index 85% rename from tests/devloop_test.sh rename to scripts/devloop_test.sh index b5afe14..4a9354a 100755 --- a/tests/devloop_test.sh +++ b/scripts/devloop_test.sh @@ -1,7 +1,10 @@ #!/usr/bin/env bash +# shellcheck disable=SC2030,SC2031 set -euo pipefail REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +SCRIPTS_DIR="$REPO_ROOT/scripts" +REMOTE_INSTALLER="$SCRIPTS_DIR/install.remote.sh" fail() { echo "not ok - $*" >&2 @@ -33,7 +36,7 @@ equals() { [[ "$actual" == "$expected" ]] || fail "$label expected [$expected], got [$actual]" } -bash -n "$REPO_ROOT/devloop" "$REPO_ROOT/install.sh" "$REPO_ROOT/skill_helpers.sh" "$REPO_ROOT/release.sh" +bash -n "$REPO_ROOT/devloop" "$SCRIPTS_DIR/install.sh" "$SCRIPTS_DIR/skill_helpers.sh" "$SCRIPTS_DIR/release.sh" "$REMOTE_INSTALLER" ok "bash syntax" DEVLOOP_LIB=1 @@ -61,6 +64,28 @@ contains "$help" "--version" "help" contains "$help" "--timeout-minutes" "help" ok "help output" +remote_help="$("$REMOTE_INSTALLER" --help)" +contains "$remote_help" "curl -fsSL https://devloop.sh/install | bash" "remote installer help" +contains "$remote_help" "--yes" "remote installer help" +contains "$remote_help" "--version " "remote installer help" +contains "$remote_help" "--no-skills" "remote installer help" +contains "$remote_help" "--dry-run" "remote installer help" +contains "$remote_help" "--install-dir " "remote installer help" +contains "$remote_help" "--bin-dir " "remote installer help" +ok "remote installer help output" + +readme_text="$(cat "$REPO_ROOT/README.md")" +tilde_marker="~" +contains "$readme_text" "curl -fsSL https://devloop.sh/install | bash" "README remote install" +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" "rm -f ~/.local/bin/devloop" "README uninstall" +contains "$readme_text" "rm -rf ~/.local/share/devloop" "README uninstall" +contains "$readme_text" "$tilde_marker/.agents/skills/devloop-spec" "README uninstall" +contains "$readme_text" "$tilde_marker/.claude/skills/devloop-review" "README uninstall" +ok "README install and uninstall docs" + skill_path="$("$REPO_ROOT/devloop" spec --skill-path)" [[ "$skill_path" == "$REPO_ROOT/skills/devloop-spec/SKILL.md" ]] || fail "unexpected skill path: $skill_path" contains "$("$REPO_ROOT/devloop" spec --print-skill)" "name: devloop-spec" "spec skill" @@ -94,11 +119,26 @@ ok "skill metadata" work=$(mktemp -d "${TMPDIR:-/tmp}/devloop-test.XXXXXX") trap 'rm -rf "$work"' EXIT +make_remote_release() { + local version="$1" + local releases="$2" + local fixture="$work/remote-release-src-$version" + local release_dir="$releases/v$version" + local archive="$release_dir/devloop-$version.tar.gz" + 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 -R "$REPO_ROOT/skills" "$fixture/devloop-$version/skills" + printf '%s\n' "$version" > "$fixture/devloop-$version/VERSION" + tar -C "$fixture" -czf "$archive" "devloop-$version" + printf '%s %s\n' "$(devloop_checksum_file "$archive")" "devloop-$version.tar.gz" > "$archive.sha256" +} + coverage_functions="$work/project-functions.txt" coverage_hits="$work/project-function-hits.txt" coverage_set="" sed -nE 's/^([[:alpha:]_][[:alnum:]_]*)\(\)[[:space:]]*\{/\1/p' \ - "$REPO_ROOT/devloop" "$REPO_ROOT/skill_helpers.sh" "$REPO_ROOT/release.sh" | + "$REPO_ROOT/devloop" "$SCRIPTS_DIR/skill_helpers.sh" "$SCRIPTS_DIR/release.sh" | LC_ALL=C sort -u > "$coverage_functions" while IFS= read -r fn; do coverage_set="${coverage_set}|${fn}|" @@ -469,18 +509,29 @@ ok "pure helpers" ( DEVLOOP_RELEASE_LIB=1 - source "$REPO_ROOT/release.sh" - contains "$(release_usage)" "usage: ./release.sh" "release usage" + export DEVLOOP_RELEASE_LIB + # shellcheck disable=SC1091 + source "$SCRIPTS_DIR/release.sh" + contains "$(release_usage)" "usage: ./scripts/release.sh" "release usage" release_version_valid "0.1.0" || fail "release version rejected valid patch" release_version_valid "1.2.3-alpha.1+build.7" || fail "release version rejected valid prerelease" if release_version_valid "01.2.3"; then fail "release version accepted leading zero"; fi if release_version_valid "1.2"; then fail "release version accepted missing patch"; fi if release_version_valid "1.2.3-alpha.01"; then fail "release version accepted leading zero prerelease"; fi equals "$(release_tag_for_version "1.2.3")" "v1.2.3" "release tag" + release_artifact_dir="$work/release-artifacts" + release_create_artifacts "$version" "$release_artifact_dir" + [[ -f "$RELEASE_ARCHIVE" ]] || fail "release archive was not created" + [[ -f "$RELEASE_CHECKSUM" ]] || fail "release checksum was not created" + contains "$(tar -tzf "$RELEASE_ARCHIVE")" "devloop-$version/devloop" "release archive" + contains "$(tar -tzf "$RELEASE_ARCHIVE")" "devloop-$version/scripts/devloop_test.sh" "release archive" + contains "$(tar -tzf "$RELEASE_ARCHIVE")" "devloop-$version/scripts/install.remote.sh" "release archive" + equals "$(awk '{print $1; exit}' "$RELEASE_CHECKSUM")" "$(release_checksum_file "$RELEASE_ARCHIVE")" "release checksum" equals "$(release_next_version patch "0.1.0")" "0.1.1" "patch bump" equals "$(release_next_version minor "0.1.0")" "0.2.0" "minor bump" equals "$(release_next_version major "0.1.0")" "1.0.0" "major bump" if release_next_version patch "0.1.0-alpha.1" >/dev/null 2>&1; then fail "release bump accepted prerelease"; fi + # shellcheck disable=SC2329 release_require_command() { if [ "$1" = "git-cliff" ]; then return 1; fi return 0 @@ -494,6 +545,7 @@ ok "pure helpers" contains "$dry_run_output" "would tag: v9.9.10" "release dry-run" publish_dry_run_output="$(release_main "patch" --publish --dry-run)" || fail "release publish dry-run required git-cliff" contains "$publish_dry_run_output" "would push branch and tag" "release publish dry-run" + contains "$publish_dry_run_output" "would build release assets: devloop-9.9.10.tar.gz and devloop-9.9.10.tar.gz.sha256" "release publish dry-run" contains "$publish_dry_run_output" "would create GitHub release: gh release create v9.9.10 --verify-tag --generate-notes" "release publish dry-run" git -C "$ROOT" config user.email devloop-test@example.com git -C "$ROOT" config user.name "devloop test" @@ -508,6 +560,141 @@ ok "pure helpers" ) ok "release helpers" +remote_version="9.8.7" +remote_releases="$work/remote-releases" +make_remote_release "$remote_version" "$remote_releases" +remote_release_base="file://$remote_releases" +remote_no_tools="$work/remote-no-tools" +mkdir -p "$remote_no_tools" +remote_no_tools_path="$remote_no_tools:/usr/bin:/bin:/usr/sbin:/sbin" + +remote_custom_root="$work/remote-custom-root" +remote_custom_bin="$work/remote-custom-bin" +remote_dry_output="$( + HOME="$work/remote-dry-home" PATH="$remote_no_tools_path" bash "$REMOTE_INSTALLER" \ + --dry-run \ + --version "$remote_version" \ + --install-dir "$remote_custom_root" \ + --bin-dir "$remote_custom_bin" \ + --release-base-url "$remote_release_base" \ + 2>&1 +)" +contains "$remote_dry_output" "dry run: no files will be changed" "remote dry run" +contains "$remote_dry_output" "version: $remote_version" "remote dry run version" +contains "$remote_dry_output" "download: $remote_release_base/v$remote_version/devloop-$remote_version.tar.gz" "remote dry run download" +contains "$remote_dry_output" "verify: $remote_release_base/v$remote_version/devloop-$remote_version.tar.gz.sha256" "remote dry run checksum" +contains "$remote_dry_output" "install: $remote_custom_root/$remote_version" "remote dry run install dir" +contains "$remote_dry_output" "link: $remote_custom_bin/devloop -> $remote_custom_root/$remote_version/devloop" "remote dry run bin dir" +contains "$remote_dry_output" "skills: $work/remote-dry-home/.agents/skills, $work/remote-dry-home/.claude/skills" "remote dry run skills" +contains "$remote_dry_output" "missing UI tools: gum fzf" "remote missing UI guidance" +contains "$remote_dry_output" "install with: brew install gum fzf" "remote missing UI guidance" +contains "$remote_dry_output" "missing agent CLIs: codex claude" "remote missing agent guidance" +contains "$remote_dry_output" "Devloop does not install codex or claude automatically." "remote missing agent guidance" +[[ ! -e "$remote_custom_root" ]] || fail "remote dry run created install root" +[[ ! -e "$remote_custom_bin" ]] || fail "remote dry run created bin dir" +ok "remote installer dry run" + +latest_api_file="$work/latest-release.json" +latest_tool_path="$work/latest-tool-path" +test_bash="$(command -v bash)" +mkdir -p "$latest_tool_path" +for tool in grep sed head; do + ln -s "$(command -v "$tool")" "$latest_tool_path/$tool" +done +printf '{"tag_name":"v%s"}\n' "$remote_version" > "$latest_api_file" +if ! remote_latest_output="$( + HOME="$work/remote-latest-home" PATH="$latest_tool_path" DEVLOOP_GITHUB_API_URL="file://$latest_api_file" "$test_bash" "$REMOTE_INSTALLER" \ + --dry-run \ + --release-base-url "$remote_release_base" \ + 2>&1 +)"; then + printf '%s\n' "$remote_latest_output" >&2 + fail "remote latest version dry run failed" +fi +contains "$remote_latest_output" "version: $remote_version" "remote latest version" +contains "$remote_latest_output" "download: $remote_release_base/v$remote_version/devloop-$remote_version.tar.gz" "remote latest version" +ok "remote installer latest version resolution" + +tampered_version="9.8.8" +tampered_releases="$work/tampered-releases" +make_remote_release "$tampered_version" "$tampered_releases" +tampered_archive="$tampered_releases/v$tampered_version/devloop-$tampered_version.tar.gz" +printf '%064d %s\n' 0 "devloop-$tampered_version.tar.gz" > "$tampered_archive.sha256" +tampered_home="$work/tampered-home" +if tampered_output="$( + HOME="$tampered_home" PATH="/usr/bin:/bin:/usr/sbin:/sbin" bash "$REMOTE_INSTALLER" \ + --yes \ + --version "$tampered_version" \ + --release-base-url "file://$tampered_releases" \ + 2>&1 +)"; then + printf '%s\n' "$tampered_output" >&2 + fail "remote installer accepted checksum mismatch" +fi +contains "$tampered_output" "checksum mismatch" "remote checksum mismatch" +[[ ! -e "$tampered_home/.local/share/devloop/$tampered_version" ]] || fail "checksum mismatch created install dir" +[[ ! -e "$tampered_home/.local/bin/devloop" ]] || fail "checksum mismatch created devloop symlink" +ok "remote installer rejects checksum mismatch" + +remote_tool_bin="$work/remote-tool-bin" +mkdir -p "$remote_tool_bin" +for tool in gum fzf codex claude; do + printf '%s\n' '#!/usr/bin/env bash' 'exit 0' > "$remote_tool_bin/$tool" + chmod +x "$remote_tool_bin/$tool" +done +remote_path="$remote_tool_bin:/usr/bin:/bin:/usr/sbin:/sbin" +remote_home="$work/remote-home" +remote_install_output="$( + HOME="$remote_home" PATH="$remote_path" bash "$REMOTE_INSTALLER" \ + --yes \ + --version "$remote_version" \ + --release-base-url "$remote_release_base" \ + 2>&1 +)" +remote_default_root="$remote_home/.local/share/devloop" +remote_default_bin="$remote_home/.local/bin" +[[ -d "$remote_default_root/$remote_version" ]] || fail "remote installer did not create versioned install dir" +[[ -L "$remote_default_bin/devloop" ]] || fail "remote installer did not create devloop symlink" +equals "$(readlink "$remote_default_bin/devloop")" "$remote_default_root/$remote_version/devloop" "remote installer symlink target" +equals "$("$remote_default_bin/devloop" --version)" "devloop $remote_version" "remote installed version" +contains "$remote_install_output" "verified checksum" "remote install checksum" +contains "$remote_install_output" "$remote_default_bin is not on PATH" "remote install PATH guidance" +contains "$remote_install_output" "export PATH=\"$remote_default_bin:\$PATH\"" "remote install PATH guidance" +contains "$remote_install_output" "[ok] gum:" "remote install UI check" +contains "$remote_install_output" "[ok] codex:" "remote install agent check" +[[ -f "$remote_home/.agents/skills/devloop-spec/SKILL.md" ]] || fail "remote installer did not install Codex spec skill" +[[ -f "$remote_home/.agents/skills/devloop-review/.devloop-checksum" ]] || fail "remote installer did not write Codex skill checksum" +[[ -f "$remote_home/.claude/skills/devloop-spec/SKILL.md" ]] || fail "remote installer did not install Claude spec skill" +[[ -f "$remote_home/.claude/skills/devloop-review/.devloop-checksum" ]] || fail "remote installer did not write Claude skill checksum" +ok "remote installer successful install" + +printf '%s\n' "user edit" >> "$remote_home/.agents/skills/devloop-review/SKILL.md" +remote_preserve_output="$( + HOME="$remote_home" PATH="$remote_path" bash "$REMOTE_INSTALLER" \ + --yes \ + --version "$remote_version" \ + --release-base-url "$remote_release_base" \ + 2>&1 +)" +contains "$remote_preserve_output" "skipping modified skill" "remote installer modified skill guard" +contains "$(cat "$remote_home/.agents/skills/devloop-review/SKILL.md")" "user edit" "remote installer modified skill preserved" +ok "remote installer skill preservation" + +remote_no_skills_home="$work/remote-no-skills-home" +remote_no_skills_output="$( + HOME="$remote_no_skills_home" PATH="$remote_path" bash "$REMOTE_INSTALLER" \ + --yes \ + --no-skills \ + --version "$remote_version" \ + --release-base-url "$remote_release_base" \ + 2>&1 +)" +contains "$remote_no_skills_output" "skipping skill installation" "remote no-skills" +contains "$remote_no_skills_output" "devloop doctor will require skill installation before agent loops are ready." "remote no-skills" +[[ ! -e "$remote_no_skills_home/.agents/skills/devloop-spec" ]] || fail "remote no-skills installed Codex skill" +[[ ! -e "$remote_no_skills_home/.claude/skills/devloop-review" ]] || fail "remote no-skills installed Claude skill" +ok "remote installer no-skills" + bin_dir="$work/bin" install_home="$work/install-home" tool_bin="$work/tool-bin" @@ -530,7 +717,7 @@ done BREW chmod +x "$tool_bin/brew" install_path="$tool_bin:/usr/bin:/bin:/usr/sbin:/sbin" -DEVLOOP_BIN_DIR="$bin_dir" HOME="$install_home" PATH="$install_path" "$REPO_ROOT/install.sh" >/tmp/devloop-install-test.out +DEVLOOP_BIN_DIR="$bin_dir" HOME="$install_home" PATH="$install_path" "$SCRIPTS_DIR/install.sh" >/tmp/devloop-install-test.out [[ -x "$REPO_ROOT/devloop" ]] || fail "devloop is not executable" [[ -L "$bin_dir/devloop" ]] || fail "installer did not create symlink" contains "$(cat /tmp/devloop-install-test.out)" "gh auth login" "installer optional gh auth" @@ -548,11 +735,11 @@ contains "$(cat /tmp/devloop-help-test.out)" "Spec-driven code and review loop." ok "installer" printf '%s\n' "user edit" >> "$install_home/.agents/skills/devloop-review/SKILL.md" -DEVLOOP_BIN_DIR="$bin_dir" HOME="$install_home" PATH="$install_path" "$REPO_ROOT/install.sh" >/tmp/devloop-install-skip.out 2>&1 +DEVLOOP_BIN_DIR="$bin_dir" HOME="$install_home" PATH="$install_path" "$SCRIPTS_DIR/install.sh" >/tmp/devloop-install-skip.out 2>&1 contains "$(cat /tmp/devloop-install-skip.out)" "skipping modified skill" "installer modified skill guard" contains "$(cat /tmp/devloop-install-skip.out)" "try: devloop doctor" "installer guidance after skill skip" contains "$(cat "$install_home/.agents/skills/devloop-review/SKILL.md")" "user edit" "installer modified skill preserved" -DEVLOOP_FORCE=1 DEVLOOP_BIN_DIR="$bin_dir" HOME="$install_home" PATH="$install_path" "$REPO_ROOT/install.sh" >/tmp/devloop-install-force.out +DEVLOOP_FORCE=1 DEVLOOP_BIN_DIR="$bin_dir" HOME="$install_home" PATH="$install_path" "$SCRIPTS_DIR/install.sh" >/tmp/devloop-install-force.out if grep -q "user edit" "$install_home/.agents/skills/devloop-review/SKILL.md"; then fail "installer force did not restore skill"; fi ok "installer skill updates" diff --git a/scripts/install.remote.sh b/scripts/install.remote.sh new file mode 100755 index 0000000..7bb3a2e --- /dev/null +++ b/scripts/install.remote.sh @@ -0,0 +1,359 @@ +#!/usr/bin/env bash +set -euo pipefail + +GITHUB_REPO="${DEVLOOP_GITHUB_REPO:-satyaborg/devloop}" +RELEASE_BASE_URL="${DEVLOOP_RELEASE_BASE_URL:-https://github.com/$GITHUB_REPO/releases/download}" +GITHUB_API_URL="${DEVLOOP_GITHUB_API_URL:-https://api.github.com/repos/$GITHUB_REPO/releases/latest}" +INSTALL_ROOT="${DEVLOOP_INSTALL_DIR:-$HOME/.local/share/devloop}" +BIN_DIR="${DEVLOOP_BIN_DIR:-$HOME/.local/bin}" +VERSION="" +YES=false +NO_SKILLS=false +DRY_RUN=false + +usage() { + cat <<'EOF' +usage: install.remote.sh [options] + +Primary install: + curl -fsSL https://devloop.sh/install | bash + +Options: + --yes Run without optional prompts. + --version Install a specific tagged version, for example 0.2.0. + --no-skills Install only the devloop CLI. + --dry-run Print planned actions without changing files. + --install-dir Versioned install root. Default: ~/.local/share/devloop. + --bin-dir Directory for the devloop symlink. Default: ~/.local/bin. + --release-base-url + Release asset base URL. Default: GitHub releases. + -h, --help Show this help. +EOF +} + +fail() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +info() { + printf '%s\n' "$*" +} + +version_tag() { + printf 'v%s\n' "$1" +} + +asset_name() { + printf 'devloop-%s.tar.gz\n' "$1" +} + +artifact_url() { + local version="$1" + printf '%s/%s/%s\n' "$RELEASE_BASE_URL" "$(version_tag "$version")" "$(asset_name "$version")" +} + +checksum_url() { + local version="$1" + printf '%s.sha256\n' "$(artifact_url "$version")" +} + +download_file() { + local url="$1" + local dest="$2" + local source_path + + case "$url" in + file://*) + source_path="${url#file://}" + cp "$source_path" "$dest" + ;; + *) + if ! command -v curl >/dev/null 2>&1; then + fail "missing curl; install curl or download the release archive manually" + fi + curl -fsSL "$url" -o "$dest" + ;; + esac +} + +checksum_file() { + local file="$1" + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file" | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" | awk '{print $1}' + else + fail "missing sha256 tool; install shasum or sha256sum" + fi +} + +resolve_latest_version() { + local response version source_path + case "$GITHUB_API_URL" in + file://*) + source_path="${GITHUB_API_URL#file://}" + [ -f "$source_path" ] || fail "missing latest release fixture: $source_path" + response="$(<"$source_path")" + ;; + *) + if ! command -v curl >/dev/null 2>&1; then + fail "missing curl; pass --version or install curl" + fi + response="$(curl -fsSL "$GITHUB_API_URL")" || fail "failed to resolve latest Devloop release" + ;; + esac + version="$(printf '%s\n' "$response" | sed -nE 's/.*"tag_name"[[:space:]]*:[[:space:]]*"v?([^"]+)".*/\1/p' | head -n 1)" + if [ -z "$version" ]; then + fail "latest release response did not include tag_name" + fi + printf '%s\n' "$version" +} + +normalize_version() { + local version="$1" + version="${version#v}" + if ! printf '%s\n' "$version" | grep -Eq '^(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-]+)*))?$'; then + fail "invalid version: $1" + fi + printf '%s\n' "$version" +} + +parse_args() { + while [ "$#" -gt 0 ]; do + case "$1" in + --yes) YES=true ;; + --no-skills) NO_SKILLS=true ;; + --dry-run) DRY_RUN=true ;; + --version) + [ "$#" -ge 2 ] || fail "--version requires a value" + VERSION="$2" + shift + ;; + --install-dir) + [ "$#" -ge 2 ] || fail "--install-dir requires a value" + INSTALL_ROOT="$2" + shift + ;; + --bin-dir) + [ "$#" -ge 2 ] || fail "--bin-dir requires a value" + BIN_DIR="$2" + shift + ;; + --release-base-url) + [ "$#" -ge 2 ] || fail "--release-base-url requires a value" + RELEASE_BASE_URL="$2" + shift + ;; + -h|--help) + usage + exit 0 + ;; + --*) + fail "unknown option: $1" + ;; + *) + fail "unexpected argument: $1" + ;; + esac + shift + done +} + +missing_commands() { + local missing=() + local command_name + for command_name in "$@"; do + if ! command -v "$command_name" >/dev/null 2>&1; then + missing+=("$command_name") + fi + done + if [ "${#missing[@]}" -eq 0 ]; then + printf '\n' + return 0 + fi + printf '%s\n' "${missing[*]}" +} + +check_ui_tools() { + local missing_text="$1" + local missing_items=() + local reply + if [ -z "$missing_text" ]; then + info "[ok] gum: $(command -v gum)" + info "[ok] fzf: $(command -v fzf)" + return 0 + fi + + read -r -a missing_items <<< "$missing_text" + info "missing UI tools: $missing_text" + if command -v brew >/dev/null 2>&1; then + if [ "$YES" = false ] && [ -t 0 ]; then + printf 'Install missing UI tools with Homebrew now? [y/N] ' + read -r reply + case "$reply" in + y|Y|yes|YES) + brew install "${missing_items[@]}" + ;; + *) + info "install with: brew install $missing_text" + return 0 + ;; + esac + else + info "install with: brew install $missing_text" + return 0 + fi + else + info "install with: brew install $missing_text" + return 0 + fi + + missing_text="$(missing_commands gum fzf)" + if [ -n "$missing_text" ]; then + info "still missing UI tools: $missing_text" + else + info "[ok] gum: $(command -v gum)" + info "[ok] fzf: $(command -v fzf)" + fi +} + +check_agent_tools() { + local agent_missing_text="$1" + if [ -z "$agent_missing_text" ]; then + info "[ok] codex: $(command -v codex)" + info "[ok] claude: $(command -v claude)" + return 0 + fi + + info "missing agent CLIs: $agent_missing_text" + info "Devloop does not install codex or claude automatically." +} + +print_path_guidance() { + case ":${PATH:-}:" in + *":$BIN_DIR:"*) ;; + *) + info "" + info "$BIN_DIR is not on PATH. Add this to your shell profile:" + info "export PATH=\"$BIN_DIR:\$PATH\"" + ;; + esac +} + +verify_archive() { + local archive="$1" + local checksum="$2" + local expected actual + expected="$(awk '{print $1; exit}' "$checksum")" + [ -n "$expected" ] || fail "empty checksum file: $checksum" + actual="$(checksum_file "$archive")" + [ "$expected" = "$actual" ] || fail "checksum mismatch for $archive" + info "verified checksum" +} + +find_extracted_root() { + local extract_dir="$1" + local found + found="$(find "$extract_dir" -mindepth 1 -maxdepth 1 -type d | head -n 1)" + [ -n "$found" ] || fail "release archive did not contain an install directory" + [ -f "$found/devloop" ] || fail "release archive missing devloop executable" + [ -f "$found/scripts/skill_helpers.sh" ] || fail "release archive missing scripts/skill_helpers.sh" + [ -d "$found/skills" ] || fail "release archive missing bundled skills" + printf '%s\n' "$found" +} + +install_archive() { + local version="$1" + local archive="$2" + local target="$INSTALL_ROOT/$version" + local tmp extract_dir extracted_root + + tmp="$(mktemp -d "${TMPDIR:-/tmp}/devloop-install.XXXXXX")" + extract_dir="$tmp/extract" + mkdir -p "$extract_dir" + tar -xzf "$archive" -C "$extract_dir" + extracted_root="$(find_extracted_root "$extract_dir")" + + mkdir -p "$INSTALL_ROOT" "$BIN_DIR" + rm -rf "$target.tmp" + cp -R "$extracted_root" "$target.tmp" + chmod +x "$target.tmp/devloop" + rm -rf "$target" + mv "$target.tmp" "$target" + ln -sfn "$target/devloop" "$BIN_DIR/devloop" + rm -rf "$tmp" + + info "installed devloop $version -> $target" + info "linked $BIN_DIR/devloop -> $target/devloop" +} + +install_skills() { + local root="$1" + if [ "$NO_SKILLS" = true ]; then + info "skipping skill installation" + info "devloop doctor will require skill installation before agent loops are ready." + return 0 + fi + + DEVLOOP_SKILL_INSTALL=copy + export DEVLOOP_SKILL_INSTALL + # shellcheck source=/dev/null + source "$root/scripts/skill_helpers.sh" + devloop_install_skills "$root" +} + +dry_run() { + local version="$1" + info "dry run: no files will be changed" + info "version: $version" + info "download: $(artifact_url "$version")" + info "verify: $(checksum_url "$version")" + info "install: $INSTALL_ROOT/$version" + info "link: $BIN_DIR/devloop -> $INSTALL_ROOT/$version/devloop" + if [ "$NO_SKILLS" = true ]; then + info "skills: skipped" + info "devloop doctor will require skill installation before agent loops are ready." + else + info "skills: $HOME/.agents/skills, $HOME/.claude/skills" + fi +} + +main() { + local version ui_missing agent_missing tmp archive checksum installed_root + parse_args "$@" + + if [ -z "$VERSION" ]; then + VERSION="$(resolve_latest_version)" + fi + version="$(normalize_version "$VERSION")" + ui_missing="$(missing_commands gum fzf)" + agent_missing="$(missing_commands codex claude)" + + if [ "$DRY_RUN" = true ]; then + dry_run "$version" + check_ui_tools "$ui_missing" + check_agent_tools "$agent_missing" + print_path_guidance + return 0 + fi + + tmp="$(mktemp -d "${TMPDIR:-/tmp}/devloop-download.XXXXXX")" + archive="$tmp/$(asset_name "$version")" + checksum="$archive.sha256" + download_file "$(artifact_url "$version")" "$archive" + download_file "$(checksum_url "$version")" "$checksum" + verify_archive "$archive" "$checksum" + install_archive "$version" "$archive" + rm -rf "$tmp" + + installed_root="$INSTALL_ROOT/$version" + install_skills "$installed_root" + check_ui_tools "$ui_missing" + check_agent_tools "$agent_missing" + print_path_guidance + info "" + info "try: devloop doctor" +} + +main "$@" diff --git a/install.sh b/scripts/install.sh similarity index 88% rename from install.sh rename to scripts/install.sh index 520f5d7..85bf18d 100755 --- a/install.sh +++ b/scripts/install.sh @@ -11,8 +11,9 @@ while [ -L "$SCRIPT_PATH" ]; do esac done -ROOT="$(cd -P "$(dirname "$SCRIPT_PATH")" >/dev/null 2>&1 && pwd)" -source "$ROOT/skill_helpers.sh" +SCRIPT_DIR="$(cd -P "$(dirname "$SCRIPT_PATH")" >/dev/null 2>&1 && pwd)" +ROOT="$(cd -P "$SCRIPT_DIR/.." >/dev/null 2>&1 && pwd)" +source "$ROOT/scripts/skill_helpers.sh" BIN_DIR="${DEVLOOP_BIN_DIR:-$HOME/.local/bin}" TARGET="$BIN_DIR/devloop" @@ -37,7 +38,7 @@ install_required_ui_tools() { if ! command -v brew >/dev/null 2>&1; then echo "missing required UI tools: ${missing[*]}" >&2 - echo "install Homebrew, then rerun ./install.sh" >&2 + echo "install Homebrew, then rerun ./scripts/install.sh" >&2 return 1 fi diff --git a/release.sh b/scripts/release.sh similarity index 69% rename from release.sh rename to scripts/release.sh index b18a65c..7ea4ae0 100755 --- a/release.sh +++ b/scripts/release.sh @@ -11,19 +11,22 @@ while [ -L "$SCRIPT_PATH" ]; do esac done -ROOT="$(cd -P "$(dirname "$SCRIPT_PATH")" >/dev/null 2>&1 && pwd)" +SCRIPT_DIR="$(cd -P "$(dirname "$SCRIPT_PATH")" >/dev/null 2>&1 && pwd)" +ROOT="$(cd -P "$SCRIPT_DIR/.." >/dev/null 2>&1 && pwd)" +RELEASE_ARCHIVE="" +RELEASE_CHECKSUM="" release_usage() { cat <<'EOF' -usage: ./release.sh [--dry-run] [--publish] [--push] +usage: ./scripts/release.sh [--dry-run] [--publish] [--push] Bumps VERSION, creates a release commit, and creates an annotated tag. Use --publish to push the commit and tag, then create a GitHub Release. Examples: - ./release.sh patch --dry-run - ./release.sh minor --publish - ./release.sh major --push + ./scripts/release.sh patch --dry-run + ./scripts/release.sh minor --publish + ./scripts/release.sh major --push EOF } @@ -107,6 +110,45 @@ release_current_branch() { git -C "$ROOT" branch --show-current } +release_checksum_file() { + local file="$1" + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file" | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" | awk '{print $1}' + else + printf '%s\n' "missing shasum or sha256sum" >&2 + return 1 + fi +} + +release_create_artifacts() { + local version="$1" + local out_dir="$2" + local name="devloop-$version" + local staging="$out_dir/$name" + + rm -rf "$staging" + mkdir -p "$staging/scripts" + cp "$ROOT/devloop" "$staging/devloop" + cp "$ROOT/scripts/install.sh" "$staging/scripts/install.sh" + cp "$ROOT/scripts/install.remote.sh" "$staging/scripts/install.remote.sh" + cp "$ROOT/scripts/devloop_test.sh" "$staging/scripts/devloop_test.sh" + cp "$ROOT/scripts/release.sh" "$staging/scripts/release.sh" + cp "$ROOT/scripts/skill_helpers.sh" "$staging/scripts/skill_helpers.sh" + cp "$ROOT/README.md" "$staging/README.md" + cp "$ROOT/LICENSE" "$staging/LICENSE" + cp "$ROOT/CHANGELOG.md" "$staging/CHANGELOG.md" + cp "$ROOT/VERSION" "$staging/VERSION" + cp -R "$ROOT/skills" "$staging/skills" + + RELEASE_ARCHIVE="$out_dir/$name.tar.gz" + RELEASE_CHECKSUM="$RELEASE_ARCHIVE.sha256" + tar -C "$out_dir" -czf "$RELEASE_ARCHIVE" "$name" + printf '%s %s\n' "$(release_checksum_file "$RELEASE_ARCHIVE")" "$name.tar.gz" > "$RELEASE_CHECKSUM" + rm -rf "$staging" +} + release_assert_push_branch() { local branch branch="$(release_current_branch)" @@ -127,6 +169,7 @@ release_main() { local publish=false local push=false local tag branch + local artifact_dir while [ "$#" -gt 0 ]; do case "$1" in @@ -183,12 +226,15 @@ release_main() { if [ -n "$(git -C "$ROOT" status --porcelain)" ]; then printf '%s\n' "note: actual release requires a clean working tree" fi - printf '%s\n' "would run bash tests/devloop_test.sh" + printf '%s\n' "would run bash scripts/devloop_test.sh" printf '%s\n' "would update VERSION and CHANGELOG.md" printf 'would commit: chore: release %s\n' "$version" printf 'would tag: %s\n' "$tag" if [ "$publish" = true ] || [ "$push" = true ]; then printf '%s\n' "would push branch and tag"; fi - if [ "$publish" = true ]; then printf 'would create GitHub release: gh release create %s --verify-tag --generate-notes\n' "$tag"; fi + if [ "$publish" = true ]; then + printf 'would build release assets: devloop-%s.tar.gz and devloop-%s.tar.gz.sha256\n' "$version" "$version" + printf 'would create GitHub release: gh release create %s --verify-tag --generate-notes devloop-%s.tar.gz devloop-%s.tar.gz.sha256\n' "$tag" "$version" "$version" + fi return 0 fi @@ -198,7 +244,7 @@ release_main() { release_assert_clean_tree if [ "$publish" = true ] || [ "$push" = true ]; then release_assert_push_branch; fi - bash "$ROOT/tests/devloop_test.sh" + bash "$ROOT/scripts/devloop_test.sh" printf '%s\n' "$version" > "$ROOT/VERSION" git-cliff --config "$ROOT/cliff.toml" --workdir "$ROOT" --tag "$tag" --output "$ROOT/CHANGELOG.md" git -C "$ROOT" add VERSION CHANGELOG.md @@ -212,7 +258,10 @@ release_main() { fi if [ "$publish" = true ]; then - gh release create "$tag" --verify-tag --generate-notes + artifact_dir="$(mktemp -d "${TMPDIR:-/tmp}/devloop-release.XXXXXX")" + release_create_artifacts "$version" "$artifact_dir" + gh release create "$tag" --verify-tag --generate-notes "$RELEASE_ARCHIVE" "$RELEASE_CHECKSUM" + rm -rf "$artifact_dir" fi printf 'released %s\n' "$tag" diff --git a/skill_helpers.sh b/scripts/skill_helpers.sh similarity index 99% rename from skill_helpers.sh rename to scripts/skill_helpers.sh index 7cd74fa..6dcd038 100644 --- a/skill_helpers.sh +++ b/scripts/skill_helpers.sh @@ -213,7 +213,7 @@ devloop_doctor_skills_in_dir() { } if [ "$bundled" != "$installed" ]; then - printf '[fail] stale skill: %s (run ./install.sh)\n' "$dest" >&2 + printf '[fail] stale skill: %s (run ./scripts/install.sh)\n' "$dest" >&2 status=1 continue fi