diff --git a/.github/workflows/frontend-nightly.yml b/.github/workflows/frontend-nightly.yml new file mode 100644 index 000000000..1325d17b9 --- /dev/null +++ b/.github/workflows/frontend-nightly.yml @@ -0,0 +1,89 @@ +# .github/workflows/frontend-nightly.yml +name: Desktop nightly + +# Daily nightly build on main. Computes X.Y.(Z+1)-nightly.+ from +# the latest stable desktop tag, stamps it, builds all platforms, and publishes +# a prerelease so electron-updater's `nightly` channel can resolve it. Skips when +# there are no new commits since the last nightly. +on: + schedule: + - cron: "30 13 * * *" # 13:30 UTC = 19:00 IST daily + workflow_dispatch: + +jobs: + guard: + runs-on: ubuntu-latest + outputs: + should_build: ${{ steps.check.outputs.should_build }} + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # need tags + history for the no-new-commits check + - uses: actions/setup-node@v4 + with: + node-version: 20 + - id: check + shell: bash + run: | + # Skip if HEAD is already covered by the most recent nightly tag. + last_nightly="$(git tag --list 'v*-nightly.*' --sort=-creatordate | head -n1)" + if [ -n "$last_nightly" ] && [ "$(git rev-list -n1 "$last_nightly")" = "$(git rev-parse HEAD)" ]; then + echo "should_build=false" >> "$GITHUB_OUTPUT" + else + echo "should_build=true" >> "$GITHUB_OUTPUT" + fi + - id: version + if: steps.check.outputs.should_build == 'true' + shell: bash + run: | + latest_stable="$(git tag --list 'desktop-v[0-9]*.[0-9]*.[0-9]*' --sort=-version:refname | grep -v nightly | head -n1)" + latest_stable="${latest_stable:-desktop-v0.0.0}" + short_sha="$(git rev-parse --short HEAD)" + version="$(node frontend/scripts/nightly-version.mjs "$latest_stable" "$short_sha")" + echo "version=$version" >> "$GITHUB_OUTPUT" + + release: + needs: guard + if: needs.guard.outputs.should_build == 'true' + strategy: + fail-fast: false + matrix: + os: [macos-latest, macos-13, windows-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + permissions: + contents: write + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + - uses: actions/setup-go@v5 + with: + go-version-file: backend/go.mod + cache-dependency-path: backend/go.sum + - run: npm ci + - name: Stamp nightly version + shell: bash + env: + NIGHTLY_VERSION: ${{ needs.guard.outputs.version }} + run: | + node -e "const v=process.env.NIGHTLY_VERSION.split('+')[0]; const fs=require('fs'); const p=require('./package.json'); p.version=v; fs.writeFileSync('./package.json', JSON.stringify(p,null,'\t')+'\n');" + - name: Publish + run: npm run publish + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AO_RELEASE_REPO: ${{ github.repository }} + # Mark this a prerelease so it lands on electron-updater's nightly channel + # and never becomes the stable `latest` GitHub release. + AO_RELEASE_PRERELEASE: "true" + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} diff --git a/docs/superpowers/plans/2026-06-26-auto-update-channels.md b/docs/superpowers/plans/2026-06-26-auto-update-channels.md new file mode 100644 index 000000000..d7c8e4ed1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-26-auto-update-channels.md @@ -0,0 +1,747 @@ +# Auto-update Channels Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship channel-aware auto-update (stable + nightly) for the desktop app via electron-updater, plus a daily nightly release pipeline with an ordered, channel-tagged version scheme. + +**Architecture:** electron-updater replaces update-electron-app in the Electron main process; its channel and auto-download are driven by three user settings persisted under `~/.ao`. Stable releases stay manual (`desktop-vX.Y.Z`); a daily GitHub Actions cron builds and publishes nightly prereleases versioned `X.Y.(Z+1)-nightly.+`. The release workflows also publish electron-updater feed metadata (`*.yml`) per platform. + +**Tech Stack:** Electron, electron-updater, TypeScript, Node ESM, electron-forge, GitHub Actions, vitest. + +## Global Constraints + +- All app state, including the new update settings, resolves under `~/.ao` (honor `AO_DATA_DIR`/`AO_RUN_FILE`); never an OS-default app-data location. +- No em dashes anywhere (prose, code comments, commit messages, copy). +- Git author email `dev@theharshitsingh.com`; end commits with `Co-Authored-By: Claude Opus 4.8 `; no backticks inside `git commit -m` (use `-F -` heredoc). +- `git add` explicit paths only; never `git add -A`. +- Version scheme is exact: stable `X.Y.Z`; nightly `X.Y.(Z+1)-nightly.+`. +- electron-updater channels: stable -> `latest`, nightly -> `nightly`. `allowDowngrade = true`. +- Skip auto-update entirely when `!app.isPackaged`. +- macOS auto-update requires a signed + notarized build (Track-B prereq); nightly macOS is download-only until signing lands. Do NOT claim macOS auto-update works in any copy. + +--- + +## File Structure + +- `frontend/scripts/nightly-version.mjs` (new): pure ESM, computes the nightly version string. Used by the nightly CI workflow and unit-tested. +- `frontend/scripts/nightly-version.test.mjs` (new): vitest tests for the version compute. +- `frontend/src/main/update-settings.ts` (new): read/write the three update settings under `~/.ao` (atomic, mirrors `app-state.ts`). +- `frontend/src/main/update-settings.test.ts` (new): vitest tests. +- `frontend/src/main/auto-updater.ts` (new): thin shell that configures electron-updater from settings. No business logic beyond Electron event glue. +- `frontend/src/main.ts` (modify): replace `update-electron-app` usage in `initAutoUpdates()` with the new shell. +- `frontend/package.json` (modify): remove `update-electron-app`, add `electron-updater`. +- `.github/workflows/frontend-nightly.yml` (new): daily cron that builds + publishes the nightly prerelease. +- `.github/workflows/frontend-release.yml` (modify): emit + upload electron-updater macOS feed metadata (`latest-mac.yml`) alongside the existing assets. + +--- + +# Group A: Versioning + nightly pipeline + +## Task 1: Nightly version compute module + +**Files:** + +- Create: `frontend/scripts/nightly-version.mjs` +- Test: `frontend/scripts/nightly-version.test.mjs` + +**Interfaces:** + +- Produces: `computeNightlyVersion(latestStableTag: string, now: Date, shortSha: string): string` returning `X.Y.(Z+1)-nightly.+`. Accepts `latestStableTag` either bare (`0.10.3`) or tag-prefixed (`v0.10.3` / `desktop-v0.10.3`). +- Produces (CLI): `node scripts/nightly-version.mjs ` prints the version using the current UTC time, for the CI workflow. + +- [ ] **Step 1: Write the failing test** + +```javascript +// frontend/scripts/nightly-version.test.mjs +// @vitest-environment node +import { describe, it, expect } from "vitest"; +import { computeNightlyVersion } from "./nightly-version.mjs"; + +const now = new Date("2026-06-27T03:00:00.000Z"); + +describe("computeNightlyVersion", () => { + it("bumps the patch and formats a UTC-timestamped nightly prerelease with sha build metadata", () => { + expect(computeNightlyVersion("0.10.3", now, "ab12cd3")).toBe("0.10.4-nightly.202606270300+ab12cd3"); + }); + + it("strips a v / desktop-v tag prefix", () => { + expect(computeNightlyVersion("v0.10.3", now, "ab12cd3")).toBe("0.10.4-nightly.202606270300+ab12cd3"); + expect(computeNightlyVersion("desktop-v0.10.3", now, "ab12cd3")).toBe("0.10.4-nightly.202606270300+ab12cd3"); + }); + + it("orders monotonically by timestamp for the same base", () => { + const earlier = computeNightlyVersion("0.10.3", new Date("2026-06-27T03:00:00Z"), "aaaaaaa"); + const later = computeNightlyVersion("0.10.3", new Date("2026-06-27T04:00:00Z"), "bbbbbbb"); + // prerelease identifiers compare lexically; zero-padded fixed-width timestamp sorts correctly + expect(later > earlier).toBe(true); + }); + + it("rejects a non-semver base tag", () => { + expect(() => computeNightlyVersion("not-a-version", now, "ab12cd3")).toThrow(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd frontend && npx vitest run scripts/nightly-version.test.mjs` +Expected: FAIL, "Failed to resolve import ./nightly-version.mjs" / `computeNightlyVersion is not a function`. + +- [ ] **Step 3: Write minimal implementation** + +```javascript +// frontend/scripts/nightly-version.mjs +// Pure version math shared by the nightly CI workflow. Kept dependency-free +// ESM so `node scripts/nightly-version.mjs` runs it directly in CI and vitest +// unit-tests it. The app does NOT compute versions; it only reads its injected +// app.getVersion(), so this lives in scripts/, not src/. + +const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/; + +// computeNightlyVersion builds X.Y.(Z+1)-nightly.+. +// Next-patch base keeps a nightly ahead of the last stable and behind the next. +// The fixed-width UTC timestamp makes prerelease ids order by build time; the +// sha is semver build metadata (ignored for ordering, kept for traceability). +export function computeNightlyVersion(latestStableTag, now, shortSha) { + const bare = String(latestStableTag).replace(/^(desktop-)?v/, ""); + const m = SEMVER.exec(bare); + if (!m) { + throw new Error(`nightly-version: base tag is not X.Y.Z: ${latestStableTag}`); + } + const [major, minor, patch] = [Number(m[1]), Number(m[2]), Number(m[3])]; + const ts = + String(now.getUTCFullYear()) + + String(now.getUTCMonth() + 1).padStart(2, "0") + + String(now.getUTCDate()).padStart(2, "0") + + String(now.getUTCHours()).padStart(2, "0") + + String(now.getUTCMinutes()).padStart(2, "0"); + return `${major}.${minor}.${patch + 1}-nightly.${ts}+${shortSha}`; +} + +// CLI entry for CI: node scripts/nightly-version.mjs +if (import.meta.url === `file://${process.argv[1]}`) { + const [, , latestStableTag, shortSha] = process.argv; + if (!latestStableTag || !shortSha) { + process.stderr.write("usage: node nightly-version.mjs \n"); + process.exit(2); + } + process.stdout.write(computeNightlyVersion(latestStableTag, new Date(), shortSha)); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd frontend && npx vitest run scripts/nightly-version.test.mjs` +Expected: PASS (4 tests). + +- [ ] **Step 5: Sanity-check the CLI entry** + +Run: `cd frontend && node scripts/nightly-version.mjs v0.10.3 abc1234` +Expected: prints `0.10.4-nightly.+abc1234` (no trailing newline). + +- [ ] **Step 6: Commit** + +```bash +git add frontend/scripts/nightly-version.mjs frontend/scripts/nightly-version.test.mjs +git commit -F - <<'EOF' +feat(release): nightly version compute module + +computeNightlyVersion -> X.Y.(Z+1)-nightly.+: next-patch base, +fixed-width UTC timestamp for monotonic prerelease ordering, sha as build +metadata. Pure ESM so CI runs it and vitest tests it. + +Co-Authored-By: Claude Opus 4.8 +EOF +``` + +## Task 2: Nightly daily cron workflow + +**Files:** + +- Create: `.github/workflows/frontend-nightly.yml` + +**Interfaces:** + +- Consumes: `frontend/scripts/nightly-version.mjs` CLI (Task 1). +- Produces: a daily prerelease GitHub release tagged `desktop-v` carrying per-platform installers + electron-updater `nightly*.yml`, on the `nightly` channel. + +- [ ] **Step 1: Write the workflow** + +```yaml +# .github/workflows/frontend-nightly.yml +name: Desktop nightly + +# Daily nightly build on main. Computes X.Y.(Z+1)-nightly.+ from +# the latest stable desktop tag, stamps it, builds all platforms, and publishes +# a prerelease so electron-updater's `nightly` channel can resolve it. Skips when +# there are no new commits since the last nightly. +on: + schedule: + - cron: "0 3 * * *" # 03:00 UTC daily + workflow_dispatch: + +jobs: + guard: + runs-on: ubuntu-latest + outputs: + should_build: ${{ steps.check.outputs.should_build }} + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # need tags + history for the no-new-commits check + - uses: actions/setup-node@v4 + with: + node-version: 20 + - id: check + shell: bash + run: | + # Skip if HEAD is already covered by the most recent nightly tag. + last_nightly="$(git tag --list 'desktop-v*-nightly.*' --sort=-creatordate | head -n1)" + if [ -n "$last_nightly" ] && [ "$(git rev-list -n1 "$last_nightly")" = "$(git rev-parse HEAD)" ]; then + echo "should_build=false" >> "$GITHUB_OUTPUT" + else + echo "should_build=true" >> "$GITHUB_OUTPUT" + fi + - id: version + if: steps.check.outputs.should_build == 'true' + shell: bash + run: | + latest_stable="$(git tag --list 'desktop-v[0-9]*.[0-9]*.[0-9]*' --sort=-version:refname | grep -v nightly | head -n1)" + latest_stable="${latest_stable:-desktop-v0.0.0}" + short_sha="$(git rev-parse --short HEAD)" + version="$(node frontend/scripts/nightly-version.mjs "$latest_stable" "$short_sha")" + echo "version=$version" >> "$GITHUB_OUTPUT" + + release: + needs: guard + if: needs.guard.outputs.should_build == 'true' + strategy: + fail-fast: false + matrix: + os: [macos-latest, macos-13, windows-latest, ubuntu-latest] + runs-on: ${{ matrix.os }} + permissions: + contents: write + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: frontend/package-lock.json + - uses: actions/setup-go@v5 + with: + go-version-file: backend/go.mod + cache-dependency-path: backend/go.sum + - run: npm ci + - name: Stamp nightly version + shell: bash + run: | + node -e "const v='${{ needs.guard.outputs.version }}'.split('+')[0]; const fs=require('fs'); const p=require('./package.json'); p.version=v; fs.writeFileSync('./package.json', JSON.stringify(p,null,'\t')+'\n');" + - name: Publish + run: npm run publish + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + AO_RELEASE_REPO: ${{ github.repository }} + # Mark this a prerelease so it lands on electron-updater's nightly channel + # and never becomes the stable `latest` GitHub release. + AO_RELEASE_PRERELEASE: "true" + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} +``` + +- [ ] **Step 2: Wire the prerelease flag in forge.config.ts** + +The publisher currently hardcodes `prerelease: false`. Make it env-driven so the nightly run publishes a prerelease while stable stays non-prerelease. + +In `frontend/forge.config.ts`, change the publisher config: + +```typescript + config: { + repository: parseReleaseRepo(process.env.AO_RELEASE_REPO), + prerelease: process.env.AO_RELEASE_PRERELEASE === "true", + draft: false, + }, +``` + +- [ ] **Step 3: Validate the workflow + forge change parse** + +Run: `cd frontend && npm run typecheck` +Expected: only the pre-existing `forge.config.ts` `osxNotarize` error; no new errors. + +Run (if available): `actionlint .github/workflows/frontend-nightly.yml`; else `ruby -ryaml -e "YAML.load_file('.github/workflows/frontend-nightly.yml')"` +Expected: parses with no error. + +- [ ] **Step 4: Commit** + +```bash +git add .github/workflows/frontend-nightly.yml frontend/forge.config.ts +git commit -F - <<'EOF' +feat(release): daily nightly build + publish workflow + +Cron computes the nightly version from the latest stable tag, stamps it, +builds all platforms, and publishes a prerelease (nightly channel). Skips +when HEAD is already covered by the latest nightly. forge publisher prerelease +flag is now env-driven (AO_RELEASE_PRERELEASE) so stable stays non-prerelease. + +Co-Authored-By: Claude Opus 4.8 +EOF +``` + +## Task 3: macOS electron-updater feed metadata + +**Files:** + +- Modify: `.github/workflows/frontend-release.yml` (add a macOS feed-metadata step; the same step is needed in `frontend-nightly.yml`, add it there too) + +**Interfaces:** + +- Produces: `latest-mac.yml` (stable) / `nightly-mac.yml` (nightly) uploaded to the release, so electron-updater can resolve macOS updates. Windows/Linux already emit their `*.yml` via the electron-builder-backed makers. + +- [ ] **Step 1: Add the macOS feed step** + +electron-forge's `maker-zip` does not emit electron-updater metadata. Generate it from the built zip with electron-builder's helper. Add this step to the macOS branch of both release workflows, after the existing alias upload: + +```yaml +- name: Generate + upload macOS update feed (electron-updater) + if: startsWith(matrix.os, 'macos') + shell: bash + run: | + tag="v$(node -p "require('./package.json').version")" + # electron-builder ships `app-builder`-based metadata generation; emit + # the channel yml from the zip. Channel is derived from the version's + # prerelease tag: stable -> latest-mac.yml, nightly -> nightly-mac.yml. + npx --yes electron-builder --pd "$(ls -d out/*-darwin-* | head -n1)" \ + --config.publish=null --mac zip 2>/dev/null || true + ymls="$(ls dist/latest-mac.yml dist/nightly-mac.yml 2>/dev/null || true)" + if [ -z "$ymls" ]; then echo "no macOS update feed yml generated" >&2; exit 1; fi + for f in $ymls; do gh release upload "$tag" "$f" --clobber; done + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +> Note: the exact electron-builder invocation to emit only the mac yml from a forge-built bundle must be confirmed on the first real CI run (it cannot run on a contributor laptop without the signed bundle). The step fails loudly if no yml is produced so the run surfaces it rather than silently shipping a feed-less macOS release. + +- [ ] **Step 2: Validate YAML** + +Run: `ruby -ryaml -e "YAML.load_file('.github/workflows/frontend-release.yml')"` +Expected: parses with no error. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/frontend-release.yml .github/workflows/frontend-nightly.yml +git commit -F - <<'EOF' +feat(release): publish macOS electron-updater feed metadata + +forge maker-zip does not emit latest-mac.yml/nightly-mac.yml, so generate and +upload it for the macOS runners. Win/linux already emit their feed yml via the +electron-builder-backed makers. Exact generation command to be confirmed on the +first signed CI run; the step fails loudly if no yml is produced. + +Co-Authored-By: Claude Opus 4.8 +EOF +``` + +--- + +# Group B: In-app channel-aware updater + +## Task 4: Update settings module + +**Files:** + +- Create: `frontend/src/main/update-settings.ts` +- Test: `frontend/src/main/update-settings.test.ts` + +**Interfaces:** + +- Produces: + - `type UpdateChannel = "latest" | "nightly"` + - `interface UpdateSettings { enabled: boolean; channel: UpdateChannel; nightlyAck: boolean }` + - `readUpdateSettings(stateDir: string): Promise` (returns defaults `{enabled:false, channel:"latest", nightlyAck:false}` when the file is missing/garbage). + - `writeUpdateSettings(stateDir: string, settings: UpdateSettings): Promise` (atomic temp+rename, mirrors `app-state.ts`). +- Consumes: the `~/.ao` dir resolution already used for `app-state.ts` (`path.dirname(runFilePath())`). + +- [ ] **Step 1: Write the failing test** + +```typescript +// frontend/src/main/update-settings.test.ts +// @vitest-environment node +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm, writeFile, readdir } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { readUpdateSettings, writeUpdateSettings, UPDATE_SETTINGS_FILE_NAME } from "./update-settings"; + +describe("update-settings", () => { + let dir: string; + beforeEach(async () => { + dir = await mkdtemp(path.join(os.tmpdir(), "ao-update-settings-")); + }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("returns safe defaults when no file exists", async () => { + expect(await readUpdateSettings(dir)).toEqual({ enabled: false, channel: "latest", nightlyAck: false }); + }); + + it("round-trips written settings", async () => { + await writeUpdateSettings(dir, { enabled: true, channel: "nightly", nightlyAck: true }); + expect(await readUpdateSettings(dir)).toEqual({ enabled: true, channel: "nightly", nightlyAck: true }); + }); + + it("falls back to defaults on garbage", async () => { + await writeFile(path.join(dir, UPDATE_SETTINGS_FILE_NAME), "{not json", "utf8"); + expect(await readUpdateSettings(dir)).toEqual({ enabled: false, channel: "latest", nightlyAck: false }); + }); + + it("coerces an unknown channel back to latest", async () => { + await writeFile( + path.join(dir, UPDATE_SETTINGS_FILE_NAME), + JSON.stringify({ enabled: true, channel: "weird", nightlyAck: false }), + "utf8", + ); + expect((await readUpdateSettings(dir)).channel).toBe("latest"); + }); + + it("atomic write leaves no temp file behind", async () => { + await writeUpdateSettings(dir, { enabled: true, channel: "latest", nightlyAck: false }); + const entries = await readdir(dir); + expect(entries).toEqual([UPDATE_SETTINGS_FILE_NAME]); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd frontend && npx vitest run src/main/update-settings.test.ts` +Expected: FAIL, cannot resolve `./update-settings`. + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// frontend/src/main/update-settings.ts +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; + +export type UpdateChannel = "latest" | "nightly"; + +export interface UpdateSettings { + enabled: boolean; + channel: UpdateChannel; + nightlyAck: boolean; +} + +/** File holding the user's auto-update preferences under the ~/.ao state dir. */ +export const UPDATE_SETTINGS_FILE_NAME = "update-settings.json"; + +const DEFAULTS: UpdateSettings = { enabled: false, channel: "latest", nightlyAck: false }; + +function coerce(raw: unknown): UpdateSettings { + const o = (raw ?? {}) as Record; + return { + enabled: o.enabled === true, + channel: o.channel === "nightly" ? "nightly" : "latest", + nightlyAck: o.nightlyAck === true, + }; +} + +/** Read update settings, tolerating a missing or corrupt file (returns defaults). */ +export async function readUpdateSettings(stateDir: string): Promise { + let raw: string; + try { + raw = await readFile(path.join(stateDir, UPDATE_SETTINGS_FILE_NAME), "utf8"); + } catch { + return { ...DEFAULTS }; + } + try { + return coerce(JSON.parse(raw)); + } catch { + return { ...DEFAULTS }; + } +} + +/** Atomically write update settings (temp file + rename), mirroring app-state.ts. */ +export async function writeUpdateSettings(stateDir: string, settings: UpdateSettings): Promise { + await mkdir(stateDir, { recursive: true, mode: 0o750 }); + const file = path.join(stateDir, UPDATE_SETTINGS_FILE_NAME); + const data = `${JSON.stringify(coerce(settings), null, 2)}\n`; + const tmp = path.join(stateDir, `.update-settings-${process.pid}-${Date.now()}.json`); + await writeFile(tmp, data, { mode: 0o600 }); + await rename(tmp, file); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd frontend && npx vitest run src/main/update-settings.test.ts` +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/main/update-settings.ts frontend/src/main/update-settings.test.ts +git commit -F - <<'EOF' +feat(update): persist auto-update settings under ~/.ao + +readUpdateSettings/writeUpdateSettings store {enabled, channel, nightlyAck} +with safe defaults and channel coercion, atomic temp+rename like app-state.ts. + +Co-Authored-By: Claude Opus 4.8 +EOF +``` + +## Task 5: electron-updater shell + main.ts wiring + +**Files:** + +- Modify: `frontend/package.json` (remove `update-electron-app`, add `electron-updater`) +- Create: `frontend/src/main/auto-updater.ts` +- Modify: `frontend/src/main.ts` (`initAutoUpdates` + the import) + +**Interfaces:** + +- Consumes: `readUpdateSettings` (Task 4); `releaseRepo` resolution (mirror the owner/repo default `AgentWrapper/agent-orchestrator`). +- Produces: `startAutoUpdates(stateDir: string): Promise` that configures and starts electron-updater from settings. No-op-safe to call once on app ready. + +- [ ] **Step 1: Swap the dependency** + +Run: + +```bash +cd frontend && npm uninstall update-electron-app && npm install electron-updater@^6 +``` + +Expected: `package.json` no longer lists `update-electron-app`; lists `electron-updater`. + +- [ ] **Step 2: Write the shell** + +```typescript +// frontend/src/main/auto-updater.ts +import { autoUpdater } from "electron-updater"; +import { readUpdateSettings } from "./update-settings"; + +// Default release repo, mirroring backend cli.releaseRepo. Override via env for +// fork test builds (AO_RELEASE_REPO=owner/repo). +const DEFAULT_RELEASE_REPO = "AgentWrapper/agent-orchestrator"; + +function repo(): { owner: string; name: string } { + const [owner, name] = (process.env.AO_RELEASE_REPO || DEFAULT_RELEASE_REPO).split("/"); + if (owner && name) return { owner, name }; + const [defOwner, defName] = DEFAULT_RELEASE_REPO.split("/"); + return { owner: defOwner, name: defName }; +} + +// startAutoUpdates configures electron-updater from the user's ~/.ao settings. +// It is a thin shell: all policy (channel, opt-in) comes from update-settings. +// Caller guards on app.isPackaged. +export async function startAutoUpdates(stateDir: string): Promise { + const settings = await readUpdateSettings(stateDir); + if (!settings.enabled) return; + + const { owner, name } = repo(); + autoUpdater.setFeedURL({ provider: "github", owner, repo: name }); + autoUpdater.channel = settings.channel; // "latest" | "nightly" + autoUpdater.allowDowngrade = true; // permits a nightly -> stable channel switch + autoUpdater.autoDownload = true; + autoUpdater.autoInstallOnAppQuit = true; + + autoUpdater.on("error", (err) => { + // Never crash on update failure (offline, unsigned macOS, etc.). + console.error("auto-update error:", err?.message ?? err); + }); + + try { + await autoUpdater.checkForUpdates(); + } catch (err) { + console.error("auto-update check failed:", err); + } +} +``` + +- [ ] **Step 3: Wire it into main.ts** + +In `frontend/src/main.ts`, replace the `update-electron-app` import and `initAutoUpdates` body: + +Remove: + +```typescript +import { updateElectronApp } from "update-electron-app"; +``` + +Add (with the other `./main/*` imports): + +```typescript +import { startAutoUpdates } from "./main/auto-updater"; +``` + +Replace `initAutoUpdates`: + +```typescript +function initAutoUpdates(): void { + if (!app.isPackaged) return; + const runFile = runFilePath(); + if (!runFile) return; + void startAutoUpdates(path.dirname(runFile)); +} +``` + +- [ ] **Step 4: Typecheck + build** + +Run: `cd frontend && npm run typecheck` +Expected: only the pre-existing `forge.config.ts` `osxNotarize` error. + +Run: `cd frontend && npx vitest run src/main/update-settings.test.ts scripts/nightly-version.test.mjs` +Expected: PASS (all). + +- [ ] **Step 5: Commit** + +```bash +git add frontend/package.json frontend/package-lock.json frontend/src/main/auto-updater.ts frontend/src/main.ts +git commit -F - <<'EOF' +feat(update): channel-aware electron-updater wiring + +Replace update-electron-app (stable-only, no channels) with electron-updater +driven by the user's ~/.ao settings: channel from settings, allowDowngrade for +channel switches, auto-download gated on opt-in, errors swallowed. Feed +configured via setFeedURL since forge does not emit app-update.yml. + +Co-Authored-By: Claude Opus 4.8 +EOF +``` + +## Task 6: Opt-in + channel + nightly-disclaimer prompts (minimal) + +**Files:** + +- Modify: `frontend/src/main.ts` (first-run prompt via `dialog`) +- Modify: `frontend/src/main/auto-updater.ts` (export a helper to persist a channel choice) + +**Interfaces:** + +- Consumes: `writeUpdateSettings`, `readUpdateSettings` (Task 4). +- Produces: `ensureUpdatePrefs(stateDir: string): Promise` that, on first run (no settings file written yet), prompts the user once for opt-in + channel and, if nightly, shows the instability/data-loss disclaimer; persists the result. + +> Minimal main-process `dialog`-based prompt now; the polished Settings-page selector is #2207. This keeps the user-facing decision (opt-in + nightly disclaimer) present without depending on renderer UI work. + +- [ ] **Step 1: Write the helper** + +Add to `frontend/src/main/auto-updater.ts`. Extend the existing +`./update-settings` import to also pull in `writeUpdateSettings` and +`UPDATE_SETTINGS_FILE_NAME` (do NOT add a second import line from the same +module), and add the `electron`/`node:fs`/`node:path` imports: + +```typescript +// extend the existing import: +// import { readUpdateSettings, writeUpdateSettings, UPDATE_SETTINGS_FILE_NAME } from "./update-settings"; +import { dialog } from "electron"; +import { existsSync } from "node:fs"; +import path from "node:path"; + +// ensureUpdatePrefs prompts once (first run, before any settings file exists) +// for auto-update opt-in + channel, with a nightly instability disclaimer. +export async function ensureUpdatePrefs(stateDir: string): Promise { + if (existsSync(path.join(stateDir, UPDATE_SETTINGS_FILE_NAME))) return; + + const optIn = await dialog.showMessageBox({ + type: "question", + buttons: ["Enable auto-updates", "Not now"], + defaultId: 0, + cancelId: 1, + message: "Keep Agent Orchestrator up to date automatically?", + detail: "You can change this later in Settings.", + }); + if (optIn.response !== 0) { + await writeUpdateSettings(stateDir, { enabled: false, channel: "latest", nightlyAck: false }); + return; + } + + const chan = await dialog.showMessageBox({ + type: "question", + buttons: ["Stable", "Nightly"], + defaultId: 0, + cancelId: 0, + message: "Which update channel?", + detail: "Stable is released and tested. Nightly is the newest daily build.", + }); + if (chan.response !== 1) { + await writeUpdateSettings(stateDir, { enabled: true, channel: "latest", nightlyAck: false }); + return; + } + + const ack = await dialog.showMessageBox({ + type: "warning", + buttons: ["I understand, use Nightly", "Use Stable instead"], + defaultId: 1, + cancelId: 1, + message: "Nightly builds can be unstable", + detail: "Nightly is built every day and may be broken or lose data. Only use it if you are comfortable with that.", + }); + await writeUpdateSettings( + stateDir, + ack.response === 0 + ? { enabled: true, channel: "nightly", nightlyAck: true } + : { enabled: true, channel: "latest", nightlyAck: false }, + ); +} +``` + +- [ ] **Step 2: Call it before startAutoUpdates in main.ts** + +Update `initAutoUpdates` in `frontend/src/main.ts`: + +```typescript +function initAutoUpdates(): void { + if (!app.isPackaged) return; + const runFile = runFilePath(); + if (!runFile) return; + const stateDir = path.dirname(runFile); + void ensureUpdatePrefs(stateDir).then(() => startAutoUpdates(stateDir)); +} +``` + +Add `ensureUpdatePrefs` to the import from `./main/auto-updater`. + +- [ ] **Step 3: Typecheck** + +Run: `cd frontend && npm run typecheck` +Expected: only the pre-existing `forge.config.ts` `osxNotarize` error. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/main/auto-updater.ts frontend/src/main.ts +git commit -F - <<'EOF' +feat(update): first-run opt-in + channel + nightly disclaimer prompt + +Minimal main-process dialog flow: opt into auto-updates, pick stable/nightly, +and acknowledge a nightly instability/data-loss disclaimer. Persists the choice +to ~/.ao. Polished Settings-page selector is tracked in #2207. + +Co-Authored-By: Claude Opus 4.8 +EOF +``` + +--- + +## Verification (whole feature) + +- `cd frontend && npx vitest run scripts/nightly-version.test.mjs src/main/update-settings.test.ts` -> all pass. +- `cd frontend && npm run typecheck` -> only the pre-existing `osxNotarize` error. +- `ruby -ryaml -e "YAML.load_file('.github/workflows/frontend-nightly.yml')"` and same for `frontend-release.yml` -> parse clean. +- Real-CI-only (cannot run locally): first nightly cron run produces a prerelease with per-platform installers + `nightly*.yml`; the macOS feed step emits `nightly-mac.yml`. Confirm electron-updater on a packaged build resolves the channel. macOS update application requires signing (Track-B prereq). + +## Deferred / prereqs (not in this plan; see spec) + +- CI signing + notarization (Track B): macOS update application is blocked until this lands. +- Stable version stamping off `0.0.0` (Track B): nightly stamps its own version here; stable still needs its tag-derived stamp wired in the release workflow. +- Polished channel selector on the Global Settings page: #2207. diff --git a/docs/superpowers/specs/2026-06-26-auto-update-channels-design.md b/docs/superpowers/specs/2026-06-26-auto-update-channels-design.md new file mode 100644 index 000000000..dd8411ff4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-26-auto-update-channels-design.md @@ -0,0 +1,214 @@ +# Auto-update with stable + nightly channels + +_Design spec. 2026-06-26. Branch `feat/ao-auto-update` (off `main`)._ + +## Goal + +Give the Agent Orchestrator desktop app channel-aware auto-update with two +channels: + +- **stable**: real semver releases (`vX.Y.Z`), cut manually by a human. +- **nightly**: built and published automatically every day. + +Both channels auto-update via Electron. The user opts in to auto-update and +picks a channel in-app; choosing nightly requires acknowledging an +instability/data-loss disclaimer. + +## Scope (this spec) + +**In scope (implement now):** + +1. In-app channel-aware updater (the `electron-updater` runtime wiring + + channel-switch API + the three persisted settings + opt-in/disclaimer + prompts). +2. The nightly release pipeline (daily CI cron) and the version scheme for + both channels, including the build version stamping that nightly forces. + +**Captured here but NOT implemented now (deferred / tracked elsewhere):** + +- Polished channel-selection settings UI: issue #2207 (a minimal selector on + the Global Settings page is acceptable now; full UI is #2207). +- Global Settings page itself: done in #2218 (was blocked as #2205). + +**Hard prerequisites (must land for the feature to fully function):** + +- **CI code signing + notarization** (Track B). macOS auto-update (Squirrel.Mac + via electron-updater) refuses to apply updates to an unsigned/unnotarized + build. Until signing lands, macOS nightly/stable are download-only; win/linux + can still auto-update. This is a functional blocker for the macOS half, not a + nicety. +- **Stable version stamping** off `0.0.0` (Track B). Nightly solves stamping for + its own builds (below); stable still needs its tag-derived version stamped in. + +## Mechanism: electron-updater + +Chosen over `update.electronjs.org` (stable-only, no channels) and a custom +GitHub-releases updater (reimplements the risky download/verify/install glue) +and a hosted update server (extra ops). electron-updater has native channels, +per-OS atomic install, signature verification, and delta downloads. + +### Runtime (`frontend/src/main.ts`) + +- Add the `electron-updater` dependency. Remove `update-electron-app`. +- Replace `updateElectronApp()` in `initAutoUpdates()` with an `autoUpdater` + setup that: + - Resolves the GitHub provider from the same release-repo source `ao start` + uses (`AgentWrapper/agent-orchestrator` by default, override-aware). + - Sets `autoUpdater.channel` from the saved user choice + (`latest` | `nightly`). + - Gates `autoUpdater.autoDownload` on the user's auto-update opt-in. + - Sets `allowDowngrade = true` so a nightly -> stable channel switch can move + to a lower semver. + - Stays a thin shell over a pure, testable module (see Testing). +- **Forge nuance:** electron-forge does not generate electron-updater's + `app-update.yml` (electron-builder normally does). Configure the feed + programmatically with `autoUpdater.setFeedURL(...)` at startup so the feed + config lives in one place in `main.ts` and we do not depend on a build-time + file forge will not produce. +- Keep the existing guard: skip entirely when `!app.isPackaged` (no feed in + dev). + +### Feed (release assets the app reads) + +electron-updater reads per-platform, per-channel metadata from the GitHub +release: + +- stable: `latest-mac.yml`, `latest.yml`, `latest-linux.yml` (+ `.blockmap`s). +- nightly: `nightly-mac.yml`, `nightly.yml`, `nightly-linux.yml` (+ blockmaps). + +**CORRECTION (verified 2026-06-26, final review):** the original assumption that +"Windows and Linux emit this metadata naturally via the electron-builder makers" +is FALSE. Both `maker-nsis.ts` and `maker-appimage.ts` set `config.publish: null` +(deliberately, so electron-builder does not try to upload, forge owns publishing). +With `publish: null`, `getPublishConfigs` returns null and +`artifactCreatedWithoutExplicitPublishConfig` returns early BEFORE +`createUpdateInfoTasks` runs (app-builder-lib `PublishManager.js:143`,`356`,`163`), +so NO feed `*.yml` is generated on Windows or Linux either. macOS (forge +`maker-zip`) never emitted it. Net: as currently built, no platform produces a +feed, so the updater is inert everywhere until this is fixed. + +Generating the feed `*.yml` (and `.blockmap`) on all three platforms WITHOUT +letting electron-builder upload (forge does the upload), then uploading the yml +to each release, is its own coherent "feed-publishing" workstream. It is +verifiable only on real CI and is coupled to Track-B macOS signing. It is OUT OF +SCOPE for this spec's implementation and tracked separately (see Open +prerequisites). The runtime half (the in-app updater, settings, prompts, and +nightly build) ships first as groundwork and is inert-but-harmless until the +feed exists, the same state the prior `update-electron-app` was in. + +## Version scheme + +``` +stable: X.Y.Z e.g. 0.10.4 +nightly: X.Y.(Z+1)-nightly.+ + e.g. 0.10.4-nightly.202606270300+ab12cd3 +``` + +Rationale: + +- **Ordered.** A bare `{sha}` prerelease is not monotonic (two nightlies do not + order by time), so the updater could not tell newer from older. The + `nightly.` component is monotonic and orders by build time. +- **Channel-tagged.** electron-builder derives the channel from the prerelease + tag's first identifier, so `-nightly.<...>` publishes to the `nightly` + channel (`nightly*.yml`); a bare `X.Y.Z` (no prerelease) publishes to + `latest`. +- **Traceable.** `+` is semver build metadata: ignored for ordering + but visible in the UI and ties a build to its commit. Preserves the original + "{sha}" intent without breaking ordering. +- **Next-patch base (`Z+1`).** A nightly always sorts ahead of the last stable + (`0.10.4-nightly.* > 0.10.3`) and behind the eventual `0.10.4` stable. + Intuitive when a user on nightly is waiting for the release to catch up. + +## Release pipeline + +### Stable (manual) + +Unchanged trigger: a human pushes `desktop-vX.Y.Z`. The desktop-release workflow +builds all platforms and, in addition to the installers + existing `ao start` +stable aliases, now generates and uploads electron-updater's `latest*.yml` + +`.blockmap`s to the release. The build version is the tag's version (stable +stamping). + +### Nightly (daily cron) + +A GitHub Actions `schedule` workflow on `main` that: + +1. Computes `X.Y.(Z+1)-nightly.+`, with `X.Y.Z` from + the latest stable tag. +2. Stamps the version into the build: `frontend/package.json` version and the + daemon `-ldflags` version. (This is the version-stamping work, solved for + nightly here.) +3. Builds all platforms and publishes a **prerelease** GitHub release carrying + installers + `nightly*.yml`. +4. **Skips when there are no new commits since the last nightly** (no empty + builds). + +macOS nightly auto-update needs signing (prereq); until then macOS nightly is +download-only. + +## In-app UX + +Minimal now; polished UI is #2207 on the Global Settings page (#2218). + +Three persisted settings under `~/.ao` (app settings / app-state, never an +OS-default location): + +- `autoUpdate.enabled` (bool) +- `autoUpdate.channel` (`latest` | `nightly`) +- `autoUpdate.nightlyAck` (bool) + +Flow: + +- First-run opt-in prompt: enable auto-updates? which channel? +- Choosing nightly shows the "may be unstable, data may be lost" disclaimer and + requires explicit acknowledgement (`nightlyAck`). +- `main.ts` reads these settings, configures `autoUpdater` (channel, + `autoDownload`), and on `update-downloaded` notifies the user and installs on + quit. +- A minimal channel selector can live on the Global Settings page; full UI is + #2207. + +## Error handling + +- No feed / offline: log and retry on the next interval; never crash. +- Unsigned macOS: the updater errors; swallow it gracefully, no nagging. +- Channel switch downgrade: handled by `allowDowngrade = true`. +- Dev / unpackaged: skip (as today). + +## Testing + +Pull version computation, channel detection, and settings read/write into a +thin, pure module with unit tests: + +- nightly version is monotonic by timestamp; +- nightly base is the next patch; +- channel derives correctly from the prerelease tag; +- sha is build metadata (ignored for ordering, present in the string). + +The Electron `autoUpdater` wiring stays a thin shell around this module and is +not unit-tested (Electron runtime). + +## Components (isolation) + +- `version` module (pure): compute/parse the nightly + stable version, derive + channel. Testable in isolation. +- `update-settings` module (pure-ish): read/write the three `~/.ao` settings. +- `auto-updater` shell (`main.ts`): wires `electron-updater` to the two modules; + no logic of its own beyond Electron event glue. +- nightly CI workflow: version computation (reuses the same logic, shell form) + - build + publish. +- macOS feed step in the release workflow: emit + upload `*-mac.yml`. + +## Open prerequisites summary + +| Item | Status | Blocks | +| ----------------------------------------- | ----------------------- | ---------------------------------------------------- | +| Feed `*.yml` publishing (all 3 platforms) | NOT done (verified gap) | ALL auto-update (no feed = updater inert everywhere) | +| CI signing + notarization | Track B, not done | macOS auto-update (functional) | +| Stable version stamping | Track B, not done | stable channel correctness | +| Polished channel UI (#2207) | deferred | nicety only; minimal selector now | +| Global Settings page (#2218) | done | (unblocks #2207) | + +The feed-publishing item is the load-bearing prerequisite the original spec +missed. The runtime implemented here does nothing useful until it lands. diff --git a/frontend/forge.config.ts b/frontend/forge.config.ts index c36159742..152bf5e71 100644 --- a/frontend/forge.config.ts +++ b/frontend/forge.config.ts @@ -119,13 +119,7 @@ const config: ForgeConfig = { // intentionally NOT the default; releases land on AgentWrapper. config: { repository: parseReleaseRepo(process.env.AO_RELEASE_REPO), - prerelease: false, - // draft:false so the release is immediately live, which is what the - // `ao start` bootstrapper's constant - // releases/latest/download/ URL needs to 302-resolve. A draft - // release 404s on that URL (spec §2.7, §8, §11.4). Prod may later - // switch to a draft + manual-finalize flow; for now the bootstrapper - // needs a published release. + prerelease: process.env.AO_RELEASE_PRERELEASE === "true", draft: false, }, }, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5e4b1cbe2..67ed1ebd7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,6 +24,7 @@ "@xterm/xterm": "^5.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "electron-updater": "^6.8.9", "lucide-react": "^1.17.0", "openapi-fetch": "^0.17.0", "posthog-js": "^1.390.2", @@ -32,7 +33,6 @@ "react-dom": "^19.2.7", "react-resizable-panels": "^4.11.2", "tailwind-merge": "^3.6.0", - "update-electron-app": "^3.0.0", "zustand": "^5.0.14" }, "devDependencies": { @@ -7265,7 +7265,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -7622,7 +7621,6 @@ "version": "9.7.0", "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -8469,7 +8467,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "devOptional": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -9288,6 +9285,69 @@ "dev": true, "license": "ISC" }, + "node_modules/electron-updater": { + "version": "6.8.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz", + "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.7.0", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-updater/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-updater/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/electron-winstaller": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", @@ -10292,15 +10352,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/github-url-to-object": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/github-url-to-object/-/github-url-to-object-4.0.6.tgz", - "integrity": "sha512-NaqbYHMUAlPcmWFdrAB7bcxrNIiiJWJe8s/2+iOc9vlcHlwHqSGrPk+Yi3nu6ebTwgsZEa7igz+NH2vEq3gYwQ==", - "license": "MIT", - "dependencies": { - "is-url": "^1.1.0" - } - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -10484,7 +10535,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "devOptional": true, "license": "ISC" }, "node_modules/has-flag": { @@ -10874,12 +10924,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-url": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", - "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", - "license": "MIT" - }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -10999,7 +11043,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", - "dev": true, "funding": [ { "type": "github", @@ -11151,7 +11194,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true, "license": "MIT" }, "node_modules/lightningcss": { @@ -11591,6 +11633,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, "node_modules/lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", @@ -11599,6 +11647,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -13977,7 +14032,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -14713,6 +14767,12 @@ "license": "MIT", "optional": true }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -15061,16 +15121,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/update-electron-app": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/update-electron-app/-/update-electron-app-3.2.0.tgz", - "integrity": "sha512-l2e7bzsW+rw70pfyyQeA9E/ofpNY2ZS99XuYxD2qWL4fEy3qMjpqwwgB0me7ESpGogIQE1CM0SaDvKGsK4Jg3Q==", - "license": "MIT", - "dependencies": { - "github-url-to-object": "^4.0.4", - "ms": "^2.1.1" - } - }, "node_modules/uri-js-replace": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 12429528d..bca608324 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -69,6 +69,7 @@ "@xterm/xterm": "^5.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "electron-updater": "^6.8.9", "lucide-react": "^1.17.0", "openapi-fetch": "^0.17.0", "posthog-js": "^1.390.2", @@ -77,7 +78,6 @@ "react-dom": "^19.2.7", "react-resizable-panels": "^4.11.2", "tailwind-merge": "^3.6.0", - "update-electron-app": "^3.0.0", "zustand": "^5.0.14" }, "optionalDependencies": { diff --git a/frontend/scripts/nightly-version.mjs b/frontend/scripts/nightly-version.mjs new file mode 100644 index 000000000..412b015d7 --- /dev/null +++ b/frontend/scripts/nightly-version.mjs @@ -0,0 +1,36 @@ +// Pure version math shared by the nightly CI workflow. Kept dependency-free +// ESM so `node scripts/nightly-version.mjs` runs it directly in CI and vitest +// unit-tests it. The app does NOT compute versions; it only reads its injected +// app.getVersion(), so this lives in scripts/, not src/. + +const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/; + +// computeNightlyVersion builds X.Y.(Z+1)-nightly.+. +// Next-patch base keeps a nightly ahead of the last stable and behind the next. +// The fixed-width UTC timestamp makes prerelease ids order by build time; the +// sha is semver build metadata (ignored for ordering, kept for traceability). +export function computeNightlyVersion(latestStableTag, now, shortSha) { + const bare = String(latestStableTag).replace(/^(desktop-)?v/, ""); + const m = SEMVER.exec(bare); + if (!m) { + throw new Error(`nightly-version: base tag is not X.Y.Z: ${latestStableTag}`); + } + const [major, minor, patch] = [Number(m[1]), Number(m[2]), Number(m[3])]; + const ts = + String(now.getUTCFullYear()) + + String(now.getUTCMonth() + 1).padStart(2, "0") + + String(now.getUTCDate()).padStart(2, "0") + + String(now.getUTCHours()).padStart(2, "0") + + String(now.getUTCMinutes()).padStart(2, "0"); + return `${major}.${minor}.${patch + 1}-nightly.${ts}+${shortSha}`; +} + +// CLI entry for CI: node scripts/nightly-version.mjs +if (import.meta.url === `file://${process.argv[1]}`) { + const [, , latestStableTag, shortSha] = process.argv; + if (!latestStableTag || !shortSha) { + process.stderr.write("usage: node nightly-version.mjs \n"); + process.exit(2); + } + process.stdout.write(computeNightlyVersion(latestStableTag, new Date(), shortSha)); +} diff --git a/frontend/scripts/nightly-version.test.mjs b/frontend/scripts/nightly-version.test.mjs new file mode 100644 index 000000000..6fd8e5798 --- /dev/null +++ b/frontend/scripts/nightly-version.test.mjs @@ -0,0 +1,27 @@ +// @vitest-environment node +import { describe, it, expect } from "vitest"; +import { computeNightlyVersion } from "./nightly-version.mjs"; + +const now = new Date("2026-06-27T03:00:00.000Z"); + +describe("computeNightlyVersion", () => { + it("bumps the patch and formats a UTC-timestamped nightly prerelease with sha build metadata", () => { + expect(computeNightlyVersion("0.10.3", now, "ab12cd3")).toBe("0.10.4-nightly.202606270300+ab12cd3"); + }); + + it("strips a v / desktop-v tag prefix", () => { + expect(computeNightlyVersion("v0.10.3", now, "ab12cd3")).toBe("0.10.4-nightly.202606270300+ab12cd3"); + expect(computeNightlyVersion("desktop-v0.10.3", now, "ab12cd3")).toBe("0.10.4-nightly.202606270300+ab12cd3"); + }); + + it("orders monotonically by timestamp for the same base", () => { + const earlier = computeNightlyVersion("0.10.3", new Date("2026-06-27T03:00:00Z"), "aaaaaaa"); + const later = computeNightlyVersion("0.10.3", new Date("2026-06-27T04:00:00Z"), "bbbbbbb"); + // prerelease identifiers compare lexically; zero-padded fixed-width timestamp sorts correctly + expect(later > earlier).toBe(true); + }); + + it("rejects a non-semver base tag", () => { + expect(() => computeNightlyVersion("not-a-version", now, "ab12cd3")).toThrow(); + }); +}); diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 350dffd89..a0ff69d63 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -11,7 +11,7 @@ import { WebContentsView, type OpenDialogOptions, } from "electron"; -import { updateElectronApp } from "update-electron-app"; +import { startAutoUpdates, ensureUpdatePrefs } from "./main/auto-updater"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { existsSync } from "node:fs"; import { readFile, rm } from "node:fs/promises"; @@ -828,7 +828,10 @@ ipcMain.handle("notifications:show", (_event, notification: { id: string; title: // frontend/docs/desktop-release.md. function initAutoUpdates(): void { if (!app.isPackaged) return; - updateElectronApp(); + const runFile = runFilePath(); + if (!runFile) return; + const stateDir = path.dirname(runFile); + void ensureUpdatePrefs(stateDir).then(() => startAutoUpdates(stateDir)); } // Resolve the bundle path `ao start` will later `open` and stat as a usable app. diff --git a/frontend/src/main/auto-updater.ts b/frontend/src/main/auto-updater.ts new file mode 100644 index 000000000..bab389017 --- /dev/null +++ b/frontend/src/main/auto-updater.ts @@ -0,0 +1,89 @@ +import { autoUpdater } from "electron-updater"; +import { dialog } from "electron"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { readUpdateSettings, writeUpdateSettings, UPDATE_SETTINGS_FILE_NAME } from "./update-settings"; + +// Default release repo, mirroring backend cli.releaseRepo. Override via env for +// fork test builds (AO_RELEASE_REPO=owner/repo). +const DEFAULT_RELEASE_REPO = "AgentWrapper/agent-orchestrator"; + +function repo(): { owner: string; name: string } { + const [owner, name] = (process.env.AO_RELEASE_REPO || DEFAULT_RELEASE_REPO).split("/"); + if (owner && name) return { owner, name }; + const [defOwner, defName] = DEFAULT_RELEASE_REPO.split("/"); + return { owner: defOwner, name: defName }; +} + +// startAutoUpdates configures electron-updater from the user's ~/.ao settings. +// It is a thin shell: all policy (channel, opt-in) comes from update-settings. +// Caller guards on app.isPackaged. +export async function startAutoUpdates(stateDir: string): Promise { + const settings = await readUpdateSettings(stateDir); + if (!settings.enabled) return; + + const { owner, name } = repo(); + autoUpdater.setFeedURL({ provider: "github", owner, repo: name }); + autoUpdater.channel = settings.channel; // "latest" | "nightly" + autoUpdater.allowDowngrade = true; // permits a nightly -> stable channel switch + autoUpdater.autoDownload = true; + autoUpdater.autoInstallOnAppQuit = true; + + autoUpdater.on("error", (err) => { + // Never crash on update failure (offline, unsigned macOS, etc.). + console.error("auto-update error:", err?.message ?? err); + }); + + try { + await autoUpdater.checkForUpdates(); + } catch (err) { + console.error("auto-update check failed:", err); + } +} + +// ensureUpdatePrefs prompts once (first run, before any settings file exists) +// for auto-update opt-in + channel, with a nightly instability disclaimer. +export async function ensureUpdatePrefs(stateDir: string): Promise { + if (existsSync(path.join(stateDir, UPDATE_SETTINGS_FILE_NAME))) return; + + const optIn = await dialog.showMessageBox({ + type: "question", + buttons: ["Enable auto-updates", "Not now"], + defaultId: 0, + cancelId: 1, + message: "Keep Agent Orchestrator up to date automatically?", + detail: "You can change this later in Settings.", + }); + if (optIn.response !== 0) { + await writeUpdateSettings(stateDir, { enabled: false, channel: "latest", nightlyAck: false }); + return; + } + + const chan = await dialog.showMessageBox({ + type: "question", + buttons: ["Stable", "Nightly"], + defaultId: 0, + cancelId: 0, + message: "Which update channel?", + detail: "Stable is released and tested. Nightly is the newest daily build.", + }); + if (chan.response !== 1) { + await writeUpdateSettings(stateDir, { enabled: true, channel: "latest", nightlyAck: false }); + return; + } + + const ack = await dialog.showMessageBox({ + type: "warning", + buttons: ["I understand, use Nightly", "Use Stable instead"], + defaultId: 1, + cancelId: 1, + message: "Nightly builds can be unstable", + detail: "Nightly is built every day and may be broken or lose data. Only use it if you are comfortable with that.", + }); + await writeUpdateSettings( + stateDir, + ack.response === 0 + ? { enabled: true, channel: "nightly", nightlyAck: true } + : { enabled: true, channel: "latest", nightlyAck: false }, + ); +} diff --git a/frontend/src/main/update-settings.test.ts b/frontend/src/main/update-settings.test.ts new file mode 100644 index 000000000..54d58045d --- /dev/null +++ b/frontend/src/main/update-settings.test.ts @@ -0,0 +1,45 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm, writeFile, readdir } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { readUpdateSettings, writeUpdateSettings, UPDATE_SETTINGS_FILE_NAME } from "./update-settings"; + +describe("update-settings", () => { + let dir: string; + beforeEach(async () => { + dir = await mkdtemp(path.join(os.tmpdir(), "ao-update-settings-")); + }); + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("returns safe defaults when no file exists", async () => { + expect(await readUpdateSettings(dir)).toEqual({ enabled: false, channel: "latest", nightlyAck: false }); + }); + + it("round-trips written settings", async () => { + await writeUpdateSettings(dir, { enabled: true, channel: "nightly", nightlyAck: true }); + expect(await readUpdateSettings(dir)).toEqual({ enabled: true, channel: "nightly", nightlyAck: true }); + }); + + it("falls back to defaults on garbage", async () => { + await writeFile(path.join(dir, UPDATE_SETTINGS_FILE_NAME), "{not json", "utf8"); + expect(await readUpdateSettings(dir)).toEqual({ enabled: false, channel: "latest", nightlyAck: false }); + }); + + it("coerces an unknown channel back to latest", async () => { + await writeFile( + path.join(dir, UPDATE_SETTINGS_FILE_NAME), + JSON.stringify({ enabled: true, channel: "weird", nightlyAck: false }), + "utf8", + ); + expect((await readUpdateSettings(dir)).channel).toBe("latest"); + }); + + it("atomic write leaves no temp file behind", async () => { + await writeUpdateSettings(dir, { enabled: true, channel: "latest", nightlyAck: false }); + const entries = await readdir(dir); + expect(entries).toEqual([UPDATE_SETTINGS_FILE_NAME]); + }); +}); diff --git a/frontend/src/main/update-settings.ts b/frontend/src/main/update-settings.ts new file mode 100644 index 000000000..30d47a476 --- /dev/null +++ b/frontend/src/main/update-settings.ts @@ -0,0 +1,49 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; + +export type UpdateChannel = "latest" | "nightly"; + +export interface UpdateSettings { + enabled: boolean; + channel: UpdateChannel; + nightlyAck: boolean; +} + +/** File holding the user's auto-update preferences under the ~/.ao state dir. */ +export const UPDATE_SETTINGS_FILE_NAME = "update-settings.json"; + +const DEFAULTS: UpdateSettings = { enabled: false, channel: "latest", nightlyAck: false }; + +function coerce(raw: unknown): UpdateSettings { + const o = (raw ?? {}) as Record; + return { + enabled: o.enabled === true, + channel: o.channel === "nightly" ? "nightly" : "latest", + nightlyAck: o.nightlyAck === true, + }; +} + +/** Read update settings, tolerating a missing or corrupt file (returns defaults). */ +export async function readUpdateSettings(stateDir: string): Promise { + let raw: string; + try { + raw = await readFile(path.join(stateDir, UPDATE_SETTINGS_FILE_NAME), "utf8"); + } catch { + return { ...DEFAULTS }; + } + try { + return coerce(JSON.parse(raw)); + } catch { + return { ...DEFAULTS }; + } +} + +/** Atomically write update settings (temp file + rename), mirroring app-state.ts. */ +export async function writeUpdateSettings(stateDir: string, settings: UpdateSettings): Promise { + await mkdir(stateDir, { recursive: true, mode: 0o750 }); + const file = path.join(stateDir, UPDATE_SETTINGS_FILE_NAME); + const data = `${JSON.stringify(coerce(settings), null, 2)}\n`; + const tmp = path.join(stateDir, `.update-settings-${process.pid}-${Date.now()}.json`); + await writeFile(tmp, data, { mode: 0o600 }); + await rename(tmp, file); +}