feat(update): channel-aware auto-update runtime + nightly pipeline (#2221)

* docs(spec): auto-update with stable + nightly channels

Design spec for channel-aware auto-update via electron-updater: stable
(manual semver releases) and nightly (daily CI cron, version
X.Y.(Z+1)-nightly.<UTC-ts>+<sha>). Covers runtime wiring, the feed,
the version scheme and its ordering/channel rationale, the nightly
pipeline, in-app UX (opt-in + channel + nightly disclaimer), error
handling, testing, and the deferred/prereq items (CI signing for macOS
updates, stable version stamping, polished UI in #2207).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(plan): auto-update channels implementation plan

Bite-sized TDD plan for the in-app channel-aware updater + nightly pipeline:
nightly version compute module, daily cron workflow, macOS feed metadata,
~/.ao update-settings module, electron-updater shell + main.ts wiring, and the
first-run opt-in/channel/nightly-disclaimer prompt. Deferred items and Track-B
prereqs (CI signing, stable stamping) are called out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(release): nightly version compute module

computeNightlyVersion -> X.Y.(Z+1)-nightly.<UTC-ts>+<sha>: 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix(release): pass nightly version via env, not inline interpolation

Avoids interpolating ${{ }} into the run script (GitHub Actions injection
hardening); the version now arrives via the NIGHTLY_VERSION env var.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* style(update): tabs in auto-updater.ts to match repo prettier config

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* docs(spec): correct feed-yml premise (no platform emits it as built)

Final review verified that publish:null in both custom makers makes
electron-builder skip update-info yml generation on Windows and Linux too
(app-builder-lib PublishManager returns early before createUpdateInfoTasks),
not just signing-blocked macOS. The runtime updater is therefore inert on all
platforms until a separate feed-publishing workstream (yml gen+upload across
all 3 platforms, coupled to Track-B signing) lands. Recorded in the Feed
section and the prerequisites table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(release): nightly skip-guard must match the v<version> tag namespace

The forge publisher tags nightly releases v<version> (e.g.
v0.10.4-nightly.<ts>), not desktop-v..., so the no-new-commits guard queried a
namespace that never exists and never fired. Query v*-nightly.* instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: format with prettier [skip ci]

* chore(release): run nightly at 19:00 IST (13:30 UTC)

Was 03:00 UTC (08:30 AM IST); move to 13:30 UTC = 7:00 PM IST.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Harshit Singh Bhandari 2026-06-27 02:26:56 +05:30 committed by GitHub
parent 572093de9d
commit a1789a3a1e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1386 additions and 43 deletions

89
.github/workflows/frontend-nightly.yml vendored Normal file
View File

@ -0,0 +1,89 @@
# .github/workflows/frontend-nightly.yml
name: Desktop nightly
# Daily nightly build on main. Computes X.Y.(Z+1)-nightly.<UTC-ts>+<sha> 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 }}

View File

@ -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.<UTC-ts>+<short-sha>`. 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 <noreply@anthropic.com>`; 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.<UTC-timestamp YYYYMMDDHHMM>+<short-sha>`.
- 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.<YYYYMMDDHHMM>+<shortSha>`. Accepts `latestStableTag` either bare (`0.10.3`) or tag-prefixed (`v0.10.3` / `desktop-v0.10.3`).
- Produces (CLI): `node scripts/nightly-version.mjs <latestStableTag> <shortSha>` 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.<YYYYMMDDHHMM>+<shortSha>.
// 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 <latestStableTag> <shortSha>
if (import.meta.url === `file://${process.argv[1]}`) {
const [, , latestStableTag, shortSha] = process.argv;
if (!latestStableTag || !shortSha) {
process.stderr.write("usage: node nightly-version.mjs <latestStableTag> <shortSha>\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.<current-UTC-YYYYMMDDHHMM>+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.<UTC-ts>+<sha>: 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 <noreply@anthropic.com>
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<nightly-version-without-build-metadata>` 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.<UTC-ts>+<sha> 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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<UpdateSettings>` (returns defaults `{enabled:false, channel:"latest", nightlyAck:false}` when the file is missing/garbage).
- `writeUpdateSettings(stateDir: string, settings: UpdateSettings): Promise<void>` (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<string, unknown>;
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<UpdateSettings> {
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<void> {
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 <noreply@anthropic.com>
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<void>` 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<void> {
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 <noreply@anthropic.com>
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<void>` 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<void> {
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 <noreply@anthropic.com>
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.

View File

@ -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.<UTC-timestamp>+<short-sha>
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.<UTC-timestamp>` 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.** `+<short-sha>` 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.<UTC-timestamp>+<short-sha>`, 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.

View File

@ -119,13 +119,7 @@ const config: ForgeConfig = {
// intentionally NOT the default; releases land on AgentWrapper. // intentionally NOT the default; releases land on AgentWrapper.
config: { config: {
repository: parseReleaseRepo(process.env.AO_RELEASE_REPO), repository: parseReleaseRepo(process.env.AO_RELEASE_REPO),
prerelease: false, prerelease: process.env.AO_RELEASE_PRERELEASE === "true",
// draft:false so the release is immediately live, which is what the
// `ao start` bootstrapper's constant
// releases/latest/download/<asset> 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.
draft: false, draft: false,
}, },
}, },

View File

@ -24,6 +24,7 @@
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"electron-updater": "^6.8.9",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
"openapi-fetch": "^0.17.0", "openapi-fetch": "^0.17.0",
"posthog-js": "^1.390.2", "posthog-js": "^1.390.2",
@ -32,7 +33,6 @@
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-resizable-panels": "^4.11.2", "react-resizable-panels": "^4.11.2",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"update-electron-app": "^3.0.0",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
@ -7265,7 +7265,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/aria-hidden": { "node_modules/aria-hidden": {
@ -7622,7 +7621,6 @@
"version": "9.7.0", "version": "9.7.0",
"resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz",
"integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"debug": "^4.3.4", "debug": "^4.3.4",
@ -8469,7 +8467,6 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@ -9288,6 +9285,69 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/electron-winstaller": {
"version": "5.4.0", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz",
@ -10292,15 +10352,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/glob": {
"version": "7.2.3", "version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@ -10484,7 +10535,6 @@
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"devOptional": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/has-flag": { "node_modules/has-flag": {
@ -10874,12 +10924,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/isarray": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
@ -10999,7 +11043,6 @@
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -11151,7 +11194,6 @@
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lightningcss": { "node_modules/lightningcss": {
@ -11591,6 +11633,12 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/lodash.get": {
"version": "4.4.2", "version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
@ -11599,6 +11647,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/log-symbols": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
@ -13977,7 +14032,6 @@
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
"dev": true,
"license": "BlueOak-1.0.0", "license": "BlueOak-1.0.0",
"engines": { "engines": {
"node": ">=11.0.0" "node": ">=11.0.0"
@ -14713,6 +14767,12 @@
"license": "MIT", "license": "MIT",
"optional": true "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": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@ -15061,16 +15121,6 @@
"browserslist": ">= 4.21.0" "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": { "node_modules/uri-js-replace": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",

View File

@ -69,6 +69,7 @@
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"electron-updater": "^6.8.9",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
"openapi-fetch": "^0.17.0", "openapi-fetch": "^0.17.0",
"posthog-js": "^1.390.2", "posthog-js": "^1.390.2",
@ -77,7 +78,6 @@
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-resizable-panels": "^4.11.2", "react-resizable-panels": "^4.11.2",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"update-electron-app": "^3.0.0",
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
"optionalDependencies": { "optionalDependencies": {

View File

@ -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.<YYYYMMDDHHMM>+<shortSha>.
// 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 <latestStableTag> <shortSha>
if (import.meta.url === `file://${process.argv[1]}`) {
const [, , latestStableTag, shortSha] = process.argv;
if (!latestStableTag || !shortSha) {
process.stderr.write("usage: node nightly-version.mjs <latestStableTag> <shortSha>\n");
process.exit(2);
}
process.stdout.write(computeNightlyVersion(latestStableTag, new Date(), shortSha));
}

View File

@ -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();
});
});

View File

@ -11,7 +11,7 @@ import {
WebContentsView, WebContentsView,
type OpenDialogOptions, type OpenDialogOptions,
} from "electron"; } from "electron";
import { updateElectronApp } from "update-electron-app"; import { startAutoUpdates, ensureUpdatePrefs } from "./main/auto-updater";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { readFile, rm } from "node:fs/promises"; 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. // frontend/docs/desktop-release.md.
function initAutoUpdates(): void { function initAutoUpdates(): void {
if (!app.isPackaged) return; 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. // Resolve the bundle path `ao start` will later `open` and stat as a usable app.

View File

@ -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<void> {
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<void> {
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 },
);
}

View File

@ -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]);
});
});

View File

@ -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<string, unknown>;
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<UpdateSettings> {
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<void> {
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);
}