Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/homebrew-distribution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"devctl": minor
---

devctl installs from Homebrew: `brew install --cask quantizor/tap/devctl`. `brew upgrade` keeps it current, and when a newer version ships the menu bar popover shows a quiet notice with a one-click Upgrade button that runs the upgrade in Terminal (a Homebrew install) or links to the release notes (a direct download). A direct DMG download still works exactly as before.

A new Settings window, opened from the gear at the bottom of the popover, is the way back to anything you skipped at first run: install or remove the Claude Code and Cursor session hooks per harness, toggle Start at login, and turn the update check on or off. devctl still only edits a harness's settings when you click; it never changes them on its own.

Removing devctl is now a single command. `devctl uninstall` unregisters the background agent, removes the agent hooks, and removes the CLI, keeping your data unless you pass `--purge`; running servers keep going. The Settings window offers the same as a button. `devctl doctor` reports a harness whose hook is missing or points at a path that no longer exists, and names the command to fix it.
9 changes: 9 additions & 0 deletions .changeset/honest-path-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"devctl": patch
---

The installer no longer tells you to fix a PATH that is already fine. It asked the app's own process for its PATH, and the app is launched by Finder, so that was launchd's rather than your shell's: it can never contain `~/.local/bin`, so the warning appeared for everyone whatever their shell actually had.

Asking a login shell was not enough either. `zsh -l` without `-i` runs `.zshenv`, `.zprofile` and `.zlogin` and skips `.zshrc`, which is where most tools put themselves. On the machine this was found, that meant devctl handed every server it started a PATH missing `~/.local/bin`, pnpm, conda and gcloud, so a server script calling `devctl` could not find it. Both the warning and the PATH your servers inherit now reflect what your shell really has.

Reading that PATH means running your shell profile, which devctl does not control, so it now gives up after a while and falls back rather than waiting forever. A profile that waits on the network or on a terminal that is not there used to hang the app at launch with nothing on screen. `DEVCTL_RESOLVING_ENVIRONMENT` is set while it runs, so a profile can skip whatever needs a real session.
9 changes: 9 additions & 0 deletions .changeset/one-copy-of-the-app.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"devctl": patch
---

Installing no longer leaves two menu bar apps running. A second copy of the same app doubles everything you see: two icons, two pollers, and two notifications for one crash. Nothing prevented it, and the install hands off by asking macOS for a new instance by name, so any second trigger produced one, whether that was the relaunch button racing the automatic handoff, a squatting copy that would not quit, or opening the app in Finder while it was already running. A copy that finds the same app already running now steps aside on its own, whichever way it was launched.

The copy on the disk image and the copy in Applications still run side by side for the moment the handoff needs, since that pair is the one case where two is correct.

Confirming an upgrade now stops rather than replacing the app in Applications while an old copy is still running it, and says which app to quit. It used to try for a few seconds, give up quietly, and replace the bundle anyway, leaving that copy running code no longer on disk.
117 changes: 117 additions & 0 deletions .github/workflows/bump-homebrew-cask.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Updates the Homebrew tap cask after a release DMG is published.
#
# Kicked by "Release DMG" via workflow_dispatch once the DMG is uploaded (a
# GITHUB_TOKEN release does not fire release:published on other workflows, so the
# explicit dispatch is the real path; release:published stays as a manual / PAT
# fallback). Runs on ubuntu (no brew, no macOS minutes): it needs only the DMG's
# checksum and a git push.
#
# The cask's structure lives in devctl's packaging/homebrew/devctl.rb; this job
# only injects the release's version and sha256, so there is one home for the
# cask and the tap is a generated artifact.
#
# Requires a repository secret HOMEBREW_TAP_TOKEN: a fine-grained PAT with
# contents:write on quantizor/homebrew-tap (the default GITHUB_TOKEN cannot push
# across repos). A GitHub App token via actions/create-github-app-token is the
# equivalent alternative. Note dawidd6/action-homebrew-bump-formula is
# formula-only despite being the action usually linked for this.
name: Bump Homebrew cask

on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Release tag to bump the cask to (e.g. v1.3.0)"
required: true

permissions:
contents: read

concurrency:
group: bump-homebrew-cask
cancel-in-progress: false

