diff --git a/.github/workflows/build-desktop-mac.yml b/.github/workflows/build-desktop-mac.yml new file mode 100644 index 000000000..07df36e3a --- /dev/null +++ b/.github/workflows/build-desktop-mac.yml @@ -0,0 +1,132 @@ +name: Build macOS desktop + +# Builds, signs and notarizes the desktop app for arm64 and x64 in one electron-builder run, +# so a single latest-mac.yml lists both zips. Read-only like the Windows installer workflow: +# the release owner uploads the artifact to the desktop-v* release by hand. +on: + push: + tags: + - 'desktop-v*' + workflow_dispatch: + +permissions: + contents: read + +jobs: + mac: + runs-on: macos-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 22.13.0 + cache: npm + cache-dependency-path: | + package-lock.json + app/package-lock.json + + - name: Check tag matches the app version + if: github.ref_type == 'tag' + run: test "desktop-v$(node -p "require('./app/package.json').version")" = "$GITHUB_REF_NAME" + + - name: Install dependencies + run: | + npm ci + npm ci --prefix app + + - name: Check secrets and write notary key + env: + MACOS_CERT_P12_BASE64: ${{ secrets.MACOS_CERT_P12_BASE64 }} + MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }} + APPSTORE_API_KEY_P8_BASE64: ${{ secrets.APPSTORE_API_KEY_P8_BASE64 }} + APPSTORE_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }} + APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }} + run: | + for name in MACOS_CERT_P12_BASE64 MACOS_CERT_PASSWORD APPSTORE_API_KEY_P8_BASE64 APPSTORE_API_KEY_ID APPSTORE_API_ISSUER_ID; do + if [ -z "${!name}" ]; then echo "::error::secret $name is not set"; exit 1; fi + done + echo "$APPSTORE_API_KEY_P8_BASE64" | base64 --decode > "$RUNNER_TEMP/notary.p8" + # Keychain Access exports a legacy p12 (RC2-40, sha1 MAC, 1 iteration) that the + # runner's `security import` rejects as a wrong password; rewrap it in a form it reads. + # electron-builder's own temporary keychain fails to unlock on this runner, so import + # into our keychain (as release-menubar.yml does) and let it find the identity there. + umask 077 + openssl3="$(brew --prefix openssl@3)/bin/openssl" + cert="$RUNNER_TEMP/signing.p12" + keychain="$RUNNER_TEMP/codeburn-signing.keychain-db" + base64 --decode <<< "$MACOS_CERT_P12_BASE64" > "$cert.in" + "$openssl3" pkcs12 -legacy -in "$cert.in" -passin env:MACOS_CERT_PASSWORD -nodes -out "$cert.pem" + export cert_password="$(openssl rand -hex 24)" + echo "::add-mask::$cert_password" + "$openssl3" pkcs12 -export -legacy -in "$cert.pem" -out "$cert" -passout env:cert_password \ + -keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES -macalg sha1 -iter 2048 + rm -f "$cert.in" "$cert.pem" + keychain_password="$(openssl rand -hex 24)" + echo "::add-mask::$keychain_password" + security create-keychain -p "$keychain_password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + security import "$cert" -k "$keychain" -P "$cert_password" -f pkcs12 -T /usr/bin/codesign -T /usr/bin/security + rm -f "$cert" + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain" > /dev/null + # shellcheck disable=SC2046 + security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') + security find-identity -v -p codesigning "$keychain" | grep -q 'Developer ID Application: .*(XRVP7P7F9M)' \ + || { echo "::error::No Developer ID Application identity for team XRVP7P7F9M in MACOS_CERT_P12_BASE64."; exit 1; } + + - name: Build, sign and notarize + working-directory: app + env: + APPLE_API_KEY: ${{ runner.temp }}/notary.p8 + APPLE_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPSTORE_API_ISSUER_ID }} + run: | + npm run stage-cli + npm run build + CSC_IDENTITY_AUTO_DISCOVERY=true npx electron-builder --mac --publish never \ + -c.mac.identity="Resham Joshi (XRVP7P7F9M)" \ + -c.mac.hardenedRuntime=true \ + -c.dmg.sign=true + + - name: Notarize and staple the dmgs + working-directory: app/release + env: + APPLE_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPSTORE_API_ISSUER_ID }} + run: | + for dmg in CodeBurn-*.dmg; do + xcrun notarytool submit "$dmg" --key "$RUNNER_TEMP/notary.p8" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER" --wait + xcrun stapler staple "$dmg" + done + + - name: Verify signatures + working-directory: app/release + run: | + for app in mac-arm64/CodeBurn.app mac/CodeBurn.app; do + codesign --verify --deep --strict "$app" + spctl --assess --type execute --verbose "$app" + done + for dmg in CodeBurn-*.dmg; do xcrun stapler validate "$dmg"; done + grep -Eq 'url: CodeBurn-[0-9.]+-arm64-mac\.zip' latest-mac.yml + grep -Eq 'url: CodeBurn-[0-9.]+-mac\.zip' latest-mac.yml + + - name: Remove notary key + if: always() + run: | + rm -f "$RUNNER_TEMP/notary.p8" + security delete-keychain "$RUNNER_TEMP/codeburn-signing.keychain-db" 2>/dev/null || true + + - name: Upload artifact + uses: actions/upload-artifact@v6 + with: + name: CodeBurn-macOS + path: | + app/release/CodeBurn-*.dmg + app/release/CodeBurn-*.dmg.blockmap + app/release/CodeBurn-*-mac.zip + app/release/CodeBurn-*-mac.zip.blockmap + app/release/latest-mac.yml + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/build-windows-installer.yml b/.github/workflows/build-windows-installer.yml index c2d8eab46..0547f8be3 100644 --- a/.github/workflows/build-windows-installer.yml +++ b/.github/workflows/build-windows-installer.yml @@ -65,9 +65,11 @@ jobs: working-directory: windows run: npm ci + # The staged tray MSI needs no updater signature: the tray updates from its own windows-v* line. - name: Build tray app working-directory: windows - run: npm run tauri build + shell: bash + run: npm run tauri build -- --no-sign - name: Build NSIS installer run: npm --prefix app run package:win @@ -88,6 +90,7 @@ jobs: path: | app/release/CodeBurn-Setup-*.exe app/release/CodeBurn-Setup-*.exe.blockmap + app/release/latest.yml if-no-files-found: error retention-days: 30 @@ -107,3 +110,36 @@ jobs: node app/scripts/verify-windows-installer.mjs \ --tag "$RELEASE_TAG" \ --release-assets "$RUNNER_TEMP/release-assets.json" + + # Last step of a desktop release: point the app's update feed at it. Runs only after the + # live-asset check passes, and refuses to move the feed backwards. + publish-update-feeds: + needs: verify-release-assets + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + + - name: Publish desktop update feeds + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} + run: | + set -euo pipefail + cd "$RUNNER_TEMP" + version="${RELEASE_TAG#desktop-v}" + gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" --jq '[.assets[].name]' > release-assets.json + gh release download "$RELEASE_TAG" -R "$GITHUB_REPOSITORY" -p 'latest*.yml' + node "$GITHUB_WORKSPACE/app/scripts/update-feed.mjs" --tag "$RELEASE_TAG" --release-assets release-assets.json latest*.yml + if gh release view update-feeds -R "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + current=$(gh release download update-feeds -R "$GITHUB_REPOSITORY" -p latest-mac.yml -O - 2>/dev/null | sed -n 's/^version: //p' || true) + if [ -n "$current" ] && [ "$(printf '%s\n%s\n' "$current" "$version" | sort -V | tail -1)" != "$version" ]; then + echo "::error::update-feeds already serves $current, newer than $version" + exit 1 + fi + else + gh release create update-feeds -R "$GITHUB_REPOSITORY" --prerelease --latest=false \ + --title "Update feeds" --notes "Update metadata for the apps. Not a release to download." + fi + gh release upload update-feeds -R "$GITHUB_REPOSITORY" latest*.yml --clobber diff --git a/.github/workflows/build-windows-store.yml b/.github/workflows/build-windows-store.yml index 033b3eea0..df29c05a3 100644 --- a/.github/workflows/build-windows-store.yml +++ b/.github/workflows/build-windows-store.yml @@ -57,7 +57,8 @@ jobs: - name: Build tray app working-directory: windows - run: npm run tauri build -- --no-bundle + shell: bash + run: npm run tauri build -- --no-bundle --no-sign - name: Build Microsoft Store package run: npm --prefix app run package:store diff --git a/.github/workflows/release-menubar-windows.yml b/.github/workflows/release-menubar-windows.yml index 4f148106e..1e7bc4642 100644 --- a/.github/workflows/release-menubar-windows.yml +++ b/.github/workflows/release-menubar-windows.yml @@ -2,8 +2,9 @@ name: Release Windows Menubar # Triggers on a `windows-v*` tag push (e.g. `git tag windows-v0.9.20 && git push origin # windows-v0.9.20`), or manually via the Actions tab. Mirrors release-menubar.yml, which -# does the same job for the macOS menubar under the `mac-v*` tags. The produced `.msi` is -# unsigned; users see a SmartScreen prompt on first run until we add signing. +# does the same job for the macOS menubar under the `mac-v*` tags. The produced `.msi` has no +# Authenticode signature, so SmartScreen prompts on first run; the tray's one-click updater +# instead checks the minisign signature written into windows-latest.json on update-feeds. on: push: tags: @@ -54,9 +55,20 @@ jobs: working-directory: windows run: npm ci + # Without the updater key the MSI ships unsigned for the updater and the feed is left alone. - name: Build MSI bundle working-directory: windows - run: npm run tauri build + shell: bash + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + if [ -n "$TAURI_SIGNING_PRIVATE_KEY" ]; then + npm run tauri build + else + echo "::warning::TAURI_SIGNING_PRIVATE_KEY is not set; building without an updater signature" + npm run tauri build -- --no-sign + fi - name: Collect artifacts shell: bash @@ -98,8 +110,43 @@ jobs: This `.msi` is a developer preview. The recommended install is the Microsoft Store (https://apps.microsoft.com/detail/9P0R4ZL5XMB8), which is signed and - updates itself. Preview builds do not update themselves: the tray app reports - that a newer version exists and links to the release, and you take it by - re-running `codeburn menubar --force` or downloading the new `.msi` here. + updates itself. Previews built with the updater key install newer previews from + the tray in one click, after checking their signature. Older previews link to the + release instead: take it by re-running `codeburn menubar --force` or downloading + the new `.msi` here. files: release-artifacts/* fail_on_unmatched_files: true + + # Last: point the tray's updater at the release, once its MSI is live on it. + - name: Publish tray update feed + if: startsWith(github.ref, 'refs/tags/windows-v') + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#windows-v}" + msi="CodeBurn.Menubar_${version}_x64_en-US.msi" + gh release view "$GITHUB_REF_NAME" -R "$GITHUB_REPOSITORY" --json assets --jq '.assets[].name' | grep -Fqx "$msi" + sig=$(find windows/src-tauri/target/release/bundle/msi -name "*_${version}_x64_en-US.msi.sig" | head -1) + if [ -z "$sig" ]; then + echo "::warning::no updater signature for $msi; update-feeds/windows-latest.json not changed" + exit 0 + fi + VERSION="$version" MSI="$msi" SIG="$sig" node -e ' + const fs = require("fs") + const { VERSION, MSI, SIG } = process.env + fs.writeFileSync("windows-latest.json", JSON.stringify({ + version: VERSION, + pub_date: new Date().toISOString(), + platforms: { + "windows-x86_64": { + signature: fs.readFileSync(SIG, "utf8").trim(), + url: `https://github.com/getagentseal/codeburn/releases/download/windows-v${VERSION}/${MSI}`, + }, + }, + }, null, 2))' + gh release view update-feeds -R "$GITHUB_REPOSITORY" >/dev/null 2>&1 || \ + gh release create update-feeds -R "$GITHUB_REPOSITORY" --prerelease --latest=false \ + --title "Update feeds" --notes "Update metadata for the apps. Not a release to download." + gh release upload update-feeds -R "$GITHUB_REPOSITORY" windows-latest.json --clobber diff --git a/.github/workflows/release-menubar.yml b/.github/workflows/release-menubar.yml index 52e6c5ba2..59153f110 100644 --- a/.github/workflows/release-menubar.yml +++ b/.github/workflows/release-menubar.yml @@ -1,9 +1,12 @@ name: Release macOS Menubar # Triggers on a `mac-v*` tag push (e.g. `git tag mac-v0.8.0 && git push origin mac-v0.8.0`), -# or manually via the Actions tab. Builds a universal arm64+x86_64 bundle, ad-hoc signs it, -# zips via `ditto`, and uploads the zip to the GitHub Release. The installer verifies -# the checksum and bundle identity before replacing the local app. +# or manually via the Actions tab. Builds a universal arm64+x86_64 bundle, signs it with the +# Developer ID Application cert (hardened runtime, secure timestamp), notarizes and staples it, +# zips via `ditto`, and uploads the zip to the GitHub Release. Tag builds then point the +# rolling `update-feeds` prerelease at it. Without the signing secrets the job fails. +# The installer verifies the checksum, bundle id, team signature and Gatekeeper before +# replacing the local app. on: push: tags: @@ -37,9 +40,76 @@ jobs: - name: Show Swift toolchain run: swift --version - - name: Build + bundle + zip + - name: Import signing certificate and notary key + env: + MACOS_CERT_P12_BASE64: ${{ secrets.MACOS_CERT_P12_BASE64 }} + MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }} + MACOS_KEYCHAIN_PASSWORD: ${{ secrets.MACOS_KEYCHAIN_PASSWORD }} + APPSTORE_API_KEY_P8_BASE64: ${{ secrets.APPSTORE_API_KEY_P8_BASE64 }} + APPSTORE_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }} + APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }} + run: | + missing=() + for name in MACOS_CERT_P12_BASE64 MACOS_CERT_PASSWORD MACOS_KEYCHAIN_PASSWORD \ + APPSTORE_API_KEY_P8_BASE64 APPSTORE_API_KEY_ID APPSTORE_API_ISSUER_ID; do + [[ -n "${!name}" ]] || missing+=("$name") + done + if (( ${#missing[@]} )); then + echo "::error::Missing signing secrets: ${missing[*]}. Refusing to build an unsigned menubar release (see RELEASING.md)." + exit 1 + fi + + keychain="$RUNNER_TEMP/codeburn-signing.keychain-db" + cert="$RUNNER_TEMP/codeburn-signing.p12" + # Keychain Access exports a legacy p12 (RC2-40, sha1 MAC, 1 iteration) that the + # runner's `security import` rejects as a wrong password; rewrap it in a form it reads. + umask 077 + openssl3="$(brew --prefix openssl@3)/bin/openssl" + base64 --decode <<< "$MACOS_CERT_P12_BASE64" > "$cert.in" + "$openssl3" pkcs12 -legacy -in "$cert.in" -passin env:MACOS_CERT_PASSWORD -nodes -out "$cert.pem" + export cert_password="$(openssl rand -hex 24)" + echo "::add-mask::$cert_password" + "$openssl3" pkcs12 -export -legacy -in "$cert.pem" -out "$cert" -passout env:cert_password \ + -keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES -macalg sha1 -iter 2048 + rm -f "$cert.in" "$cert.pem" + security create-keychain -p "$MACOS_KEYCHAIN_PASSWORD" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$MACOS_KEYCHAIN_PASSWORD" "$keychain" + security import "$cert" -k "$keychain" -P "$cert_password" -f pkcs12 -T /usr/bin/codesign -T /usr/bin/security + rm -f "$cert" + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_KEYCHAIN_PASSWORD" "$keychain" > /dev/null + # shellcheck disable=SC2046 + security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') + + identity=$(security find-identity -v -p codesigning "$keychain" | awk '/Developer ID Application: .*\(XRVP7P7F9M\)/ { print $2; exit }') + if [[ -z "$identity" ]]; then + echo "::error::No Developer ID Application identity for team XRVP7P7F9M in MACOS_CERT_P12_BASE64." + exit 1 + fi + + key="$RUNNER_TEMP/AuthKey_${APPSTORE_API_KEY_ID}.p8" + base64 --decode <<< "$APPSTORE_API_KEY_P8_BASE64" > "$key" + { + echo "CODESIGN_IDENTITY=$identity" + echo "NOTARY_KEY_PATH=$key" + echo "NOTARY_KEY_ID=$APPSTORE_API_KEY_ID" + echo "NOTARY_ISSUER_ID=$APPSTORE_API_ISSUER_ID" + } >> "$GITHUB_ENV" + + - name: Build + sign + notarize + zip run: mac/Scripts/package-app.sh "${{ steps.version.outputs.value }}" + - name: Verify the zip the way the installer will + env: + ZIP: mac/.build/dist/CodeBurnMenubar-${{ steps.version.outputs.value }}.zip + run: | + (cd "$(dirname "$ZIP")" && shasum -a 256 -c "$(basename "$ZIP").sha256") + check="$RUNNER_TEMP/verify-zip" + rm -rf "$check" && mkdir -p "$check" + ditto -x -k "$ZIP" "$check" + codesign --verify --deep --strict -R='anchor apple generic and certificate leaf[subject.OU] = "XRVP7P7F9M"' "$check/CodeBurnMenubar.app" + spctl --assess --type execute -vv "$check/CodeBurnMenubar.app" + - name: Upload artifact (for manual runs) if: github.event_name == 'workflow_dispatch' uses: actions/upload-artifact@v7 @@ -65,11 +135,44 @@ jobs: ``` That command drops the app into `~/Applications`, records the persistent - `codeburn` CLI path used by the menubar, verifies the downloaded checksum, - clears quarantine after bundle verification, and launches it. If you download - the zip from this page directly and macOS shows "cannot verify developer", - right-click the app in Finder and pick Open to whitelist it once. + `codeburn` CLI path used by the menubar, verifies the checksum and the + AgentSeal Developer ID signature, and launches it. The app is notarized, so + the zip from this page also opens directly. files: | mac/.build/dist/CodeBurnMenubar-${{ steps.version.outputs.value }}.zip mac/.build/dist/CodeBurnMenubar-${{ steps.version.outputs.value }}.zip.sha256 fail_on_unmatched_files: true + + # Last on purpose: the feed only moves once the published asset is known to match the + # verified build. It lives on a prerelease so it never becomes GitHub's Latest release. + - name: Point the update feed at this release + if: startsWith(github.ref, 'refs/tags/mac-v') + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + VERSION: ${{ steps.version.outputs.value }} + run: | + zip_name="CodeBurnMenubar-${VERSION}.zip" + url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/${zip_name}" + built=$(cut -d' ' -f1 "mac/.build/dist/${zip_name}.sha256") + published=$(curl -fsSL --retry 5 --retry-all-errors --retry-delay 5 "$url" | shasum -a 256 | cut -d' ' -f1) + if [[ "$published" != "$built" ]]; then + echo "::error::Published ${zip_name} (${published}) does not match the verified build (${built})." + exit 1 + fi + + feed="$RUNNER_TEMP/menubar-latest.json" + jq -n --arg version "${VERSION#v}" --arg url "$url" --arg sha256 "$built" \ + '{version: $version, url: $url, sha256: $sha256}' > "$feed" + cat "$feed" + if ! gh release view update-feeds > /dev/null 2>&1; then + gh release create update-feeds --prerelease --latest=false \ + --title "Update feeds" --notes "Update metadata for the apps. Not a release to download." + fi + gh release upload update-feeds "$feed" --clobber + + - name: Remove signing material + if: always() + run: | + security delete-keychain "$RUNNER_TEMP/codeburn-signing.keychain-db" 2>/dev/null || true + rm -f "$RUNNER_TEMP"/AuthKey_*.p8 "$RUNNER_TEMP/codeburn-signing.p12" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fc790f026..cc5413fd5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -107,3 +107,50 @@ jobs: run: npm test - name: Build run: npm run build + + # The VS Code extension (publisher.name codeburn.codeburn): its host code, the + # renderer's webview build, the bundled CLI, a packaged .vsix, and a smoke run + # in a real VS Code under xvfb against a fixture home. + vscode: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v4 + with: + node-version: 22.13.0 + cache: npm + cache-dependency-path: | + package-lock.json + app/package-lock.json + vscode/package-lock.json + - run: npm ci + - name: Install the renderer + run: npm ci + working-directory: app + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: '1' + - name: Install the extension + run: npm ci + working-directory: vscode + - name: Typecheck + run: npm run typecheck + working-directory: vscode + - name: Unit tests + run: npm test + working-directory: vscode + - name: Webview bridge tests + run: npx vitest run renderer/vscode renderer/i18n + working-directory: app + - name: Build and package + run: npm run package + working-directory: vscode + - name: Smoke test in VS Code + run: xvfb-run -a npm run test:smoke + working-directory: vscode + - uses: actions/upload-artifact@v6 + with: + name: codeburn-vscode + path: vscode/*.vsix + if-no-files-found: error diff --git a/.github/workflows/windows-menubar-ci.yml b/.github/workflows/windows-menubar-ci.yml index 3606de40d..85ade9eff 100644 --- a/.github/workflows/windows-menubar-ci.yml +++ b/.github/workflows/windows-menubar-ci.yml @@ -89,4 +89,5 @@ jobs: - name: Release build smoke if: runner.os == 'Windows' working-directory: windows - run: npm run tauri build -- --no-bundle + shell: bash + run: npm run tauri build -- --no-bundle --no-sign diff --git a/.gitignore b/.gitignore index 268256bf4..d9974aed9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ # One did exactly that in db018f7 and had to be removed again in c642787. node_modules dist/ +dist-*/ *.tgz # OS diff --git a/CHANGELOG.md b/CHANGELOG.md index 41c5fc922..2e458028e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,37 +3,113 @@ ## Unreleased ### Added -- **Overview marks Cursor dollars that are local estimates, not the bill.** When a period still holds local Cursor or Cursor Agent usage that no dashboard import covers, `codeburn overview` prints one line naming how much of the total is priced from the IDE's local files (`includes $X of Cursor priced from local files, not Cursor's bill`) and points at `codeburn import cursor `, instead of letting the estimate read as the dashboard bill — on the account that reported #1545 the local figure was about 2% of what Cursor actually charged. Imported events and days an import covers never trigger the line, since covered local calls are dropped at serve time and the imported rows carry Cursor's own billed amounts. (#1545) -- **Amp support.** CodeBurn reads Sourcegraph Amp's local thread mirror at `~/.local/share/amp/threads/` (`AMP_DATA_DIR` honored, comma-separated): one JSON document per thread with a per-request usage ledger (`tokens.input`/`output`, cache tokens joined from the billed message through `toMessageId`) and a per-message usage fallback for older threads. Every call is priced from the underlying model's token rates and marked estimated; Amp's own credits field is not converted; thread files carry no cwd, so sessions group under the provider name. Reported in #1572. -- **`codeburn import cursor ` replaces the local Cursor estimates with Cursor's own usage export.** The local database has no per-request token counts and none of the cache reads Cursor re-sends, so on one real month CodeBurn saw about 7M Cursor tokens where the dashboard showed 644M. Export CSV at cursor.com/dashboard/usage and import it: for the time the export covers, its events replace the local Cursor, Cursor Agent and (when the export holds Grok Bot events) Grok Bot estimates, and outside it the estimates stay. Dollar amounts in the export are kept as billed; included usage is priced at API rates like every other local call. Pass `--from`/`--to` with the range you exported, re-import overlapping exports without double counting, and `--remove` to go back. Stored locally, nothing is uploaded. Reported in #1545. +- **Windows tray popover, settings and Capacity Dock follow the shared language.** The surfaces #1608 left in English (the popover, General settings, and the Capacity Dock window) use the macOS menu bar catalogs, including Traditional Chinese. Settings → General → Language writes the same `language` key in `~/.config/codeburn/config.json` that the desktop app writes, and a change made in either app is picked up while the tray is running. (#1646) -### Removed -- Roo Code support. Roo Code was discontinued in May 2026 (its repository is archived). +### Fixed +- **Copilot agent requests in VS Code chat count every model call.** Copilot Chat 0.69 keeps only the last call of an agent request in its metadata, so CodeBurn showed a fraction of the output and cost. It now reads the request's totals (all calls, with the cache split when VS Code records it), and a request that records GitHub's credits costs exactly that bill (1 credit = $0.01) in reports and plan credits. A chat session that also ran on the agent host is counted once, from the agent host. In Copilot plan credits each chat request is rated on its own. Past Copilot days re-derive once after the update. +- Copilot showed `claude-sonnet-4-6` as "Sonnet 4" instead of "Sonnet 4.6". + +## 0.9.27 - 2026-10-11 + +### Added +- **One-click updates on every surface.** `codeburn update` updates the CLI, the menu bar app and the desktop app in one go. `--check` lists what is out of date, and `--json` gives a stable result for scripts. (#1740) +- **The desktop app updates itself and its companions.** Click Update: it downloads and restarts on its own, and after the restart the CLI and the menu bar follow. Replacing the app by hand does the same. (#1741) +- **The menu bar update pill updates everything** through `codeburn update`, and checks every 6 hours. (#1742) +- **The Windows tray updates the CLI** through `codeburn update`, for Store and installer installs. (#1743) +- **Download links always point at the latest release.** (#1740) + +### Fixed +- A menu bar download could crash the desktop app's update when the server closed the connection mid-download. (#1745) +- Releases publish npm first, so an update never points at a CLI version npm does not serve yet. (#1740) + +## 0.9.26 - 2026-10-11 + +Upgrade note: the daily cache moves from v34 to v74 and the session cache converts to the day-piece layout once. The first run after updating re-reads your history and can take longer than usual. Totals for past days can change, because the money fixes below re-derive settled days. + +### Added +- **CodeBurn for VS Code.** Extension 1.0.0 for VS Code, Cursor, Windsurf, Antigravity and VSCodium: today's spend in the status bar, an activity bar summary with quotas and Optimize findings, and the desktop dashboard in an editor tab. It bundles this CLI and sends no telemetry. (#1670) +- **Amp support.** Reads Sourcegraph Amp's local thread mirror. Calls are priced from the model's token rates and marked estimated. (#1578, #1604) +- **Command Code support.** Reads the `cmd` CLI's session transcripts, keeps the cost Command Code recorded, and shows its 5-hour, weekly and monthly quota windows. (#1658, #1682, #1734) +- **ClinePass quota** from the Cline sign-in, with a clear "Cline login expired" status and how to renew it. (#1682, #1736) +- **Pull request view shows how each PR went.** Per PR: one shot or the number of follow-up rounds (pushes after `gh pr create`), review runs with their cost, time to merge, and a split of cost into building, review runs and after the first review. Local data only, no GitHub calls. A subagent whose description names exactly one PR folds into that PR. Totals do not change, only which PR holds the money. (#1733) +- **PR cards state the whole story.** A run that pushes a branch is charged to that branch's PR. Every card shows status, follow-ups, reviews and the spend from other work in the same session. Tables are compact and have tooltips. (#1737) +- **"Why did this session cost this."** A view for Claude Code sessions in the desktop app and `--why` on the CLI. (#1645) +- **Cursor usage syncs automatically from cursor.com** with the app's login, replacing local estimates, with a status line and an on/off switch in the desktop, Mac and Windows apps. `codeburn import cursor ` does the same from an exported CSV. (#1637, #1638, #1558) +- **Cursor Enterprise team seats** connect to the team on-demand meter. (#1553) +- **Estimated dollars carry a `~` in every view**, including the headline totals on the desktop, Mac, Windows and GNOME apps (share of estimates over 1%). `codeburn overview` says how much Cursor spend is priced from local files rather than Cursor's bill. (#1657, #1722, #1615) +- **Google AI Pro and Ultra plan presets** and the Antigravity tier label in `codeburn quota`. (#1683) +- **Devin plan and quota** from the Devin CLI cache. (#1606) +- **Projects follow the git repository.** Clones and worktrees sharing an origin are one project everywhere, a deleted sibling clone joins its repository by folder name, and `project link` puts a non-git folder into a repo project. (#1691, #1692, #1695) +- **Sessions list folds subagent sessions into their parent row.** On one real list, rows went from 3,163 to 1,407 with totals unchanged. Top sessions include their folded subagents. (#1690, #1694) +- **Project picker in the desktop top bar**, scoped to the current session of the app. (#1680) +- **Optimize flags sessions with a prompt cache hit rate under 80%.** (#1536) +- **Peak and off-peak usage** for first-party DeepSeek and Z.ai routes, with a live status command. (#1561) +- **Model names are consistent across providers** (Opus 4.8, GPT-5.5, GPT-5.6 Luna). (#1531) +- **Codex thread names** show from `session_index.jsonl`. (#1687) +- **Fable and Mythos 5 report a 1M context window.** (#1522) +- **Trend chart in the menu bar has a Cost / Tokens toggle.** (#1603) +- **Terminal dashboard:** mouse tracking is off by default so text selection works; `m` turns it on. (#1562) +- **Telemetry for CLI error causes, provider read failures and update results.** Fixed enums only, no paths. (#1702) + +### Added (macOS) +- **Menu bar release is signed and notarized in CI**, updates are pinned to our team, and the update feed is fixed. (#1684, #1731) +- **Menu bar one-click update picks brew or npm** from the CLI's real path. (#1563) +- **Opening a provider's settings checks that provider**, and the Capacity Dock keeps its order when a provider connects. (#1735) +- **One Claude ring per config directory in the Capacity Dock.** (#1570) +- **Dock settings:** keep every ring out at rest, and choose the hover bubble surface. (#1560, #1573) + +### Added (Windows) +- **One-click Update then Restart** for the desktop app and the Windows tray. (#1685) +- **Windows tray in Simplified Chinese.** (#1608) +- **Windows Store installs get no GitHub update offer**, and `codeburn menubar` opens the Store copy. (#1549) +- **Windows finds the CLI under a relocated Volta folder**, reads the Claude login from running WSL distros, and finds Mistral Vibe sessions in WSL homes. (#1534, #1554, #1681) + +### Added (Linux) +- **GNOME indicator shows combined usage from paired devices.** (#1566) + +### Changed +- **Roo Code support is removed.** The project is archived; Roo spend no longer shows. (#1551) +- **Session cache is stored in day pieces and ranged reports load only what the range needs.** On a heavy corpus, `overview -p today` went from about 3.1 s and 690 MB to 0.8 s and 560 MB. Figures are unchanged. (#1550, #1542) +- **Cursor, Codex and Devin are faster.** JSON and menu bar answers no longer wait on cursor.com (menubar-json first output 5.8-7.1 s to 0.7 s on one test), Codex fork parents are read once per process, Devin reads only the metadata fields it uses, and model display names are memoized. (#1724, #1725, #1726, #1727) +- **Flat subscriptions past their price read as API value**, not as an overage bill. (#1723) +- **Desktop reports a missing bundled CLI differently from a refused one**, and its "CLI not found" panel gives app advice instead of npm. (#1700, #1701) +- **Older menu bar apps load again after a CLI update**, and Update restarts the app. (#1548) +- **README rewritten as a story**; the reference manual moved to docs. (#1527) + +### Fixed (money) +- **Opus 5.5 offline prices:** input $5 to $4, output $25 to $20, cache read $0.50 to $0.20 per million. (#1537) +- **Export days match the report**, an invented cache-write premium is gone, and two pricing fixes. (#1544) +- **Long-context tiers:** Codex calls above 272k use the long-context price (cache writes count toward the threshold), and Claude calls use Anthropic's tiers (Haiku 5.5 above 100k). (#1478, #1706, #1716) +- **Codex:** Fast-mode turns at priority rates (was a 2 to 2.5x undercount), Auto Review priced by date (was overstated 2x to 25x), Flex tier prices, forked sessions keep their real work, response usage counted once, no-timestamp and queued-prompt fixes. (#1628, #1641, #1642, #1569, #1678, #1571, #1582) +- **Cursor:** Cursor Agent counts every assistant message; CLI sessions in `store.db` are counted; billed CLI sessions are not counted twice; IDE chat transcripts are no longer counted by both `cursor` and `cursor-agent`; stale cursor-agent slices are dropped on days an import covers; the parse always reads the full lookback (a narrow run used to leave wider ranges with 2 calls and $0.06 instead of 1,680 and $16.01 on one machine). (#1513, #1556, #1654, #1717, #1710, #1718, #1634, #1698) +- **Copilot:** agent-host requests and unclosed JetBrains sessions counted, chat-session journals regain input tokens, JetBrains `claude-opus-4.7` priced at $5/$25 instead of Sonnet rates, older VS Code chat sessions read. (#1651, #1652, #1607, #1705, #1232) +- **Crush** keeps the cost it recorded (it showed about a twelfth before). **Cline CLI** no longer bills cache tokens twice. **OMP** side calls are counted. (#1618, #1675, #1575) +- **OpenCode:** Vertex sessions no longer show $0, and OpenCode 2 users who upgraded from 1.x get their older history back. (#1552, #1543) +- **Pricing and aliases:** a model maker's own price wins over a reseller's, `kimi-for-coding` priced by the model it served on the call date, Mistral Large 4 at its preview rate, Devin SWE-2 at Cognition's list rate, grok-4.6 at xAI rates, DeepSeek v4 and gpt-5.6-codex invariants restored, and Antigravity cache reads counted. (#1653, #1676, #1656, #1674, #1652, #1577, #1655) +- **Bundled prices refreshed:** Sonnet 5.5 cache read $0.20 to $0.10; Haiku 5.5 and GPT-6.1 Sol rows added. (#1697) +- **Projects add up.** Daily project split is keyed per real path, Claude calls go to the project of their own working folder, project rows take money from the day set only, the git origin walk stops at the folder's own repo, and clones group by origin. (#1679, #1689, #1708, #1707) +- **Cache upgrades keep history exact.** One timezone per daily cache with the last seven days re-derived on seal, midnight-straddling turns kept whole, and a timezone change during an upgrade no longer loses or inflates days. (#1696, #1709, #1713) +- **Currency:** with no exchange rate available, amounts show in USD instead of a foreign symbol; the last cached rate is used whatever its age. `overview` no longer prints `$0.0,028`. (#1715, #1714) +- **Optimize findings are honest.** Low-worth sessions no longer flag delivered or editing sessions (lifetime claim $2,499.60 to $449.90 on one machine), and deferred MCP tools are priced as name lines, once per call. No cost totals move. (#1719, #1720) ### Fixed -- **Crush: the session cost Crush records now reaches reports instead of a much smaller re-priced figure.** Crush accumulates each session's cost in `sessions.cost`, but its `prompt_tokens` and `completion_tokens` columns are not session totals; they hold recent usage. Crush was not on the reported-cost pass-through, so the session cache dropped the recorded cost and every read, cold and warm, re-priced the session from those counters. On real data the reported figure was roughly a twelfth of what Crush recorded for the same sessions. A session with a positive recorded cost now keeps it through the cache, the same way Warp, OpenClaw and Pi do; a session that recorded zero keeps the existing behavior and is still priced from its tokens. Crush sessions re-parse once and settled days re-derive (daily cache v49). -- **DeepSeek v4 and the gpt-5.6 Codex SKUs price at their repriced rates again.** The 0.9.21 snapshot had caught the 2026-08-24 repricings mid-transition and relaxed two pricing invariants. LiteLLM has since synced DeepSeek v4 to the official peak rates (pro $1.32/$3.96 per million, flash $0.30/$1.20, off-peak half), so the stale hand-pinned entries are gone and bare and `deepseek/`-prefixed rows agree again. The same refresh exposed a bundler ordering defect: a reseller's `openrouter/deepseek/deepseek-v4-pro` row could claim the `deepseek/deepseek-v4-pro` slot ahead of the official row (whichever came first in the upstream JSON), quietly pricing the namespaced id at the resale rate, ~40% under official peak. A prefixed row may now fill only a slot that is not itself an upstream entry, so the official row always wins and the fill no longer depends on key order. The same fix reprices `zai/glm-4.6` from the vercel_ai_gateway resale row ($0.45/$1.8 per million) to the official row ($0.6/$2.2), and moves `mistral/codestral-embed` and `mistral/mistral-embed` to the fallback table at unchanged rates. The gpt-5.6-codex and gpt-5.6-codex-max entries, still absent from LiteLLM, now mirror the repriced gpt-5.6 base row verbatim including its >272k tier, restoring the codex-equals-base assertion at $4/$20 per million. DeepSeek v4 sessions re-price on read; no re-parse needed. (#1134) -- **Codex long-context calls now use the published higher rates at their prompt-token threshold.** The bundled and live pricing readers retain LiteLLM's context tiers, and Codex applies them per call; below-threshold calls and other providers retain base pricing, and explicit price overrides still win. Audit totals use the same per-call calculation. Pricing and daily-cache versions advance so warm history re-derives with the corrected rates. The regenerated bundle preserves the snapshot refresh's base-rate guards and exact-key carry-forward for previously priced models. (#1076) -- Bundled pricing is refreshed: 5,896 primary entries (298 added, 158 repriced by upstream, 364 no-longer-published ids moved to the fallback table, which grows from 212 to 543). `grok-4.6` base input moves to $1.25/M; `claude-opus-4` is pinned at Anthropic list price since LiteLLM dropped it. -- **OMP: side calls outside the chat transcript now count.** OMP logs `find`, `judge`, TTSR checks and cache warming as `model_usage` entries, not assistant messages, and the parser read only messages, so that spend was missing. On one real week that was 605 `find` decisions on TypeSafe Jev and a $0.048 cache warm. Each side call now counts at its reported cost, with its purpose in the tools column as `omp:find`, `omp:cache-warm` and so on, and a zero reported cost is priced from tokens. A side call never duplicates an assistant turn. OMP sessions re-parse once and settled days re-derive (daily cache v41). -- **Text selection works again in the terminal dashboard.** Mouse tracking, added in 0.9.20 so the wheel scrolls the view, makes the terminal send mouse events to the application, and in most terminals that stops click and drag from selecting text, so copying a number out of the dashboard needed Shift held down. Tracking is now off by default and `m` turns it on and off while the dashboard runs. -- **Cursor Enterprise and team seats connect in Plan & Usage instead of showing an unrecognized quota response.** Their `usage-summary` carries no individual meter, only a team on-demand meter, which now fills the bar under the Enterprise plan label. On other plans it is listed beside the monthly window. In the macOS app a rate limit, outage or unrecognized response no longer adds sign-in guidance, which is only shown when the session is missing or rejected. Reported in #1546, thanks @paulodearaujo. -- **Windows: the Claude quota shows for Claude Code installed only inside WSL.** The tray's Plan panel and `codeburn quota` read the credential from the Windows profile only, so a WSL-only login showed nothing. Both now also read `~/.claude/.credentials.json` in each running distro's home over `\\wsl$`, never waking a stopped distro and never writing to it; when both sides are logged in, the credential that expires last wins. A slow or unreachable share times out instead of holding up the poll. `CODEBURN_WSL=off` skips it. (#1061) -- **VS Code Copilot: OTel conversations land on their workspace instead of `copilot-chat`.** Copilot Chat's OTel spans name the conversation but carry no repository attribute CodeBurn could read, so every conversation from `agent-traces.db` went to `copilot-chat`. A conversation is now matched to the workspace whose `chatSessions` folder holds its session id, and named from that workspace's `workspace.json`, including multi-root and remote/WSL workspaces. Only the project split moves; cost, tokens and calls are unchanged. Copilot re-parses once and settled days re-derive (daily cache v38); calls already pruned from `agent-traces.db` keep `copilot-chat`. Reported in #1529, thanks @jDegut. -- **OpenCode: Vertex turns and turns CodeBurn cannot price no longer show $0.** OpenCode 2 records a Vertex model as `{"id":"claude-sonnet-5@default","providerID":"google-vertex-anthropic"}`. Per-message turns already price on the bare id; the session-level rollup still used `provider/model` and now uses the bare id too. Turns through `google-vertex` and `google-vertex-anthropic` land on their own row, like "Sonnet 5 (Vertex)". A turn whose model CodeBurn cannot price used the cost OpenCode recorded only until it reached the session cache, which re-priced it from tokens to $0 on every read; that recorded cost now survives the cache, and a price CodeBurn learns later still wins. Applies to Kilo Code too. OpenCode and Kilo Code sessions re-parse once and settled days re-derive. Reported in #1547, thanks @bulwahn. -- **Cursor Agent reads CLI sessions that exist only in `~/.cursor/chats/*/*/store.db`.** The provider read exported transcripts only, so a session with no transcript was never counted. A store is read when its session has no transcript; a session with both is read from the transcript alone, so nothing counts twice. Store turns follow the transcript rules (every assistant message is a turn, the prompt is billed once, tool inputs count as output), take their time from Cursor's per-prompt `` stamp, and are priced at the same Cursor (auto) rate as transcripts, so a session keeps one price whether it is read from its store or from the transcript Cursor writes when it ends. Messages Cursor summarized away on a long session are still read from the older conversation roots the store keeps. The daily cache moves to v39 so settled days re-derive. Reported in #986, thanks @kelchm. -- **Cursor Agent counts every assistant message instead of one turn per user message.** Agentic loops emit dozens of assistant messages per user message and the parser kept only the first (on one real corpus: 247 of 4,946 messages, ~4% of assistant text). Each message now carries the last user message forward, tool_use inputs join the output text, and input tokens use the full user text, billed once per user message, while the display stays truncated at 500 chars. The parse version bump re-parses cached sessions, and the daily cache moves to v36 so settled days re-derive. -- **Windows Store installs: no update offer the Store cannot deliver, and `codeburn menubar` opens the Store copy.** The desktop app read GitHub releases for its update banner, so a Store copy was told about a version still waiting on Store certification and sent to a Store page that did not have it. Store installs now skip the GitHub check and the About update button, like the tray app already did; the Store updates them. `codeburn menubar` spawned the tray exe inside `WindowsApps`, which Windows refuses from outside the package (`spawn EPERM`); it now opens the Store app by its app id, and the desktop app starts the tray. Reported in #1520, thanks @Brandin. -- **Menu bar apps 0.9.14 to 0.9.24 load Today again with a 0.9.25 CLI.** Homebrew and npm update the CLI on their own, and 0.9.25 left `avgCostPerSession` out of a project row whose session count is a lower bound, a key those apps require, so the popover showed "Couldn't load Today". The payload always carries it again, with `sessionCountBasis` still telling 0.9.25 and later not to show a partial average. A new test checks the payload against the keys every released menu bar app requires. Reported in #1541, thanks @AleksandrLiadov. -- **Update in the menu bar app restarts it into the new version.** The app runs `codeburn menubar --force` as its own child, and `pkill`/`pgrep` skip their own ancestors unless told otherwise, so the running app was never stopped: the bundle was replaced and `open` re-activated the old copy. The CLI now finds the app among its ancestors, swaps the bundle, exits, and a detached helper stops the old copy and opens the new one. Run from a terminal, it stops the app first as before. Because the fix is in the CLI, older menu bar apps update correctly once the CLI is current. Reported in #1541, thanks @AleksandrLiadov. -- **OpenCode 2.x: upgraded history is no longer lost and v2 turns price like 1.x ones.** OpenCode 2 copies 1.x sessions into new tables on first start, but the copy drops compaction summary and task-tool turns, skips messages it cannot decode, and can stop partway. CodeBurn read only the copy for any migrated session, so on one real upgraded database more than half the sessions and three quarters of the all-time OpenCode cost disappeared. A session present in both generations now reads its legacy turns first and adds only the turns written after the upgrade, so pre-upgrade figures match 1.x exactly. v2 turns also carried a `provider/model` id that failed pricing for ids like `opencode/claude-haiku-4-5` or `github-copilot/gpt-5` so they showed as $0 in reports; they now use the bare id 1.x used. OpenCode sessions re-parse once. Users on CodeBurn 0.9.24 or older see no OpenCode 2 usage at all and need 0.9.25 or later. Reported by a user. -- **The session cache is stored by day, so a today view reads only today.** Each provider keeps an index of every cached session and one file per day of activity. A narrow range reads the index, the duplicate-check keys and only the sessions it reports on, and saving an appended session rewrites one day instead of a month. Older Claude transcripts are no longer re-parsed on every narrow query. The old cache converts once on first run, a session at a time. On a heavy corpus `overview -p today` drops from about 3.1 s and 690 MB to 0.8 s and 560 MB; on a normal one from 0.54 s to 0.41 s. Figures are unchanged. -- **`overview`, `status` and the menu bar load only the part of the session cache a date range can use.** Cached sessions outside the range stay on disk and are held in memory as small stubs that still take part in duplicate checks and clean-up, so figures are unchanged. Shards are written one session per line, so a month past 512 MB is saved instead of silently re-parsed on every run, and Codex project names come from a small index instead of the whole results cache. On a heavy corpus `overview -p today` drops from about 3.4 GB to 1.2 GB and runs under a 1 GB heap where it used to crash; on a normal one it drops from about 970 MB to 280 MB and gets faster. Closes #1504. -- **An expired Claude Code login is recovered from the Keychain, and `codeburn quota` and the desktop app say so when it cannot be.** Both providers read the OAuth credential from `~/.claude/.credentials.json` and fall back to the macOS Keychain, which is where Claude Code keeps it on a normal Mac. After a 401 the retry re-read the file whatever the credential had come from, so a Keychain user got `Temporarily unavailable.` until Claude Code was run again, and the desktop Plans card kept showing the last percentage it had fetched. A 401 now re-reads the store the credential came from, which picks up a login Claude Code has since renewed. When nothing newer is there and the credential's `expiresAt` has passed, the row reports a terminal failure with a line telling you to run Claude Code once. The desktop app still reads the Keychain only on Check now or a forced refresh, and a background poll keeps that reconnect prompt instead of falling back to Check now. A 401 on a credential still within its life stays transient with its backoff. (#1516, thanks @ozymandiashh) -- Export daily rows now bucket each call on its own day, like the history and the summary, and no longer drop a turn that has no timestamp. -- Cache-write tokens on a non-Anthropic model with no published write price are billed at the input rate, not an invented 1.25x. Daily cache moves to v35 so settled days re-derive. -- An OpenCode or Kilo Code message with output but no recorded usage, and a Copilot transcript call with no token count, are marked estimated instead of reading as a measured $0. -- Export records keep each call's unrounded cost, so they sum to the summary, and gain `estimated` and `unpriced` columns at the end. +- **Antigravity:** a response without a model no longer drops the whole session, and the Gemini 3.8 Flash placeholder maps to its real model. (#1712) +- **Providers:** OpenClaw per-agent SQLite store, Mistral Vibe Unified Harness, DeepSeek Harness v4, Grok Build `logs/unified.jsonl`, Kimi Code `state.json` cwd, Hermes in Windows local app data, Copilot and Cursor under redirected APPDATA and XDG roots, Claude 3p Cowork sessions on macOS, Crush projects registry, ZCode PR links, Antigravity tool parsing, Antigravity 2.16 quota decoding and model fallbacks, Copilot OTel conversations on their workspace. (#1526, #1599, #1612, #1677, #1688, #1617, #1624, #1579, #1510, #1581, #1620, #1667, #1712, #1555) +- **Claude quota** recovers an expired login from where it came from (macOS Keychain). (#1516) +- **Compare** skips a provider whose discovery fails, and no longer requests a model pair the period does not list (nonzero compare exits went from 9 installs to 81 between 0.9.24 and 0.9.25). (#1643, #1721) +- **Desktop** status, yield and act no longer disable serve after slow replies, and GLM-5.3 shows as Flagship and its Flash variants as Fast. (#1644, #1598) +- **Windows Capacity Dock** shows multi-profile Claude data. (#1594) +- **Docs:** Claude Cowork cloud tasks from Oct 6 and other Claude Code usage not in transcripts are not tracked locally. (#1639, #1693) + +### Internal +- Signed macOS menu bar and desktop workflows import the Developer ID cert in CI. (#1731) +- Stray `dist-*` build folders removed and ignored. (#1704) +- `npm run dev` works on Windows. (#1629) +- Test and CI hang fixes. (#1559, #1576, #1649) +- Dependency and security updates, including lockfile fixes for open alerts. (#1699 and Dependabot) + +### Thanks +@ozymandiashh, @uttamdeb, @Swizzy, @iliaal, @dcaldr, @ptstory, @sm0keyyy, @JaylyDev, @akapti, @oEscal, @Yi-111-a, @mvanhorn, and for the reports behind fixes @paulodearaujo, @bulwahn, @jDegut, @kelchm, @Brandin and @AleksandrLiadov. ## 0.9.25 - 2026-09-21 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f2ab66bf5..94bb86a35 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thanks for your interest. This document covers what you need to know to send a w ## Prerequisites -- Node.js 22.20 or newer (`engines.node` in `package.json`). +- Node.js 22.13.0 or newer (`engines.node` in `package.json`). - npm 10 or newer (ships with recent Node). - macOS or Linux for full provider coverage. Windows works for most providers but Cursor / Antigravity development is easier on macOS. - Optional: Swift 6 toolchain if you are touching the macOS menubar (`mac/`). @@ -18,7 +18,19 @@ cd codeburn npm install ``` -There is no separate build step required to run the dev CLI. `npm run dev` runs `tsx` against `src/cli.ts` directly. +There is no separate build step required to run the dev CLI. `npm run dev` loads +`tsx` through Node's `--import` flag and runs `src/cli.ts` directly. The command +works with npm's default shell on Windows, macOS, and Linux; no shell configuration +or extra environment assignment is required. Deprecation warnings are suppressed +for this invocation, and any existing `NODE_OPTIONS` are retained. + +Use `--` to pass CLI arguments through npm. For a quick setup check that does not +scan your session history, run: + +```bash +npm run dev -- --version +npm run dev -- status --help +``` ## Common Commands diff --git a/README.md b/README.md index 581d047f6..e27055c54 100644 --- a/README.md +++ b/README.md @@ -25,23 +25,23 @@ Sponsor