jobs:
bump:
name: Update the tap cask
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Resolve the tag
id: tag
env:
TAG: ${{ github.event.release.tag_name || inputs.tag }}
run: |
set -euo pipefail
[[ -n "${TAG:-}" ]] || { echo "no tag provided" >&2; exit 1; }
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"

- name: Checkout devctl at the tag
uses: actions/checkout@v4
with:
ref: ${{ steps.tag.outputs.tag }}
path: devctl

- name: Verify the DMG asset exists and download it
id: dmg
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.tag.outputs.tag }}
VERSION: ${{ steps.tag.outputs.version }}
run: |
set -euo pipefail
ASSET="devctl-${VERSION}.dmg"
# Fail fast if the DMG is not attached yet: the release DMG build must
# finish first, and shipping a cask whose URL 404s is worse than waiting.
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets \
--jq '.assets[].name' | grep -qx "$ASSET"; then
echo "Release $TAG has no asset $ASSET yet; run this after the DMG build finishes." >&2
exit 1
fi
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern "$ASSET" --dir .
echo "sha=$(sha256sum "$ASSET" | awk '{print $1}')" >> "$GITHUB_OUTPUT"

- name: Checkout the tap
uses: actions/checkout@v4
with:
repository: quantizor/homebrew-tap
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
path: tap

- name: Rewrite the cask from the template
env:
VERSION: ${{ steps.tag.outputs.version }}
SHA: ${{ steps.dmg.outputs.sha }}
run: |
set -euo pipefail
mkdir -p tap/Casks
# Ruby, not `sed -i`: sub! returns nil on no match, so a drifted
# template aborts the job, where sed would exit 0 on zero matches and
# ship a cask still pointing at the previous version and checksum.
ruby -e '
version = ENV.fetch("VERSION")
sha = ENV.fetch("SHA")
text = File.read("devctl/packaging/homebrew/devctl.rb")
text.sub!(/version "[^"]*"/, %(version "#{version}")) or abort("version stanza not found in template")
text.sub!(/sha256 "[^"]*"/, %(sha256 "#{sha}")) or abort("sha256 stanza not found in template")
File.write("tap/Casks/devctl.rb", text)
'

- name: Commit and push
env:
VERSION: ${{ steps.tag.outputs.version }}
run: |
set -euo pipefail
cd tap
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
if git diff --quiet -- Casks/devctl.rb; then
echo "cask already at ${VERSION}; nothing to push"
exit 0
fi
git add Casks/devctl.rb
git commit -m "devctl ${VERSION}"
git push
16 changes: 16 additions & 0 deletions .github/workflows/release-dmg.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ concurrency:
cancel-in-progress: false

permissions:
actions: write
contents: write

jobs:
Expand Down Expand Up @@ -93,6 +94,9 @@ jobs:
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
DEVCTL_DMG_QUARANTINE: "0"
# Fail the build if signing falls back to ad-hoc: an unsigned release
# would install through the cask and then be disabled by Gatekeeper.
DEVCTL_REQUIRE_SIGNING: "1"
run: |
set -euo pipefail
: "${SIGN_IDENTITY:?set APPLE_SIGN_IDENTITY secret}"
Expand Down Expand Up @@ -157,6 +161,18 @@ jobs:
TAG="$(git describe --tags --abbrev=0)"
fi
gh release upload "$TAG" "$DMG" --clobber
echo "UPLOADED_TAG=$TAG" >> "$GITHUB_ENV"

# Now that the DMG is attached, update the Homebrew tap cask. Same
# explicit-dispatch pattern Release uses to kick this workflow, since a
# GITHUB_TOKEN release does not fire release:published on the bump job.
- name: Kick Homebrew cask bump
if: steps.gate.outputs.publish == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
gh workflow run "Bump Homebrew cask" --ref main -f "tag=${UPLOADED_TAG}"

# A dry run's whole point is inspecting the image, so hand it back.
- name: Attach DMG to the run (dry run only)
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ devctl.app/
dist/
*.corrupt-*
.DS_Store
# Maintainer-local backlog: holds release-ops detail and known-issue notes that
# do not belong in a public repo. Tracked history still contains earlier copies.
/BACKLOG.md
Loading
Loading