-

If CodeBurn shows you something your bill never did, star the repo so other developers find it, and consider sponsoring to keep 41 integrations honest.

+

If CodeBurn shows you something your bill never did, star the repo so other developers find it, and consider sponsoring to keep 42 integrations honest.

npx codeburn

To keep it: npm install -g codeburn or brew install codeburn. Needs Node.js 22.13+.

- Download CodeBurn for macOS (Apple Silicon) - Download CodeBurn for macOS (Intel) + Download CodeBurn for macOS (Apple Silicon) + Download CodeBurn for macOS (Intel) Get CodeBurn from the Microsoft Store - Download the CodeBurn Windows installer (.exe) - Download CodeBurn for Linux (.deb) - Download CodeBurn for Linux (.rpm) - Download CodeBurn for Linux (AppImage) + Download the CodeBurn Windows installer (.exe) + Download CodeBurn for Linux (.deb) + Download CodeBurn for Linux (.rpm) + Download CodeBurn for Linux (AppImage)

-

Desktop app 0.9.25. The macOS builds are signed with a Developer ID and notarized by Apple.

+

The macOS builds are signed with a Developer ID and notarized by Apple.

## The problem @@ -142,7 +142,7 @@ codeburn menubar On Linux the same view lives in the top panel, as a GNOME Shell extension. -The desktop app runs on Windows. Install it from the [Microsoft Store](https://apps.microsoft.com/detail/9P0R4ZL5XMB8), which is the recommended way and keeps itself up to date, or take the [direct installer](https://github.com/getagentseal/codeburn/releases/download/desktop-v0.9.25/CodeBurn-Setup-0.9.25.exe). The Windows tray app shows today's cost next to the clock, the same way the macOS menu bar does, and clicking it opens the same popover. The Capacity Dock is there too, from the tray menu or the app's Plugins page. If you run your agents inside WSL, CodeBurn reads the distro's home directory as well as your Windows profile, so sessions you ran in Linux are counted without you installing anything twice. +The desktop app runs on Windows. Install it from the [Microsoft Store](https://apps.microsoft.com/detail/9P0R4ZL5XMB8), which is the recommended way and keeps itself up to date, or take the [direct installer](https://codeburn.app/download/windows-exe). The Windows tray app shows today's cost next to the clock, the same way the macOS menu bar does, and clicking it opens the same popover. The Capacity Dock is there too, from the tray menu or the app's Plugins page. If you run your agents inside WSL, CodeBurn reads the distro's home directory as well as your Windows profile, so sessions you ran in Linux are counted without you installing anything twice. Setup for all three platforms, including WSL, is in [Menu bar and tray](docs/menubar.md). @@ -156,7 +156,7 @@ That registers a local MCP server over stdio. Your agent can then answer "where It reads the same files on disk that the CLI reads. The server answers from that local data and makes no network call of its own, and project names are pseudonymized unless the agent asks for them. -## Works with 41 tools +## Works with 42 tools CodeBurn detects the tools you already use. There is nothing to configure and no folder to point it at. If a tool is installed and has sessions on disk, it shows up. Each logo links to that tool's page. @@ -198,12 +198,12 @@ CodeBurn detects the tools you already use. There is nothing to configure and no

-All 41 tools +All 42 tools Each page lists where that tool keeps its data, the format it uses, and the quirks CodeBurn works around. [Amp](docs/providers/amp.md) · [Antigravity](docs/providers/antigravity.md) · [Claude Code](docs/providers/claude.md) · [Cline](docs/providers/cline.md) · [Cline CLI](docs/providers/cline-cli.md) · [Codebuff](docs/providers/codebuff.md) · [Codex](docs/providers/codex.md) · -[CodeWhale](docs/providers/codewhale.md) · [Copilot](docs/providers/copilot.md) · [Crush](docs/providers/crush.md) · [Cursor](docs/providers/cursor.md) · [Cursor Agent](docs/providers/cursor-agent.md) · [DeepSeek Harness](docs/providers/dsh.md) · +[CodeWhale](docs/providers/codewhale.md) · [Command Code](docs/providers/command-code.md) · [Copilot](docs/providers/copilot.md) · [Crush](docs/providers/crush.md) · [Cursor](docs/providers/cursor.md) · [Cursor Agent](docs/providers/cursor-agent.md) · [DeepSeek Harness](docs/providers/dsh.md) · [Devin](docs/providers/devin.md) · [Droid](docs/providers/droid.md) · [Forge](docs/providers/forge.md) · [Gemini CLI](docs/providers/gemini.md) · [Goose](docs/providers/goose.md) · [Grok Bot](docs/providers/grokbot.md) · [Grok Build](docs/providers/grok.md) · [Hermes Agent](docs/providers/hermes.md) · [IBM Bob](docs/providers/ibm-bob.md) · [KiloCode](docs/providers/kilo-code.md) · [Kimi](docs/providers/kimi.md) · [Kimi Code](docs/providers/kimicode.md) · [Kiro](docs/providers/kiro.md) · [LingTai TUI](docs/providers/lingtai-tui.md) · [Mistral Vibe](docs/providers/mistral-vibe.md) · [Mux](docs/providers/mux.md) · [OMP](docs/providers/omp.md) · [Open Design](docs/providers/open-design.md) · @@ -257,7 +257,7 @@ MIT licensed. Development happens in this repo. Salesforce

-

Keeping 41 integrations working takes constant time. The tools underneath change often, and each change means a config path to follow or a stored format to relearn. Sponsorship pays for that work.

+

Keeping 42 integrations working takes constant time. The tools underneath change often, and each change means a config path to follow or a stored format to relearn. Sponsorship pays for that work.

Sponsor CodeBurn @@ -280,6 +280,7 @@ MIT licensed. Development happens in this repo. | [How it works](docs/how-it-works.md) | Pricing, task categories, and where each tool keeps its data | | [Optimize](docs/optimize.md) | What is scanned, what `--apply` writes, how to read the grade | | [Menu bar and tray](docs/menubar.md) | macOS, Windows (including WSL), and the Linux GNOME extension | +| [VS Code extension](docs/vscode.md) | Status bar, summary and dashboard in VS Code, Cursor, Windsurf, Antigravity and VSCodium | | [Plans and quota](docs/plans-and-quota.md) | Subscription tracking and live provider limits | | [Guard](docs/guard.md) | Budget caps for Claude Code | | [Web dashboard](docs/web.md) | The browser view, and combining usage across your devices | @@ -361,7 +362,7 @@ The dollar figure is what your tokens would have cost at API rates. It is not an

Is my plan the right size, or am I paying for capacity I never use? -Set what you pay for with `codeburn plan set claude-max`, or `claude-pro`, `cursor-pro`, `copilot-pro`, or `custom --monthly-usd 200 --provider codex`. The Plans page then shows spend this cycle against that budget with a pacing line, either On track or on pace to exceed with the projected figure and the date. After two or three cycles the pattern is clear. Consistently under a quarter of the plan means you are buying capacity you do not use, and consistently over means the cheaper plan is costing you. CodeBurn shows the share and the pace, and leaves the choice of plan to you. +Set what you pay for with `codeburn plan set claude-max`, or `claude-pro`, `cursor-pro`, `copilot-pro`, `google-ai-pro`, or `custom --monthly-usd 200 --provider codex`. The Plans page then shows spend this cycle against that budget with a pacing line, either On track or on pace to exceed with the projected figure and the date. After two or three cycles the pattern is clear. Consistently under a quarter of the plan means you are buying capacity you do not use, and consistently over means the cheaper plan is costing you. CodeBurn shows the share and the pace, and leaves the choice of plan to you.
@@ -414,7 +415,7 @@ From the tools the session used and the words in your own messages, with no mode
On a subscription the dollars are an estimate. What is estimated and what is measured? -The tokens are measured for most tools. Claude Code, Codex, Gemini, Zed, OpenCode and others write real per-call input, output and cache counts into their own session files, and CodeBurn reads those rather than guessing. The price applied to them is published API pricing, so the dollar figure is arithmetic on measured tokens, not a guess about your bill. A few tools record no counts at all, so Cursor, Kiro and some Copilot sessions are estimated from content length, and those are marked estimated in the tables. `codeburn audit` prints a row per provider and model saying where every number came from. +The tokens are measured for most tools. Claude Code, Codex, Gemini, Zed, OpenCode and others write real per-call input, output and cache counts into their own session files, and CodeBurn reads those rather than guessing. The price applied to them is published API pricing, so the dollar figure is arithmetic on measured tokens, not a guess about your bill. A few tools record no counts at all, so Cursor, Kiro and some Copilot sessions are estimated from content length, and those are marked estimated in the tables. Claude Code also bills some calls it does not write to its transcripts: some advisor calls, and subagent replies that keep only part of their usage. For those, CodeBurn can show less than Claude Code's own cost counter. `codeburn audit` prints a row per provider and model saying where every number came from.
@@ -458,7 +459,7 @@ Register the local MCP server with `claude mcp add codeburn -- npx -y codeburn m
Why is this free, and what is going to cost money later? -CodeBurn is MIT licensed and all of it is in this repository: the CLI, the desktop app, the menu bar and tray apps, the GNOME extension. There is no account, no paid tier and no feature held back for one. It is free because it reads files you already have, which costs nothing to run. What it does cost is time, because 41 integrations sit on top of tools that change their config paths and data formats without warning. [Sponsorship](https://github.com/sponsors/iamtoruk) is what pays for keeping up with them. +CodeBurn is MIT licensed and all of it is in this repository: the CLI, the desktop app, the menu bar and tray apps, the GNOME extension. There is no account, no paid tier and no feature held back for one. It is free because it reads files you already have, which costs nothing to run. What it does cost is time, because 42 integrations sit on top of tools that change their config paths and data formats without warning. [Sponsorship](https://github.com/sponsors/iamtoruk) is what pays for keeping up with them.
@@ -502,7 +503,9 @@ The other events are name-only: | `app_open`, `app_close` | Session length in whole minutes | | `section_view` | Which section you opened (`overview`, `spend`, …) | | `cold_start` | Milliseconds to the first painted overview, and whether it timed out | -| `cli_error` | Error kind and the command name, capped at 20 per kind per day | +| `cli_error` | Error kind, the command name, how long it ran as a range (`<1s` to `120s+`), the exit code or signal, a reason label (`lock-busy`, `oom`, `eacces`, `enoent`, `network`, `parse`, `shutdown`, `serve`, `other`) picked on your machine from the error text, which is never sent, and the provider when the read was for one provider. Capped at 20 per kind per day | +| `provider_read_fail` | A provider whose data could not be read: its name, the stage (`locate` or `parse`) and the error kind (`eacces`, `busy`, `enoent`, `malformed`, `error`). Once a day per provider, stage and kind | +| `update_result` | How a one-click update went: the version before and after, and `ok`, `download_fail`, `verify_fail` or `install_fail`. Also sent by the macOS menu bar app and the Windows tray | | `optimize_apply` | The finding id you took a fix for (`unused-mcp`, `claude-md-too-long`, …) and the fix type | | `plan_set` | Provider and plan preset id | | `export` | Format (`csv` or `json`) and provider | diff --git a/RELEASING.md b/RELEASING.md index 22d4e2633..1db6d1090 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -2,9 +2,9 @@ This document describes the actual steps a maintainer takes to cut CLI, macOS menubar, and Electron desktop releases. CLI releases are run by hand with `npm publish`; macOS menubar releases are automated by `.github/workflows/release-menubar.yml` when a `mac-v*` tag is pushed. -The Electron desktop app (`app/`) is released manually under `desktop-v` tags. Build macOS and Linux artifacts as described in `app/DISTRIBUTION.md`; the tag also runs the read-only `Build Windows installer` workflow on `windows-latest`. Download its `CodeBurn-Windows-Installer` artifact and upload both the `.exe` and `.exe.blockmap` with the other platform assets. The workflow never publishes release assets. +The Electron desktop app (`app/`) is released manually under `desktop-v` tags. The tag runs two read-only workflows: `Build macOS desktop` (signed, notarized dmgs and zips plus `latest-mac.yml`, artifact `CodeBurn-macOS`) and `Build Windows installer` on `windows-latest` (artifact `CodeBurn-Windows-Installer`). Build Linux artifacts as described in `app/DISTRIBUTION.md`. Upload the artifacts' files, the Linux files and `latest-linux.yml` to the release. Neither workflow publishes release assets. -Before announcing a desktop release, the release owner must confirm the live GitHub Release contains all four macOS `.dmg`/`.zip` files, the Linux `.AppImage`, `.deb`, and `.rpm`, and both Windows installer files. Publishing the Release runs the workflow's read-only live-asset verification job. If assets are uploaded after publication, rerun `Build Windows installer` with the `release_tag` input and require that verification job to pass. A failed or missing verification is a release blocker. +Before announcing a desktop release, the release owner must confirm the live GitHub Release contains all four macOS `.dmg`/`.zip` files, the Linux `.AppImage`, `.deb`, and `.rpm`, both Windows installer files, `latest-mac.yml` and `latest-linux.yml`. Publishing the Release runs the workflow's read-only live-asset verification job. If assets are uploaded after publication, rerun `Build Windows installer` with the `release_tag` input and require that verification job to pass. A failed or missing verification is a release blocker. Only after it passes does the `publish-update-feeds` job point the desktop update feed at the release (see "Desktop and tray auto-update"). ## Versioning @@ -37,6 +37,22 @@ Verify that the build completes without errors: npm run build ``` +## Release Order + +Every surface's updater reads its own channel, and the menubar and `codeburn update` install the CLI from npm first. Publish in this order, so nothing points at a version a later step has not published yet. 0.9.26 went out the other way round, and menubars that checked in between were told to install a CLI npm did not have yet (npm 404): + +1. `npm publish`. +2. Wait until the tarball downloads: run `npm pack codeburn@` in a temp directory until it writes `codeburn-.tgz`. +3. Homebrew: `brew bump-formula-pr` (see "Homebrew Core") and bump the tap (CLI step 5b). +4. GitHub `v` release. +5. Push `mac-v` (writes `menubar-latest.json`). +6. Push `windows-v` (writes `windows-latest.json`). +7. Publish the `desktop-v` release (writes `latest*.yml`). + +Push tags one at a time and wait for each workflow to start. GitHub drops the push events when more than three tags arrive in one push, so `git push --tags` can leave a release with no workflow run. + +The README and the website link to `https://codeburn.app/download/`, which redirects to whatever the update feeds name. Neither needs a version bump per release. + ## CLI Release Process ### 1. Update the Version @@ -143,30 +159,24 @@ git tag mac-v0.9.8 git push origin mac-v0.9.8 ``` -### 3. GitHub Actions Builds the Bundle - -The `.github/workflows/release-menubar.yml` workflow automatically detects the `mac-v*` tag and: +### 3. GitHub Actions Builds, Signs, and Notarizes the Bundle -1. Checks out the repo -2. Runs `mac/Scripts/package-app.sh v0.9.8` -3. Signs the app bundle with the Developer ID Application certificate, notarizes it with Apple, and staples the ticket -4. Creates a zip file: `CodeBurnMenubar-v0.9.8.zip` -5. Computes a SHA-256 checksum: `CodeBurnMenubar-v0.9.8.zip.sha256` -6. Uploads both to a GitHub Release named "Menubar v0.9.8" +The `.github/workflows/release-menubar.yml` workflow runs on the `mac-v*` tag and: -The script output on the build machine shows: +1. Fails right away if any signing secret below is missing. It never publishes an ad-hoc build. +2. Imports the Developer ID Application certificate into a temporary keychain. +3. Runs `mac/Scripts/package-app.sh v0.9.8`, which builds the universal app, signs it with hardened runtime and a secure timestamp, notarizes it with `notarytool` using the App Store Connect API key, staples the ticket, then zips it to `CodeBurnMenubar-v0.9.8.zip` and writes `CodeBurnMenubar-v0.9.8.zip.sha256`. +4. Unzips the result and checks it the way the installer will: checksum, `codesign --verify --strict` against team `XRVP7P7F9M`, and `spctl --assess`. +5. Uploads both files to a GitHub Release named "Menubar v0.9.8". +6. Downloads the published zip, confirms it matches the verified build, and only then rewrites `menubar-latest.json` on the `update-feeds` prerelease (see below). -``` -✓ Built /path/mac/.build/dist/CodeBurnMenubar-v0.9.8.zip -✓ Checksum /path/mac/.build/dist/CodeBurnMenubar-v0.9.8.zip.sha256 - CodeBurnMenubar-v0.9.8.zip -``` +No manual re-signing, notarizing, or re-uploading is needed. The repository secrets it needs are listed under "Secrets" below. -No manual action is needed; the workflow handles everything. +To build a signed release locally, run `package-app.sh` with `CODESIGN_IDENTITY`, `NOTARY_KEY_PATH`, `NOTARY_KEY_ID` and `NOTARY_ISSUER_ID` set. ### 4. Verify the Release -After the workflow completes, the GitHub Release page shows the zip and sha256 files. The installed CLI command `codeburn menubar --force` fetches the newest `mac-v*` menubar release that includes both assets, verifies the checksum and bundle identity, and installs it into `~/Applications`. +After the workflow completes, the GitHub Release page shows the zip and sha256 files, and the `update-feeds` prerelease carries a `menubar-latest.json` naming the new version. `codeburn menubar --force` installs the `mac-v*` release matching the CLI version. When that is missing, it reads the feed, and if the feed is unavailable it scans recent `mac-v*` releases. It refuses any bundle that fails the checksum, bundle id, Developer ID team (`XRVP7P7F9M`) or Gatekeeper check. ## Homebrew Core @@ -178,19 +188,58 @@ brew bump-formula-pr codeburn --url "https://registry.npmjs.org/codeburn/-/codeb Users install with `brew install codeburn` and upgrade with `brew upgrade codeburn`. -## Replacing Assets on an Existing Release +## Update Feeds -If a release is published with broken assets (e.g., a menubar zip with a build error), re-run the build and upload the fixed assets without creating a new tag. +GitHub's "Latest release" is whichever line (`v*`, `mac-v*`, `desktop-v*`, `windows-v*`) was published last, so nothing should read `/releases/latest`. Update clients read fixed files on the rolling `update-feeds` prerelease instead, at `https://github.com/getagentseal/codeburn/releases/download/update-feeds/`: -Use `gh release upload` with the `--clobber` flag to overwrite existing files: +- `menubar-latest.json`: `{"version": "0.9.8", "url": "", "sha256": ""}`. The menubar's update check and `codeburn menubar` read it. Written by `release-menubar.yml`. +- `latest-mac.yml`, `latest-linux.yml` and `latest.yml`: the desktop app (electron-updater). Written by the `publish-update-feeds` job in `build-windows-installer.yml`. +- `windows-latest.json`: the Windows tray (tauri-plugin-updater). Written by `release-menubar-windows.yml`. -```bash -# After re-running mac/Scripts/package-app.sh v0.9.8 to regenerate the zip and sha256 -gh release upload mac-v0.9.8 mac/.build/dist/CodeBurnMenubar-v0.9.8.zip --clobber -gh release upload mac-v0.9.8 mac/.build/dist/CodeBurnMenubar-v0.9.8.zip.sha256 --clobber -``` +`update-feeds` holds only metadata; the files point at the versioned `mac-v*`, `desktop-v*` and `windows-v*` releases that hold the downloads. Whichever workflow runs first creates it with the same flags (`--prerelease --latest=false`, title "Update feeds"), and each workflow replaces only its own files with `gh release upload --clobber`. Keep it a prerelease, never mark it Latest, and never edit it by hand except to roll back. + +## Desktop and tray auto-update + +### Feed upload order + +Feeds go up last, only after every asset they reference is live: + +1. Desktop: upload all assets to the `desktop-v` release and publish it. `verify-release-assets` checks the asset list; then `publish-update-feeds` downloads the release's `latest*.yml`, rewrites their file names to absolute `desktop-v` URLs (`app/scripts/update-feed.mjs`, which fails on any file the release lacks), refuses to move the feed to an older version, and uploads them to `update-feeds`. +2. Tray: `release-menubar-windows.yml` signs the MSI, creates the `windows-v` release, confirms the MSI is on it, then writes `windows-latest.json` (version, minisign signature, MSI URL) and uploads it to `update-feeds`. + +To hold an update back, do not publish the release (desktop) or do not push the tag (tray). -The GitHub Release page will now serve the fixed assets. The menubar installer selects the newest `mac-v*` release with `CodeBurnMenubar-v*.zip` plus its checksum, so users who run `codeburn menubar --force` after the replacement get the fixed version automatically. +### Windows NSIS auto-update + +Off. The switch is `WINDOWS_AUTO_UPDATE` in `app/electron/auto-update.ts`; with it `false`, Windows keeps the link banner and `latest.yml` on the feed is ignored. Before turning it on, pick one: + +- **A, Authenticode.** Buy a code-signing certificate, sign the NSIS installer in `build-windows-installer.yml` (`WIN_CSC_LINK` / `WIN_CSC_KEY_PASSWORD`), and set `build.win.signtoolOptions.publisherName` in `app/package.json`. electron-updater then refuses any installer not signed by that publisher. SmartScreen stops warning too. +- **B, hash only.** No certificate. electron-updater checks the installer against the sha512 in `latest.yml`, which proves the download is intact, not who built it: anyone who can write to the release and the feed can ship code. SmartScreen keeps warning on first install. + +Then set the constant to `true`, rebuild, and add `latest.yml` to the required list in `app/scripts/verify-windows-installer.mjs`. + +## Secrets + +Repository secrets (Settings > Secrets and variables > Actions). All are set today. + +| Secret | Used by | What it holds | +| --- | --- | --- | +| `MACOS_CERT_P12_BASE64` | `release-menubar.yml`, `build-desktop-mac.yml` | The "Developer ID Application: Resham Joshi (XRVP7P7F9M)" certificate and private key, exported as .p12, base64 encoded (`base64 -i cert.p12`) | +| `MACOS_CERT_PASSWORD` | `release-menubar.yml`, `build-desktop-mac.yml` | The password the .p12 was exported with | +| `MACOS_KEYCHAIN_PASSWORD` | `release-menubar.yml` | Any random string; it locks the throwaway CI keychain | +| `APPSTORE_API_KEY_P8_BASE64` | `release-menubar.yml`, `build-desktop-mac.yml` | The App Store Connect API key (`AuthKey_.p8`, Developer role or higher), base64 encoded, for notarization | +| `APPSTORE_API_KEY_ID` | `release-menubar.yml`, `build-desktop-mac.yml` | That key's Key ID | +| `APPSTORE_API_ISSUER_ID` | `release-menubar.yml`, `build-desktop-mac.yml` | The Issuer ID shown above the key list in App Store Connect | +| `TAURI_SIGNING_PRIVATE_KEY` | `release-menubar-windows.yml` | Tray updater private key | +| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | `release-menubar-windows.yml` | Its password | + +Without the macOS secrets the menubar release and `Build macOS desktop` fail; the manual signed desktop build in `app/DISTRIBUTION.md` still works, but it must build both arches in one `electron-builder --mac` run so one `latest-mac.yml` lists both zips. + +The tray key's public half is `plugins.updater.pubkey` in `windows/src-tauri/tauri.conf.json`. To rotate it, run `npx @tauri-apps/cli signer generate -w ~/.tauri/codeburn-tray.key`, then replace the two secrets and the pubkey in one go: a private key without its matching pubkey fails the tray build. Losing the private key means every installed tray needs a manual update to a build with a new key. Without the secret the workflow builds the MSI with `npm run tauri build -- --no-sign` and leaves `windows-latest.json` alone, so trays keep their release-page link. That step runs under `shell: bash`; PowerShell drops the `--` and the flag never reaches the Tauri CLI. + +## Never Replace Assets on an Existing Release + +Do not re-upload or `--clobber` assets on a published `v*`, `mac-v*`, `desktop-v*` or `windows-v*` release. Installed copies, checksums and the feeds already point at them: the feeds carry each file's sha256 (menubar), sha512 (desktop) or signature (tray), so a replaced file fails verification on every client. If a build is broken, cut a new patch release instead. The only release whose files change is `update-feeds`: CI writes it, and a rollback (below) restores it by hand. ## Rollback @@ -203,8 +252,18 @@ git push origin --delete v0.9.8 npm does not allow republishing to the same version. If you must unpublish from npm, use `npm unpublish codeburn@0.9.8 --force` (requires Owner role), but this is discouraged and all users who installed that version retain it. -For the menubar, tag a new mac-v0.9.9 and let the workflow build and upload it. Users will see the update pill in the menubar settings and upgrade automatically (or manually via `codeburn menubar --force`). +For the menubar, tag a new mac-v0.9.9 and let the workflow build and upload it; the feed moves to it when the workflow finishes. Users see the update pill and upgrade from it (or manually via `codeburn menubar --force`). + +To stop a bad menubar release from spreading before the fix is out, point the feed back at the previous good release: + +```bash +gh release download update-feeds -p menubar-latest.json -O /tmp/menubar-latest.json +# set version, url and sha256 back to the previous mac-v release (sha256 is in its .zip.sha256) +gh release upload update-feeds /tmp/menubar-latest.json --clobber +``` + +That stops the update pill from offering the bad version. A CLI at the bad version still installs its matching `mac-v*` release, so the real fix is still the new patch release. ## Summary -The CLI release is manual: bump the version, update `CHANGELOG.md`, commit, run `npm publish`, then tag and create a GitHub Release. The macOS menubar release is automated: pushing a `mac-v*` tag fires `.github/workflows/release-menubar.yml`, which builds, signs, zips, and publishes the bundle. The Electron desktop release is assembled manually under a `desktop-v*` tag, with the release-authoritative Windows NSIS installer built by the read-only `windows-latest` workflow. The homebrew-core formula is updated automatically or via `brew bump-formula-pr`. +The CLI release is manual: bump the version, update `CHANGELOG.md`, commit, run `npm publish`, then tag and create a GitHub Release. The macOS menubar release is automated: pushing a `mac-v*` tag fires `.github/workflows/release-menubar.yml`, which builds, signs with Developer ID, notarizes, staples, zips, publishes the bundle, and then moves the `update-feeds` pointer. The Electron desktop release is assembled manually under a `desktop-v*` tag, with the release-authoritative Windows NSIS installer built by the read-only `windows-latest` workflow. The homebrew-core formula is updated automatically or via `brew bump-formula-pr`. diff --git a/app/DISTRIBUTION.md b/app/DISTRIBUTION.md index 5adfe6e61..689e39a18 100644 --- a/app/DISTRIBUTION.md +++ b/app/DISTRIBUTION.md @@ -245,7 +245,8 @@ electron-builder writes to `app/release/` (gitignored, like `dist/`): - `CodeBurn--arm64.dmg`, `CodeBurn-.dmg` — installer images - `CodeBurn--arm64-mac.zip`, `CodeBurn--mac.zip` — zipped `.app` bundles - `release/mac-arm64/CodeBurn.app`, `release/mac/CodeBurn.app` — the raw unpacked bundles (arm64 and x64 respectively) -- `.blockmap` files alongside each zip/dmg (used by electron-builder's differential-update mechanism; unused since this app has no auto-updater yet) +- `.blockmap` files alongside each zip/dmg (electron-updater uses the zip blockmaps for differential downloads) +- `latest-mac.yml` — the update metadata for both zips, written because both arches build in one run (see "Auto-update" below) Both `dmg` and `zip` targets are built for both `arm64` and `x64` — four artifacts total, not a universal binary. This keeps each download roughly @@ -265,10 +266,9 @@ separate `electron-builder.yml`): convention. - `productName: "CodeBurn"`. - `files`: only `dist/electron/**/*`, `dist/renderer/**/*`, and `package.json`. - The Electron main process has no npm runtime dependencies (only Node/Electron - builtins — see `app/electron/cli.ts` and `app/electron/quota/*.ts`), and the - renderer is a single Vite bundle, so the app's own `node_modules` does not - need to ship at all. (The *bundled CLI* has its own `node_modules`, added to + electron-builder still adds the production `dependencies` to `app.asar`; the + main process needs only `electron-updater` from them (the renderer is a single + Vite bundle). (The *bundled CLI* has its own `node_modules`, added to `Resources/cli/` by the `afterPack` hook — see "The bundled CLI" above.) - `afterPack: "./scripts/after-pack.cjs"` — copies the staged CLI bundle (`app/build/cli`) into `Contents/Resources/cli` after packaging and before @@ -278,7 +278,7 @@ separate `electron-builder.yml`): with a broken/absent seal (`codesign --verify --deep --strict` fails with `code has no resources but signature indicates they must be present`, and Apple Silicon refuses to run it at all). `"-"` is the same ad-hoc identity - `mac/Scripts/package-app.sh` falls back to for the menubar app's local/CI + `mac/Scripts/package-app.sh` falls back to for the menubar app's local builds. This is the local/dev default; signed release builds pass the real Developer ID identity as a CLI override instead of changing this file (see "macOS code signing and notarization" below) — worth baking into `build.mac` @@ -314,8 +314,9 @@ AppImage tooling on first run. `electron-builder --win` produces a single installer in `app/release/`: - **`CodeBurn-Setup-0.9.15.exe`** — the NSIS installer (the version number - tracks `package.json`). A `.exe.blockmap` is written alongside it - (differential-update metadata, unused — no auto-updater yet). + tracks `package.json`). A `.exe.blockmap` and `latest.yml` are written + alongside it; they stay unused while Windows auto-update is off (see + "Auto-update" below). Config (`build.win` + `build.nsis`): @@ -374,11 +375,10 @@ artifact is affected: `package:win:arm64` is a local build today. **Distribution policy.** The Microsoft Store build (Store ID `9P0R4ZL5XMB8`) is the recommended Windows install. This NSIS setup `.exe` and the tray `.msi` under the `windows-v*` releases are a developer preview: unsigned, SmartScreen -warns on first run, and neither route updates itself. The tray app still -reports that a newer version exists and points at the GitHub release; taking it -means re-running `codeburn menubar --force` or downloading the build by hand. -One-click updating comes back once an Authenticode certificate signs the -artifacts. +warns on first run. The desktop installer does not update itself yet (see +"Auto-update" below). The tray app installs a newer `.msi` in one click once +its updater key is set (`RELEASING.md`); until then it points at the GitHub +release and `codeburn menubar --force`. ### Microsoft Store (`package:store`) @@ -458,7 +458,8 @@ Before publishing the GitHub Release, the release owner must download that workflow artifact and manually upload both Windows files along with the four macOS `.dmg`/`.zip` files, `CodeBurn-.AppImage`, `codeburn-desktop__amd64.deb`, and -`codeburn-desktop-.x86_64.rpm`. Confirm the live release contains +`codeburn-desktop-.x86_64.rpm`, plus `latest-mac.yml` (from the +`CodeBurn-macOS` artifact) and `latest-linux.yml` (from the Linux build). Confirm the live release contains every required platform asset before announcing it. The website's download links **pin that tag** in their URLs, so a release with a missing installer is broken even when another Windows distribution channel is @@ -469,6 +470,24 @@ Publishing the Release triggers a read-only live-asset check. If the files are uploaded afterward, rerun the workflow manually with `release_tag` set to the existing `desktop-v` tag and require the verification job to pass. +## Auto-update + +The app uses electron-updater with the `generic` provider pointed at the fixed +`update-feeds` release (`build.publish` in `package.json`), not GitHub's +`/releases/latest`, which the CLI, menubar, tray and desktop lines share. The +`publish-update-feeds` job in `build-windows-installer.yml` rewrites the file +names in `latest*.yml` to absolute `desktop-v` URLs and uploads them +there, after the live-asset check passes. Release order is in `RELEASING.md`. + +Who updates itself (`app/electron/auto-update.ts`): + +- macOS, signed release builds: one Update click downloads, then restarts into it. Ad-hoc local + builds fail the Squirrel signature check and fall back to the download link. +- Linux AppImage only (`APPIMAGE` is set). deb, rpm, snap and Flathub keep the + link banner or their store. +- Windows NSIS: off behind `WINDOWS_AUTO_UPDATE` until a trust model is chosen + (`RELEASING.md`, "Windows NSIS auto-update"). The Store build never checks. + ## Verifying a build ```sh @@ -539,6 +558,16 @@ renderer fails to start: ``` (repeat for the `x64` dmg). +### CI build + +`.github/workflows/build-desktop-mac.yml` runs on every `desktop-v*` tag: it +builds both arches in one run, signs with the Developer ID from +`MACOS_CERT_P12_BASE64`, notarizes the apps and the dmgs with the App Store +Connect API key, verifies them, and uploads a `CodeBurn-macOS` artifact +(dmgs, zips, blockmaps, `latest-mac.yml`) for the release owner to attach. +`build.mac` carries the entitlements; the workflow passes the identity and +hardened runtime as overrides. + ### Release builds vs. the committed config `app/package.json`'s committed `build.mac` still declares `identity: "-"` @@ -561,11 +590,11 @@ settings into `build.mac` directly (falling back to ad-hoc only when no Developer ID identity is present in the keychain) would remove the risk of a release accidentally shipping unsigned. -The menubar app (`mac/Scripts/package-app.sh`) picks up the same identity -through its existing `CODESIGN_IDENTITY` environment variable — set -`CODESIGN_IDENTITY="Resham Joshi (XRVP7P7F9M)"` before running it — and then -needs the same notarize-and-staple pass as the dmg above, run against the -built `.app` before it is zipped. +The menubar app is signed, notarized and stapled in CI by +`.github/workflows/release-menubar.yml` (see RELEASING.md for the secrets). +For a local signed build, `mac/Scripts/package-app.sh` takes the identity in +`CODESIGN_IDENTITY` and notarizes and staples the `.app` before zipping when +`NOTARY_KEY_PATH`, `NOTARY_KEY_ID` and `NOTARY_ISSUER_ID` are also set. ### Ad-hoc is still the fallback diff --git a/app/build/entitlements.mac.plist b/app/build/entitlements.mac.plist new file mode 100644 index 000000000..f612ba4b7 --- /dev/null +++ b/app/build/entitlements.mac.plist @@ -0,0 +1,14 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.allow-dyld-environment-variables + + com.apple.security.cs.disable-library-validation + + + diff --git a/app/demo-bridge.mjs b/app/demo-bridge.mjs index 2b680b3b7..c66849009 100644 --- a/app/demo-bridge.mjs +++ b/app/demo-bridge.mjs @@ -55,6 +55,7 @@ const ROUTES = { getOptimizeReport: (period, provider, r) => ['optimize', '--format', 'json', '--period', period, ...prov(provider), ...range(r)], getAudit: (period, provider, r) => ['audit', '--format', 'json', '--period', period, ...prov(provider), ...range(r)], getActReport: () => ['act', 'report', '--json'], + getSessionWhy: (id) => ['sessions', '--id', id, '--why', '--format', 'json'], getShareStatus: () => ['share', 'status', '--format', 'json'], getIdentity: () => ['identity', '--format', 'json'], getAliases: () => ['model-alias', '--list', '--format', 'json'], diff --git a/app/electron/auto-update.test.ts b/app/electron/auto-update.test.ts new file mode 100644 index 000000000..aaeb63fc5 --- /dev/null +++ b/app/electron/auto-update.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it, vi } from 'vitest' + +import { autoUpdateSupported, createAutoUpdateChecker, downloadFailOutcome, releaseFeedOverride, WINDOWS_AUTO_UPDATE, type Updater } from './auto-update' +import type { UpdateStatus } from './updates' + +function fakeUpdater(version: string | null) { + const listeners: Record void> = {} + const updater = { + autoDownload: true, + checkForUpdates: vi.fn(async () => (version ? { updateInfo: { version } } : null)), + downloadUpdate: vi.fn(async () => { listeners['update-downloaded']?.(new Error('unused')) }), + quitAndInstall: vi.fn(), + on: vi.fn((event: 'update-downloaded' | 'error', listener: (err: Error) => void) => { listeners[event] = listener }), + emitError: (err: Error) => listeners.error?.(err), + } satisfies Updater & { emitError: (err: Error) => void } + return updater +} + +describe('autoUpdateSupported', () => { + const base = { isPackaged: true, mas: false, windowsStore: false } + + it('runs on packaged mac builds and the AppImage only', () => { + expect(autoUpdateSupported({ ...base, platform: 'darwin' })).toBe(true) + expect(autoUpdateSupported({ ...base, platform: 'darwin', isPackaged: false })).toBe(false) + expect(autoUpdateSupported({ ...base, platform: 'darwin', mas: true })).toBe(false) + expect(autoUpdateSupported({ ...base, platform: 'linux', appImage: '/tmp/CodeBurn.AppImage' })).toBe(true) + // deb, rpm, snap and Flathub: no APPIMAGE. + expect(autoUpdateSupported({ ...base, platform: 'linux' })).toBe(false) + }) + + it('keeps Windows on the banner until the build flag is flipped, and never touches Store installs', () => { + expect(WINDOWS_AUTO_UPDATE).toBe(false) + expect(autoUpdateSupported({ ...base, platform: 'win32' })).toBe(false) + expect(autoUpdateSupported({ ...base, platform: 'win32', windowsStore: true })).toBe(false) + }) +}) + +describe('createAutoUpdateChecker', () => { + it('never downloads on its own; one click downloads, then restarts into it with no second click', async () => { + const updater = fakeUpdater('0.9.27') + const seen: UpdateStatus[] = [] + const checker = createAutoUpdateChecker({ updater, currentVersion: '0.9.26', onChange: s => seen.push(s) }) + + expect(updater.autoDownload).toBe(false) + const status = await checker.check() + expect(status).toEqual({ currentVersion: '0.9.26', latestVersion: '0.9.27', updateAvailable: true, tag: 'desktop-v0.9.27', install: 'available' }) + expect(updater.downloadUpdate).not.toHaveBeenCalled() + + checker.install() + await Promise.resolve() + expect(updater.quitAndInstall).not.toHaveBeenCalled() + + await checker.download() + expect(seen.map(s => s.install)).toEqual(['downloading', 'ready']) + await vi.waitFor(() => expect(updater.quitAndInstall).toHaveBeenCalledTimes(1)) + + // A later check does not knock a finished download back to "available", and a stray + // install call does not quit twice. + expect((await checker.check()).install).toBe('ready') + checker.install() + await Promise.resolve() + expect(updater.quitAndInstall).toHaveBeenCalledTimes(1) + }) + + it('runs the quit flush before quitAndInstall, and installs even when the flush throws', async () => { + let release: () => void = () => {} + const order: string[] = [] + const updater = fakeUpdater('0.9.27') + updater.quitAndInstall.mockImplementation(() => { order.push('quitAndInstall') }) + const checker = createAutoUpdateChecker({ + updater, + currentVersion: '0.9.26', + onChange: () => {}, + beforeQuit: () => new Promise(resolve => { release = () => { order.push('flushed'); resolve() } }), + }) + await checker.check() + await checker.download() + await Promise.resolve() + expect(updater.quitAndInstall).not.toHaveBeenCalled() + release() + await vi.waitFor(() => expect(order).toEqual(['flushed', 'quitAndInstall'])) + + const failing = fakeUpdater('0.9.27') + const second = createAutoUpdateChecker({ updater: failing, currentVersion: '0.9.26', onChange: () => {}, beforeQuit: async () => { throw new Error('flush') } }) + await second.check() + await second.download() + await vi.waitFor(() => expect(failing.quitAndInstall).toHaveBeenCalledTimes(1)) + }) + + it('leaves "ready" for a retry when Squirrel refuses the bundle, and restores what the quit stopped', async () => { + const updater = fakeUpdater('0.9.27') + const onInstallFail = vi.fn() + const seen: UpdateStatus[] = [] + const checker = createAutoUpdateChecker({ updater, currentVersion: '0.9.26', onChange: s => seen.push(s), onInstallFail }) + await checker.check() + // An offline check also emits error; it must not count as a failed install. + updater.emitError(new Error('net::ERR_INTERNET_DISCONNECTED')) + expect(onInstallFail).not.toHaveBeenCalled() + + await checker.download() + await vi.waitFor(() => expect(updater.quitAndInstall).toHaveBeenCalledTimes(1)) + updater.emitError(new Error('Code signature at URL file:///x did not pass validation')) + expect(onInstallFail).toHaveBeenCalledTimes(1) + expect(seen.at(-1)).toMatchObject({ install: 'ready', installFailed: true }) + + // Retry: the same downloaded update, a fresh attempt, and the error flag cleared meanwhile. + checker.install() + await vi.waitFor(() => expect(updater.quitAndInstall).toHaveBeenCalledTimes(2)) + expect(seen.at(-1)?.installFailed).toBeUndefined() + updater.emitError(new Error('again')) + expect(onInstallFail).toHaveBeenCalledTimes(2) + }) + + it('gives up waiting after 60 seconds when the app is still running', async () => { + vi.useFakeTimers() + try { + const updater = fakeUpdater('0.9.27') + const onInstallFail = vi.fn() + const checker = createAutoUpdateChecker({ updater, currentVersion: '0.9.26', onChange: () => {}, onInstallFail }) + await checker.check() + await checker.download() + await vi.advanceTimersByTimeAsync(0) + expect(updater.quitAndInstall).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(59_999) + expect(onInstallFail).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(onInstallFail).toHaveBeenCalledTimes(1) + // A late error from the same attempt does not restore twice. + updater.emitError(new Error('late')) + expect(onInstallFail).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('offers nothing when the feed is not newer', async () => { + const checker = createAutoUpdateChecker({ updater: fakeUpdater('0.9.26'), currentVersion: '0.9.26', onChange: () => {} }) + expect(await checker.check()).toEqual({ currentVersion: '0.9.26', latestVersion: '0.9.26', updateAvailable: false, tag: null }) + }) + + it('falls back to the download link when the download fails', async () => { + const updater = fakeUpdater('0.9.27') + updater.downloadUpdate.mockRejectedValueOnce(new Error('sha512 mismatch')) + const checker = createAutoUpdateChecker({ updater, currentVersion: '0.9.26', onChange: () => {} }) + await checker.check() + + const status = await checker.download() + expect(status.install).toBeUndefined() + expect(status.tag).toBe('desktop-v0.9.27') + }) + + it('keeps the last status when the check fails', async () => { + const updater = fakeUpdater('0.9.27') + const checker = createAutoUpdateChecker({ updater, currentVersion: '0.9.26', onChange: () => {} }) + await checker.check() + updater.checkForUpdates.mockRejectedValueOnce(new Error('offline')) + expect((await checker.check()).install).toBe('available') + }) + + it('reports a failed download as download_fail or verify_fail, and marks the install before quitting', async () => { + const results: unknown[] = [] + const installs: unknown[] = [] + const updater = fakeUpdater('0.9.27') + updater.downloadUpdate.mockRejectedValueOnce(new Error('net::ERR_CONNECTION_RESET')) + const checker = createAutoUpdateChecker({ + updater, + currentVersion: '0.9.26', + onChange: () => {}, + onDownloadFail: (outcome, from, to) => results.push({ outcome, from, to }), + onInstall: (from, to) => installs.push({ from, to }), + }) + await checker.check() + await checker.download() + expect(results).toEqual([{ outcome: 'download_fail', from: '0.9.26', to: '0.9.27' }]) + + await checker.check() + await checker.download() + await vi.waitFor(() => expect(updater.quitAndInstall).toHaveBeenCalledTimes(1)) + expect(installs).toEqual([{ from: '0.9.26', to: '0.9.27' }]) + }) + + it('tells a refused checksum or code signature from a failed download', () => { + expect(downloadFailOutcome(Object.assign(new Error('x'), { code: 'ERR_CHECKSUM_MISMATCH' }))).toBe('verify_fail') + expect(downloadFailOutcome(new Error('sha512 checksum mismatch, expected a, got b'))).toBe('verify_fail') + expect(downloadFailOutcome(new Error('Code signature at URL file:///x did not pass validation'))).toBe('verify_fail') + expect(downloadFailOutcome(new Error('HttpError: 404'))).toBe('download_fail') + }) +}) + +describe('releaseFeedOverride', () => { + it('takes a loopback server or a GitHub releases base, and nothing else', () => { + expect(releaseFeedOverride(undefined)).toBeNull() + expect(releaseFeedOverride('')).toBeNull() + expect(releaseFeedOverride('http://127.0.0.1:8787')).toBe('http://127.0.0.1:8787/update-feeds') + expect(releaseFeedOverride('https://github.com/getagentseal/codeburn/releases/download')).toBe('https://github.com/getagentseal/codeburn/releases/download/update-feeds') + expect(releaseFeedOverride('https://evil.example')).toBeNull() + expect(releaseFeedOverride('http://localhost:8787')).toBeNull() + expect(releaseFeedOverride('http://127.0.0.1:8787/')).toBeNull() + expect(releaseFeedOverride('http://127.0.0.1.evil.example:8787')).toBeNull() + expect(releaseFeedOverride('https://github.com/getagentseal/codeburn/releases/download/../../x')).toBeNull() + expect(releaseFeedOverride('https://github.com.evil.example/a/b/releases/download')).toBeNull() + expect(releaseFeedOverride('http://github.com/a/b/releases/download')).toBeNull() + expect(releaseFeedOverride('https://github.com/../../releases/download')).toBeNull() + }) +}) diff --git a/app/electron/auto-update.ts b/app/electron/auto-update.ts new file mode 100644 index 000000000..1b23d8802 --- /dev/null +++ b/app/electron/auto-update.ts @@ -0,0 +1,168 @@ +// One-click updates through electron-updater, reading the fixed update-feeds release rather +// than GitHub's /releases/latest, which four release lines share. Nothing downloads until the +// reader clicks; once the download lands the app restarts into the new version on its own. + +import { compareSemver, type UpdateChecker, type UpdateStatus } from './updates' + +// Off until the maintainer picks Authenticode signing or hash-only trust for the NSIS +// installer (RELEASING.md, "Windows NSIS auto-update"). Windows keeps the link banner. +export const WINDOWS_AUTO_UPDATE = false + +const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 +// quitAndInstall normally quits within seconds; still running after this, it is not going to. +const INSTALL_TIMEOUT_MS = 60_000 + +export function autoUpdateSupported(env: { + platform: string + isPackaged: boolean + mas: boolean + windowsStore: boolean + appImage?: string +}): boolean { + if (!env.isPackaged) return false + if (env.platform === 'darwin') return !env.mas + // Only the AppImage replaces itself; deb, rpm, snap and Flathub update through their package manager. + if (env.platform === 'linux') return Boolean(env.appImage) + if (env.platform === 'win32') return WINDOWS_AUTO_UPDATE && !env.windowsStore + return false +} + +/** The generic feed a test build reads instead of the baked one, from CODEBURN_RELEASE_BASE. + * Only a loopback server or a GitHub releases download base is accepted; anything else is ignored. */ +export function releaseFeedOverride(base: string | undefined): string | null { + if (!base) return null + if (!/^http:\/\/127\.0\.0\.1:\d{1,5}$/.test(base) && !/^https:\/\/github\.com\/[\w-][\w.-]*\/[\w-][\w.-]*\/releases\/download$/.test(base)) return null + return `${base}/update-feeds` +} + +export type Updater = { + autoDownload: boolean + checkForUpdates(): Promise<{ updateInfo: { version: string } } | null> + downloadUpdate(): Promise + quitAndInstall(): void + on(event: 'update-downloaded' | 'error', listener: (err: Error) => void): unknown +} + +export type AutoUpdateChecker = UpdateChecker & { + download(): Promise + install(): void +} + +/** A failed download is a verify failure when the checksum or Squirrel's code signature check refused it. */ +export function downloadFailOutcome(err: unknown): 'download_fail' | 'verify_fail' { + const code = (err as { code?: unknown } | null)?.code + const message = err instanceof Error ? err.message : String(err) + return code === 'ERR_CHECKSUM_MISMATCH' || /checksum|sha512|signature|did not pass validation/i.test(message) + ? 'verify_fail' + : 'download_fail' +} + +export function createAutoUpdateChecker(opts: { + updater: Updater + currentVersion: string + onChange: (status: UpdateStatus) => void + now?: () => number + /** A download that failed, as an outcome enum. */ + onDownloadFail?: (outcome: 'download_fail' | 'verify_fail', from: string, to: string) => void + /** Called when the update is downloaded and again just before quitting into it: from then + * on it installs, at the latest on the next quit. */ + onInstall?: (from: string, to: string) => void + /** Runs before quitAndInstall: the quit flush, done here because a before-quit handler that + * defers the quit would cancel the one quitAndInstall starts. */ + beforeQuit?: () => Promise + /** quitAndInstall failed (Squirrel refused the bundle) or the app is still running after the + * timeout: restore what beforeQuit stopped. The banner then offers a retry. */ + onInstallFail?: () => void + installTimeoutMs?: number +}): AutoUpdateChecker { + const { updater, currentVersion, onChange } = opts + const now = opts.now ?? (() => Date.now()) + updater.autoDownload = false + + let status: UpdateStatus = { currentVersion, latestVersion: null, updateAvailable: false, tag: null } + let lastCheckedAt = 0 + let inflight: Promise | null = null + let installing = false + let installTimer: ReturnType | undefined + + const set = (next: UpdateStatus) => { status = next; onChange(status) } + + const failInstall = () => { + if (!installing) return + installing = false + clearTimeout(installTimer) + set({ ...status, installFailed: true }) + opts.onInstallFail?.() + } + + const install = async () => { + if (status.install !== 'ready' || installing) return + installing = true + if (status.installFailed) { + const { installFailed: _retrying, ...rest } = status + set(rest) + } + opts.onInstall?.(currentVersion, status.latestVersion ?? '') + try { await opts.beforeQuit?.() } catch { /* the install goes ahead without the flush */ } + installTimer = setTimeout(failInstall, opts.installTimeoutMs ?? INSTALL_TIMEOUT_MS) + try { updater.quitAndInstall() } catch { failInstall() } + } + + // Only while installing: electron-updater also emits error for an offline check. + updater.on('error', failInstall) + + // Nothing downloads without a click (or the hand-off from another surface), so a finished + // download restarts straight into the update; `ready` is what the banner shows meanwhile. + updater.on('update-downloaded', () => { + if (status.install !== 'downloading') return + set({ ...status, install: 'ready' }) + void install() + }) + + const check = (): Promise => { + if (inflight) return inflight + inflight = (async () => { + try { + const result = await updater.checkForUpdates() + lastCheckedAt = now() + const latest = result?.updateInfo.version ?? null + if (!latest) return status + // A download already under way or finished is not reset by a later check. + if (status.install === 'downloading' || status.install === 'ready') return status + const updateAvailable = compareSemver(latest, currentVersion) > 0 + status = { + currentVersion, + latestVersion: latest, + updateAvailable, + tag: updateAvailable ? `desktop-v${latest}` : null, + ...(updateAvailable ? { install: 'available' as const } : {}), + } + } catch { + // Offline or no feed yet: keep what was known and retry next cycle. + } finally { + inflight = null + } + return status + })() + return inflight + } + + return { + check, + getStatus: () => (lastCheckedAt !== 0 && now() - lastCheckedAt < CHECK_INTERVAL_MS ? Promise.resolve(status) : check()), + async download() { + if (status.install !== 'available') return status + set({ ...status, install: 'downloading' }) + try { + await updater.downloadUpdate() + } catch (err) { + opts.onDownloadFail?.(downloadFailOutcome(err), currentVersion, status.latestVersion ?? '') + // Fall back to the manual download link rather than a button that keeps failing. + const { install: _dropped, ...rest } = status + set(rest) + } + return status + }, + install() { void install() }, + } +} diff --git a/app/electron/bridge-handlers.ts b/app/electron/bridge-handlers.ts new file mode 100644 index 000000000..0f7182b1a --- /dev/null +++ b/app/electron/bridge-handlers.ts @@ -0,0 +1,1008 @@ +import { randomBytes } from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { CliError, DESKTOP_COLD_TIMEOUT_MS, PROGRESS_LINE_PREFIX, type ActionResult, type SpawnPriority } from './cli' +import type { CompanionUpdateStatus } from './companion-update' +import type { CompanionStatus, MenubarCompanion } from './menubar' +import { NO_MAC_MENUBAR, type MacMenubar } from './mac-menubar' +import { readOptimizeSnapshot, sameLocalDay, writeOptimizeSnapshot, type OptimizeBlock, type OptimizeSnapshot } from './optimize-store' +import { sanitizeError, type getQuota } from './quota' +import { cliErrorReason, durationBucket, type Telemetry } from './telemetry' +import type { UpdateStatus } from './updates' + +// The IPC bridge's channel -> argv mapping, free of any `electron` import so a +// non-Electron host (the VS Code extension) can run the same handlers. + + +/** What the companion card reads on a platform that has no tray app to bundle. */ +export const NO_COMPANION: CompanionStatus = { + supported: false, menuBar: false, sidebar: false, store: false, + canInstall: false, installed: false, running: false, version: null, outdated: false, +} +/** The discrete-action fallback (install/quit/uninstall) where there is no companion. */ +export const NO_COMPANION_ACTION = { ok: false, error: null, status: NO_COMPANION } + +/** The slice of Telemetry the bridge handlers use — injectable for tests. */ +export type TelemetryBridge = Pick + +export const NO_UPDATE_STATUS: UpdateStatus = { currentVersion: '', latestVersion: null, updateAvailable: false, tag: null } + +// Result envelope: handlers never throw across IPC so the structured error +// `kind` survives contextBridge serialization. preload.ts unwraps it. +export type Envelope = { ok: true; value: T } | { ok: false; error: { kind: string; message: string; cold?: true; stage?: string } } + +// The first overview fetch after boot hydrates a cold cache from scratch (a full +// history parse). That can far exceed the 45s read timeout, and killing it means +// the cache never persists, so every later poll restarts the scan — perpetual +// slowness. Give the first (cold) overview a long window; revert to the default +// once it succeeds. Sections gate their own first poll on this one resolving so +// the cold hydration runs ONCE, not once per section in parallel. +const WARMUP_TIMEOUT_MS = DESKTOP_COLD_TIMEOUT_MS + +/** Line-buffer a spawn's stderr and forward each parsed scan-progress event. */ +export function makeProgressReader(emit: (event: unknown) => void): (chunk: string) => void { + let buffer = '' + return chunk => { + buffer += chunk + let nl = buffer.indexOf('\n') + while (nl >= 0) { + const line = buffer.slice(0, nl) + buffer = buffer.slice(nl + 1) + if (line.startsWith(PROGRESS_LINE_PREFIX)) { + try { emit(JSON.parse(line.slice(PROGRESS_LINE_PREFIX.length))) } catch { /* ignore malformed line */ } + } + nl = buffer.indexOf('\n') + } + } +} + +function providerArgs(provider: string | undefined): string[] { + return provider && provider !== 'all' ? ['--provider', provider] : [] +} + +/** Include/exclude patterns scoping every CLI fetch the app makes. */ +export type ProjectFilter = { project: string[]; exclude: string[] } + +const EMPTY_PROJECT_FILTER: ProjectFilter = { project: [], exclude: [] } + +/** + * The saved project filter narrowed to these paths. Its excludes still apply; + * its includes give way, since the paths already name the projects shown. + */ +export function scopeFilter(paths: readonly string[]): (filter: ProjectFilter) => ProjectFilter { + return filter => paths.length === 0 ? filter : { project: [...paths], exclude: filter.exclude } +} + +// A file rather than a build-time constant so it toggles without rebuilding, +// and it lives here, not in renderer storage, because this is where the argv is +// assembled. Re-read whenever the file changes, so a hand edit lands. +let appFilterCache: { path: string; stamp: string; filter: ProjectFilter } | null = null + +/** + * CODEBURN_APP_FILTER overrides the location; an empty string disables the + * filter outright, which is what the test suite sets so a filter file in the + * developer's own home cannot reach the assertions. + */ +function appFilterPath(): string | null { + const override = process.env.CODEBURN_APP_FILTER + if (override !== undefined) return override.trim() === '' ? null : override + return path.join(os.homedir(), '.config', 'codeburn', 'app-filter.json') +} + +/// Drops blanks and duplicates. A leading "-" is KEPT: Claude encodes a project +/// directory as "-Users-me-Web-thing", which is most real projects. +function normalizePatterns(value: unknown): string[] { + // A hand-edit writes one pattern as a bare string; dropping it would unhide. + const entries = typeof value === 'string' ? [value] : value + if (!Array.isArray(entries)) return [] + const patterns = new Set() + for (const entry of entries) { + if (typeof entry !== 'string') continue + const pattern = expandTilde(entry.trim()) + if (pattern === '') continue + patterns.add(pattern) + } + return [...patterns] +} + +/// A pattern typed into the pane has no shell behind it, so "~/work/app" is +/// expanded here, on the way in and on the way out of the file. The renderer +/// has no home directory of its own, and a tilde it cannot resolve would make +/// its switches disagree with the argv this process writes. +function expandTilde(pattern: string): string { + const raw = pattern.replace(/\\/g, '/') + if (raw !== '~' && !raw.startsWith('~/')) return pattern + return os.homedir().replace(/\\/g, '/') + raw.slice(1) +} + +function normalizeProjectFilter(value: unknown): ProjectFilter { + const raw = (value ?? {}) as { project?: unknown; exclude?: unknown } + return { project: normalizePatterns(raw.project), exclude: normalizePatterns(raw.exclude) } +} + +/// A read that failed is NOT an empty filter. Answering with one would run the +/// next fetch unfiltered and paint the projects the file exists to hide, which +/// is the single outcome this pane must never produce. Callers surface this as +/// a panel error instead, so the screen stays empty until the file is readable. +function unreadableFilter(error: unknown): CliError { + const code = (error as NodeJS.ErrnoException).code + return new CliError('nonzero', `Could not read the project filter${code ? ` (${code})` : ''}. Showing nothing rather than the projects it hides.`) +} + +export function readProjectFilter(): ProjectFilter { + const filterPath = appFilterPath() + if (filterPath === null) return EMPTY_PROJECT_FILTER + // mtime alone misses a same-tick rewrite and a cp -p / git checkout restore. + let stamp: string + try { + const stat = fs.statSync(filterPath) + stamp = `${stat.mtimeMs}:${stat.size}:${stat.ino}` + } catch (error) { + // A missing file is the one honest way to have no filter. Every other errno + // (EACCES on the directory, EIO) is a read that failed, and statSync rejects + // them all the same way. + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw unreadableFilter(error) + appFilterCache = null + return EMPTY_PROJECT_FILTER + } + if (appFilterCache?.path === filterPath && appFilterCache.stamp === stamp) return appFilterCache.filter + try { + const filter = normalizeProjectFilter(JSON.parse(fs.readFileSync(filterPath, 'utf8'))) + appFilterCache = { path: filterPath, stamp, filter } + return filter + } catch (error) { + // Unreadable or half-written: keep the last filter, never unhide. The cache + // is per-process, so the first read of a launch has no last filter to keep + // and the failure has to travel instead of being flattened to "show all". + if (appFilterCache?.path === filterPath) return appFilterCache.filter + throw unreadableFilter(error) + } +} + +/** Persists the filter and returns what actually landed, normalization included. */ +export function writeProjectFilter(value: unknown): ProjectFilter { + const filter = normalizeProjectFilter(value) + const filterPath = appFilterPath() + // CODEBURN_APP_FILTER='' disables the filter outright: there is no file to + // write, and the empty filter is what every later read will report. + if (filterPath === null) return EMPTY_PROJECT_FILTER + fs.mkdirSync(path.dirname(filterPath), { recursive: true }) + // Staged and renamed, like saveConfig in src/config.ts and for the same + // reason: a writeFileSync straight over the live path can be interrupted, and + // this is the one file that decides what stays hidden. A truncated filter is + // an unreadable filter, which now costs a visible error on the next read + // instead of a silent unhide, but neither is a state a click should produce. + // The temp name is randomized so two windows saving at once cannot collide. + const tmpPath = `${filterPath}.${randomBytes(8).toString('hex')}.tmp` + try { + fs.writeFileSync(tmpPath, JSON.stringify(filter, null, 2) + '\n') + fs.renameSync(tmpPath, filterPath) + } catch (error) { + fs.rmSync(tmpPath, { force: true }) + throw error + } + appFilterCache = null + return filter +} + +// The shared CLI config. The desktop writes only its `language` and +// `cursorSync` keys; the CLI reads the same fields. Path is fixed +// (os.homedir), matching src/config.ts. +function configPath(): string { + return path.join(os.homedir(), '.config', 'codeburn', 'config.json') +} + +/** The desktop's six locales; absent/other = follow the system. */ +const APP_LOCALES = new Set(['en', 'fr', 'ja', 'ko', 'zh-CN', 'zh-TW']) + +function readConfigKey(key: string): unknown { + try { + return (JSON.parse(fs.readFileSync(configPath(), 'utf8')) as Record)[key] + } catch { + return undefined + } +} + +export function readConfigLanguage(): string | null { + const language = readConfigKey('language') + return typeof language === 'string' && APP_LOCALES.has(language) ? language : null +} + +export function readConfigCursorSync(): boolean { + return readConfigKey('cursorSync') !== false +} + +/** + * Persist config `language` (null clears it), preserving every other key. + */ +export function writeConfigLanguage(language: string | null): void { + writeConfigKey('language', language ?? undefined) +} + +/** On is the CLI's default, so it clears the key rather than storing `true`. */ +export function writeConfigCursorSync(enabled: boolean): void { + writeConfigKey('cursorSync', enabled ? undefined : false) +} + +/** + * Set one config key (undefined removes it), preserving every other key. Staged + * and renamed like writeProjectFilter, so a torn write never corrupts the shared + * config. A missing file starts fresh; any other read error aborts rather than + * clobber a config that is merely unreadable this instant. + */ +function writeConfigKey(key: string, value: unknown): void { + const target = configPath() + let config: Record = {} + try { + config = JSON.parse(fs.readFileSync(target, 'utf8')) as Record + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('config.json is not a JSON object') + if (value === undefined) delete config[key] + else config[key] = value + fs.mkdirSync(path.dirname(target), { recursive: true }) + const tmpPath = `${target}.${randomBytes(8).toString('hex')}.tmp` + try { + fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2) + '\n') + fs.renameSync(tmpPath, target) + } catch (error) { + fs.rmSync(tmpPath, { force: true }) + throw error + } +} + +/** + * The menu bar reads AppleLanguages and falls back to English for locales it + * lacks (it ships en + zh-Hans). Chinese maps to the script tags the .lproj + * uses; null (System) clears the override so the OS language decides. + */ +export function appleLanguageFor(language: string | null): string | null { + if (language === 'zh-CN') return 'zh-Hans' + if (language === 'zh-TW') return 'zh-Hant' + return language +} + +// `--opt=value`, never `--opt value`: a pattern routinely starts with "-", and +// as a separate argv entry that parses as another flag. +function filterArgs({ project, exclude }: ProjectFilter): string[] { + const args: string[] = [] + for (const name of project) args.push(`--project=${name}`) + for (const name of exclude) args.push(`--exclude=${name}`) + return args +} + +type DateRange = { from: string; to: string } + +function rangeArgs(range: DateRange | undefined): string[] { + return range ? ['--from', range.from, '--to', range.to] : [] +} + +function configSourceArgs(source: string | null): string[] { + return source ? ['--claude-config-source', source] : [] +} + +// Renderer-supplied strings become argv, so reject anything that could smuggle a +// flag or shell metacharacter before it reaches the CLI. Thrown from the argv +// builders, these surface through the same error envelope as any CliError. +const PERIODS = new Set(['today', 'week', '30days', 'month', 'all', 'lifetime']) +function vPeriod(period: string): string { + if (!PERIODS.has(period)) throw new CliError('bad-args', 'invalid period') + return period +} +function vProvider(provider: string): string { + if (!/^[a-z0-9-]+$/.test(provider)) throw new CliError('bad-args', 'invalid provider') + return provider +} +function vRange(range: DateRange | undefined): DateRange | undefined { + if (range && (!/^\d{4}-\d{2}-\d{2}$/.test(range.from) || !/^\d{4}-\d{2}-\d{2}$/.test(range.to))) { + throw new CliError('bad-args', 'invalid date range') + } + return range +} +/** + * Drill-down contribution key (canonical project path or model id). Unlike + * vToken, a leading '-' is legal here: Claude sanitizes project paths by + * replacing separators with '-' (e.g. `/work/pricing` → `-work-pricing`), and + * the key is only ever emitted in the VALUE position of `--key`/`--dimension` + * pairs, where Commander binds the next token as the value — a dash-leading + * value cannot inject a flag through the argv array (no shell involved). + * Empty and NUL are still rejected. + */ +function vContributionKey(value: string): string { + if (!value || value.includes('\0')) throw new CliError('bad-args', 'invalid contribution key') + return value +} +/** vRange for channels where the range is REQUIRED (compare periods). */ +function vRequiredRange(range: DateRange | undefined, name: string): DateRange { + const v = vRange(range) + if (!v) throw new CliError('bad-args', `missing ${name} date range`) + return v +} +function vCurrency(code: string): string { + if (!/^[A-Z]{3}$/.test(code)) throw new CliError('bad-args', 'invalid currency code') + return code +} +/** model/alias/device/plan tokens: must not be read as a CLI flag. */ +function vToken(value: string): string { + if (value.startsWith('-')) throw new CliError('bad-args', 'argument must not start with "-"') + return value +} +// Exact identities from the cohort facet report, not loose CLI patterns. +// Keep the value attached to its flag so label-only ids starting with "-" +// remain data; NUL is invalid in process argv. +function vProjectIds(projects: string[] | undefined): string[] { + if (!projects || projects.length === 0) return [] + for (const pattern of projects) { + if (typeof pattern !== 'string' || pattern.length === 0 || pattern.includes('\0')) { + throw new CliError('bad-args', 'invalid project identity') + } + } + return projects.map(id => `--project-id=${id}`) +} +// Activity categories for the cohort selection: the ids behind the CLI's +// --category (src/types.ts CATEGORY_LABELS keys). Duplicated here because the +// main process deliberately does not import core src/ modules. +const COHORT_CATEGORIES = new Set([ + 'coding', 'debugging', 'feature', 'refactoring', 'testing', 'exploration', + 'planning', 'delegation', 'git', 'build/deploy', 'conversation', 'brainstorming', 'general', +]) +function vCategory(category: string): string { + if (!COHORT_CATEGORIES.has(category)) throw new CliError('bad-args', 'invalid category') + return category +} +// Claude config source ids are `:` (src/providers/claude.ts) — the +// colon is part of the real value, so the token class allows it while anchoring +// the first char to alphanumeric so a leading "-" can never smuggle a flag. +function vConfigSource(source: string | null | undefined): string | null { + if (source == null) return null + if (!/^[A-Za-z0-9][A-Za-z0-9:_-]*$/.test(source)) throw new CliError('bad-args', 'invalid claude config source') + return source +} +function vScope(scope: string | undefined): 'local' | 'combined' { + if (scope === 'combined') return 'combined' + if (scope === undefined || scope === 'local') return 'local' + throw new CliError('bad-args', 'invalid scope') +} +function vOutPath(outPath: string): string { + if (outPath.startsWith('-') || !path.isAbsolute(outPath)) throw new CliError('bad-args', 'export path must be absolute') + return outPath +} +// Price-override rates are USD per 1M tokens: every provided rate must be a +// finite, strictly positive number before it becomes a CLI value. +type PriceRates = { input?: number; output?: number; cacheRead?: number; cacheCreation?: number } +function rateArg(flag: string, value: number | undefined): string[] { + if (value === undefined) return [] + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) throw new CliError('bad-args', 'rate must be a positive number') + return [flag, String(value)] +} +function priceOverrideArgs(model: string, rates: PriceRates | undefined): string[] { + const r = rates ?? {} + return [ + 'price-override', vToken(model), + ...rateArg('--input', r.input), + ...rateArg('--output', r.output), + ...rateArg('--cache-read', r.cacheRead), + ...rateArg('--cache-creation', r.cacheCreation), + ] +} + +function toEnvelopeError(err: unknown): { kind: string; message: string; stage?: string } { + if (err instanceof CliError) return { kind: err.kind, message: sanitizeError(err.message), ...(err.kind === 'not-found' && err.detail ? { stage: err.detail } : {}) } + return { kind: 'nonzero', message: sanitizeError(err instanceof Error ? err.message : String(err)) } +} + +const PROVIDER_ISSUE_KINDS = new Set(['eacces', 'busy', 'enoent', 'malformed', 'error']) + +/** + * Props for a `cli_error` telemetry event. Deliberately carries only + * non-sensitive enums so the event is diagnosable without a repro yet leaks + * nothing: `cmd` is the CLI subcommand (argv[0], a fixed literal like 'status'/ + * 'sessions' — never the full args, which can hold paths), and `detail` is the + * not-found resolution/spawn stage. The error's `message` (which may contain a + * path or stderr) only ever reaches cliErrorReason, which reduces it to a fixed + * label. `ms` is a duration bucket, `exit` a one-shot child's exit code or + * signal name, and `provider` appears only when the read was scoped to one. + */ +function cliErrorProps(err: unknown, argv: readonly string[] | undefined, startedAt: number): Record { + const props: Record = { ms: durationBucket(Date.now() - startedAt) } + const cmd = argv?.[0] + if (cmd) props.cmd = cmd + const providerAt = argv ? argv.indexOf('--provider') : -1 + if (providerAt >= 0 && argv![providerAt + 1]) props.provider = argv![providerAt + 1] + if (err instanceof CliError) { + props.kind = err.kind + if (err.kind === 'not-found' && err.detail) props.detail = err.detail + if (err.exit !== undefined) props.exit = err.exit + if (err.kind === 'nonzero') props.reason = cliErrorReason(err.message) + } else { + props.kind = 'nonzero' + } + return props +} + +export type Deps = { + spawnCli: (args: string[], opts?: { timeoutMs?: number; onStderr?: (chunk: string) => void; extraEnv?: NodeJS.ProcessEnv; priority?: SpawnPriority }) => Promise + spawnCliAction: (args: string[], opts?: { timeoutMs?: number }) => Promise + resolveCodeburnPath: () => string | null + getQuota: typeof getQuota + /** Forward cold-start scan-progress events to the renderer splash. */ + emitProgress?: (event: unknown) => void + /** Consent-gated anonymous telemetry; absent under tests unless injected. */ + telemetry?: TelemetryBridge | null + /** Cached update-availability status; absent under tests unless injected. */ + getUpdateStatus?: () => Promise + /** One-click update download and restart; absent where updates are a download link. */ + downloadUpdate?: () => Promise + installUpdate?: () => void + /** The menubar app and global CLI against the desktop's version; null until the first check. */ + companionUpdateStatus?: () => CompanionUpdateStatus | null + /** Run the companion update; progress arrives on the push channel. */ + updateCompanions?: () => unknown + /** The bundled tray app and Capacity Dock; absent off Windows and under tests. */ + companion?: Pick< + MenubarCompanion, + 'status' | 'trayPrefs' | 'setTrayAppPref' | 'setTrayDockPref' | 'setLaunchAtLogin' + | 'install' | 'open' | 'quit' | 'uninstall' | 'setDockEnabled' + > | null + /** The macOS menubar app, as the Plugins page sees it; absent off darwin and under tests. */ + macMenubar?: Pick | null + /** Where the daily optimize scan is cached (app userData). Absent = no cache. */ + stateDir?: string + /** Stamped into a cached scan so a build whose finding shapes changed never + * reads the previous build's cache. */ + appVersion?: string + /** Electron's powerMonitor, for the on-battery live cadence. */ + isOnBatteryPower?: () => boolean + /** Narrows the saved filter before it becomes argv (the IDE's workspace scope). */ + scopeProjectFilter?: (filter: ProjectFilter) => ProjectFilter +} + +export type Handler = (...args: any[]) => Promise + +/** + * Maps every CodeburnBridge channel to its `codeburn` argv (plain args, no + * shell) and returns a result envelope. Pure + injectable so the wiring is + * unit-testable without launching Electron. + */ +/** + * The line `codeburn export` prints only after a file or folder is written + * (src/main.ts, the `Exported (