feat(cli): ao start fetch/open for Windows and Linux (T6/T7) (#2204)

* feat(start): implement ao start fetch/open for Windows and Linux

Fill in the non-darwin branches of the bootstrapper (T6/T7):

- assetName() selects the per-GOOS stable release asset: windows ->
  agent-orchestrator-win32-x64.exe (NSIS installer), linux ->
  agent-orchestrator-linux-x64.AppImage. amd64-only for now via
  requireAMD64(), which returns a clear unsupported-arch error.
- fetchApp() dispatches per GOOS. Windows downloads the NSIS installer and
  runs it silently (/S) to the default per-user dir, then resolves the
  installed exe under %LOCALAPPDATA%\Programs. Linux downloads the AppImage
  to a stable path under ~/.ao (atomic temp+rename), chmods it executable,
  and skips any install step so re-runs resolve without re-fetching.
- knownAppLocations() scans the per-user and per-machine Windows install
  dirs and the stable Linux AppImage path.
- isUsableBundle() treats a win exe / linux AppImage as a regular file
  (darwin stays a directory).
- openApp() launches win/linux detached via the existing StartProcess seam,
  forwarding --installed-via=npm-bootstrap, and falls back to manual-open on
  spawn failure.

The Windows silent-install path is marked ponytail (untestable on the macOS
build host); a wrong install dir surfaces as a clear not-found error. Tests
cover asset naming, arch gating, scan locations, regular-file vs dir, and
the detached-spawn + fallback paths.

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

* feat(release): build + publish a Linux AppImage for ao start (T7)

AppImage is the Linux fetch-and-run artifact for the bootstrapper (spec
§11.3): a single self-contained executable ao start downloads and runs
directly, no system package manager.

- makers/maker-appimage.ts: a MakerBase subclass bridging to
  electron-builder's buildForge (appImage target), mirroring maker-nsis.ts,
  since Forge has no first-party AppImage maker. publish:null so Forge owns
  release uploads.
- forge.config.ts: register MakerAppImage for linux; keep deb/rpm for users
  who want a system package.
- frontend-release.yml: on ubuntu-latest, copy the built AppImage to the
  stable, space-free name agent-orchestrator-linux-x64.AppImage and upload
  it to the v<version> release with --clobber, mirroring the Windows step.

Build-untested on this macOS host: the first ubuntu CI run must confirm the
electron-builder AppImage target token and the out/make/*.AppImage output
glob.

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

* fix(ci): green up ao start Win/Linux PR (lint, cross-OS test, container smoke)

Five golangci-lint findings in start.go, two cross-OS test failures, and the
fresh-install container smoke were broken by the Win/Linux bootstrapper diff.

Go lint (start.go):
- gocritic filepathJoin: build "/Applications/<bundle>" as a literal instead of
  filepath.Join with a separator-bearing arg.
- gocritic httpNoBody: pass http.NoBody, not nil, to NewRequestWithContext.
- gosec G302: annotate the AppImage chmod 0755 with a nolint; an AppImage is a
  self-contained executable and must be executable.
- nilerr: annotate openApp's intentional (false, nil) on launch failure; the
  failure is reported via the bool, not as an error.
- unparam: resolveApp's error result was always nil; drop it and update callers.

Cross-OS tests (start_test.go):
- makeBundle created a directory, which only stats as usable on macOS. Make it a
  regular file on Windows/Linux so the marker/scan resolve tests pass there,
  matching isUsableBundle's per-OS rule.

Container fresh-install smoke (test/cli/install-check.sh, Dockerfile):
- ao start is now the desktop-app launcher and no longer runs a daemon, so the
  old daemon status/shutdown/stop assertions could never pass. Assert instead
  that on a fresh box start reaches the fetch path and exits non-zero with a
  clear error (404 download on amd64, unsupported-arch on arm64). Refresh the
  stale daemon-reaping comments in the Dockerfile.

Verified locally: go build/vet ok, golangci-lint v2.12.2 reports 0 issues,
go test -tags e2e ./internal/cli/... passes (the only remaining failure,
TestE2E_Lifecycle, is a pre-existing daemon-shutdown flake that fails the same
way on upstream/main on this host), and the container smoke passes on both
linux/arm64 and linux/amd64.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Harshit Singh Bhandari 2026-06-26 20:50:30 +05:30 committed by GitHub
parent 514946fd8b
commit bc84b6d2a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 478 additions and 70 deletions

View File

@ -125,7 +125,22 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Linux: deb/rpm build on this runner (issue #2191), but the stable asset
# name for the fetch-and-run bootstrapper is still undecided (deb/rpm vs
# AppImage, spec §11.3). No alias is uploaded until that decision lands; the
# versioned deb/rpm are still published by the forge publisher above.
# Linux: deb/rpm AND an AppImage build on this runner (issue #2191). The
# AppImage is the fetch-and-run artifact for `ao start` (spec §11.3); we
# copy it to the stable, space-free name the bootstrapper fetches and
# upload to the v<version> release. The versioned deb/rpm are still
# published by the forge publisher above for users who want a system package.
- name: Upload stable asset aliases (Linux)
if: matrix.os == 'ubuntu-latest'
shell: bash
run: |
# Forge publishes to v<version> (see the macOS step), not the git tag.
tag="v$(node -p "require('./package.json').version")"
alias_name="agent-orchestrator-linux-x64.AppImage"
# AppImage (electron-builder) output: out/make/<productName>-<version>.AppImage
src="$(ls out/make/*.AppImage | head -n1)"
if [ -z "$src" ]; then echo "no AppImage found under out/make" >&2; exit 1; fi
cp "$src" "$alias_name"
gh release upload "$tag" "$alias_name" --clobber
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -84,12 +84,10 @@ func (c *commandContext) runStart(ctx context.Context, cmd *cobra.Command, opts
out := cmd.OutOrStdout()
res := startResult{}
appPath, err := c.resolveApp()
if err != nil {
return err
}
appPath := c.resolveApp()
res.Resolved = appPath != ""
var err error
if appPath == "" {
appPath, err = c.fetchApp(ctx)
if err != nil {
@ -119,16 +117,16 @@ func (c *commandContext) runStart(ctx context.Context, cmd *cobra.Command, opts
// resolveApp returns the path to a usable desktop bundle, or "" when none is
// found (spec §6.2). Resolution order is fixed: marker path -> stat -> known
// location scan. It never compares versions (invariant 5).
func (c *commandContext) resolveApp() (string, error) {
func (c *commandContext) resolveApp() string {
if p := c.markerAppPath(); p != "" && isUsableBundle(p) {
return p, nil
return p
}
for _, p := range appScanLocations() {
if isUsableBundle(p) {
return p, nil
return p
}
}
return "", nil
return ""
}
// appScanLocations is the known-location scan source. It is a package var so
@ -169,19 +167,55 @@ func aoStateDir() (string, error) {
func knownAppLocations() []string {
switch runtime.GOOS {
case "darwin":
paths := []string{filepath.Join("/Applications", appBundleName)}
paths := []string{"/Applications/" + appBundleName}
if home, err := os.UserHomeDir(); err == nil {
paths = append(paths, filepath.Join(home, "Applications", appBundleName))
}
return paths
case "windows":
var paths []string
// Default electron-builder NSIS per-user install (perMachine:false).
if local := os.Getenv("LOCALAPPDATA"); local != "" {
paths = append(paths, windowsInstalledExe(local))
}
// Per-machine fallback (if a user chose an all-users install).
if pf := os.Getenv("ProgramFiles"); pf != "" {
paths = append(paths, filepath.Join(pf, "Agent Orchestrator", "agent-orchestrator.exe"))
}
return paths
case "linux":
paths := []string{linuxAppImagePath()}
if home, err := os.UserHomeDir(); err == nil {
paths = append(paths, filepath.Join(home, "Applications", "agent-orchestrator.AppImage"))
}
return paths
default:
// Windows/Linux scan locations land in T6/T7.
return nil
}
}
// windowsInstalledExe is the default per-user electron-builder NSIS install
// target for the app exe under %LOCALAPPDATA%.
func windowsInstalledExe(localAppData string) string {
return filepath.Join(localAppData, "Programs", "Agent Orchestrator", "agent-orchestrator.exe")
}
// linuxAppImagePath is the stable location `ao start` downloads the AppImage to
// and scans for. Keeping it out of the cleared staging dir lets re-runs resolve
// the existing download instead of re-fetching (spec §6.2/§6.3).
func linuxAppImagePath() string {
dir, err := aoStateDir()
if err != nil {
// Fall back to a bare filename so a misconfigured state dir surfaces as a
// clear "not found" rather than a panic; fetch will re-error on download.
return "agent-orchestrator.AppImage"
}
return filepath.Join(dir, "agent-orchestrator.AppImage")
}
// isUsableBundle reports whether p stats as a usable app bundle. On macOS a
// bundle is a directory; the filesystem is the source of truth (invariant 2).
// bundle is a directory; on Windows/Linux it is a regular file (the installed
// exe / the AppImage). The filesystem is the source of truth (invariant 2).
func isUsableBundle(p string) bool {
if p == "" {
return false
@ -193,18 +227,31 @@ func isUsableBundle(p string) bool {
if runtime.GOOS == "darwin" {
return info.IsDir()
}
return true
// Windows exe / Linux AppImage: must be a regular file, not a directory.
return info.Mode().IsRegular()
}
// fetchApp downloads the latest desktop release for this platform, unpacks it
// into a staging dir under ~/.ao/staging, and returns the bundle path (spec
// §6.3). Windows/Linux are tracked as T6/T7.
// fetchApp downloads the latest desktop release for this platform and returns
// the resolved bundle path (spec §6.3). macOS unpacks a signed zip into staging,
// Windows runs the NSIS installer silently, and Linux drops a chmod'd AppImage at
// a stable path.
func (c *commandContext) fetchApp(ctx context.Context) (string, error) {
if runtime.GOOS != "darwin" {
return "", fmt.Errorf("ao start: fetch not yet implemented for %s (tracked as spec T6/T7)", runtime.GOOS)
switch runtime.GOOS {
case "darwin":
return c.fetchAppDarwin(ctx)
case "windows":
return c.fetchAppWindows(ctx)
case "linux":
return c.fetchAppLinux(ctx)
default:
return "", fmt.Errorf("ao start: fetch not supported on %s", runtime.GOOS)
}
}
asset, err := darwinAssetName()
// fetchAppDarwin downloads the latest macOS release zip and unpacks it into a
// staging dir under ~/.ao/staging, returning the .app bundle path (spec §6.3).
func (c *commandContext) fetchAppDarwin(ctx context.Context) (string, error) {
asset, err := assetName()
if err != nil {
return "", err
}
@ -241,9 +288,91 @@ func (c *commandContext) fetchApp(ctx context.Context) (string, error) {
return appPath, nil
}
// fetchAppWindows downloads the NSIS installer into staging, runs it silently,
// and returns the default per-user install path (spec §6.3).
//
// ponytail: the silent-install flow (NSIS `/S`, default per-user dir) is
// untested on real Windows hardware (this build host is macOS). If the installed
// exe isn't where electron-builder's defaults put it, this surfaces as a clear
// "not found" error rather than silently launching the wrong thing.
func (c *commandContext) fetchAppWindows(ctx context.Context) (string, error) {
asset, err := assetName()
if err != nil {
return "", err
}
url := downloadURL(asset)
stateDir, err := aoStateDir()
if err != nil {
return "", err
}
staging := filepath.Join(stateDir, "staging")
if err := os.RemoveAll(staging); err != nil {
return "", fmt.Errorf("clear staging dir: %w", err)
}
if err := os.MkdirAll(staging, 0o750); err != nil {
return "", fmt.Errorf("create staging dir: %w", err)
}
installerPath := filepath.Join(staging, asset)
if err := c.download(ctx, url, installerPath); err != nil {
return "", fmt.Errorf("download %s: %w", url, err)
}
// NSIS silent install (`/S`) to the default per-user location. The installer
// is configured oneClick:false/perMachine:false, so `/S` installs without UI
// under %LOCALAPPDATA% for the current user.
if out, err := c.deps.CommandOutput(ctx, installerPath, "/S"); err != nil {
return "", fmt.Errorf("silent install: %w: %s", err, out)
}
local := os.Getenv("LOCALAPPDATA")
if local == "" {
return "", fmt.Errorf("ao start: LOCALAPPDATA not set; cannot locate installed app")
}
appPath := windowsInstalledExe(local)
if !isUsableBundle(appPath) {
return "", fmt.Errorf("ao start: installed app not found at %s", appPath)
}
return appPath, nil
}
// fetchAppLinux downloads the self-contained AppImage to a stable path under
// ~/.ao, makes it executable, and returns it. There is no install step (spec
// §6.3). Re-runs resolve the existing file via knownAppLocations and skip fetch.
func (c *commandContext) fetchAppLinux(ctx context.Context) (string, error) {
asset, err := assetName()
if err != nil {
return "", err
}
url := downloadURL(asset)
appPath := linuxAppImagePath()
if err := os.MkdirAll(filepath.Dir(appPath), 0o750); err != nil {
return "", fmt.Errorf("create state dir: %w", err)
}
// Download to a temp name in the same dir, then rename for atomicity so a
// killed download never leaves a half-written executable at the stable path.
tmpPath := appPath + ".part"
if err := c.download(ctx, url, tmpPath); err != nil {
return "", fmt.Errorf("download %s: %w", url, err)
}
// An AppImage is a self-contained executable; it must be 0755 to launch.
if err := os.Chmod(tmpPath, 0o755); err != nil { //nolint:gosec // G302: AppImage must be executable
return "", fmt.Errorf("chmod AppImage: %w", err)
}
if err := os.Rename(tmpPath, appPath); err != nil {
return "", fmt.Errorf("install AppImage: %w", err)
}
if !isUsableBundle(appPath) {
return "", fmt.Errorf("ao start: AppImage not found at %s", appPath)
}
return appPath, nil
}
// download streams url to dst using the injected HTTP client.
func (c *commandContext) download(ctx context.Context, url, dst string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
if err != nil {
return err
}
@ -273,14 +402,42 @@ func (c *commandContext) download(ctx context.Context, url, dst string) error {
return f.Close()
}
// darwinAssetName maps Go's runtime.GOARCH to the release asset name. The
// release pipeline publishes "x64" for amd64 (spec §6.3, §8).
func darwinAssetName() (string, error) {
// assetName maps the current GOOS/GOARCH to the stable release asset name the
// release pipeline publishes (spec §6.3, §8). The pipeline uses "x64" for amd64.
// - darwin: agent-orchestrator-darwin-{arm64,x64}.zip (signed bundle zip)
// - windows: agent-orchestrator-win32-x64.exe (NSIS installer, amd64 only)
// - linux: agent-orchestrator-linux-x64.AppImage (self-contained, amd64 only)
func assetName() (string, error) {
switch runtime.GOOS {
case "darwin":
arch, err := assetArch(runtime.GOARCH)
if err != nil {
return "", err
}
return fmt.Sprintf("agent-orchestrator-darwin-%s.zip", arch), nil
case "windows":
if _, err := requireAMD64(); err != nil {
return "", err
}
return "agent-orchestrator-win32-x64.exe", nil
case "linux":
if _, err := requireAMD64(); err != nil {
return "", err
}
return "agent-orchestrator-linux-x64.AppImage", nil
default:
return "", fmt.Errorf("ao start: no release asset for %s", runtime.GOOS)
}
}
// requireAMD64 enforces the amd64/x64-only support window for Windows and Linux.
// arm64 Windows/Linux are not published yet; surface a clear unsupported error
// mirroring assetArch rather than fetching a 404.
func requireAMD64() (string, error) {
if runtime.GOARCH != "amd64" {
return "", fmt.Errorf("ao start: unsupported architecture %q on %s (only amd64 is published)", runtime.GOARCH, runtime.GOOS)
}
return "x64", nil
}
// assetArch maps a Go GOARCH to the release-asset arch token.
@ -302,18 +459,37 @@ func downloadURL(asset string) string {
// openApp launches the resolved bundle detached and reports whether it launched
// (spec §6.5). It passes --installed-via=npm-bootstrap so the app can record the
// install source in its marker. It never waits on the app.
// install source in its marker. It never waits on the app. A launch failure
// returns (false, nil) so the caller falls back to manual-open instructions.
func (c *commandContext) openApp(ctx context.Context, appPath string) (bool, error) {
if runtime.GOOS != "darwin" {
// Non-darwin open lands in T6/T7; treat as "not opened" so the caller
// prints manual-open instructions.
return false, nil
}
switch runtime.GOOS {
case "darwin":
// `open` returns immediately; --args forwards the rest to the app.
if out, err := c.deps.CommandOutput(ctx, "open", appPath, "--args", "--installed-via=npm-bootstrap"); err != nil {
return false, fmt.Errorf("open %s: %w: %s", appPath, err, out)
}
return true, nil
case "windows", "linux":
// No `open`-style launcher on these platforms; exec the bundle directly,
// detached, so `ao start` does not block on the app. StartProcess uses
// cmd.Start() + a detached SysProcAttr (see process.go).
//
// ponytail: on some Linux hosts the AppImage may need --no-sandbox; not
// added here without evidence the bundled Electron requires it. If sandbox
// launch failures appear, append "--no-sandbox" as the follow-up.
err := c.deps.StartProcess(processStartConfig{
Path: appPath,
Args: []string{"--installed-via=npm-bootstrap"},
})
if err != nil {
// Treat a launch failure as "not opened" so the caller prints the
// manual-open path rather than aborting the whole command.
return false, nil //nolint:nilerr // launch failure is reported via the bool, not as an error
}
return true, nil
default:
return false, nil
}
}
// printDeprecationNotice explains the new role of the npm `ao` binary. Keep it

View File

@ -31,14 +31,22 @@ func writeMarker(t *testing.T, cfg testConfig, appPath string) {
}
}
// makeBundle creates a directory that stats as a usable bundle on every OS.
// makeBundle creates a path that stats as a usable bundle on the host OS:
// a directory on macOS (.app), a regular file on Windows/Linux (exe/AppImage),
// matching isUsableBundle's per-OS rule.
func makeBundle(t *testing.T, name string) string {
t.Helper()
p := filepath.Join(t.TempDir(), name)
if runtime.GOOS == "darwin" {
if err := os.MkdirAll(p, 0o750); err != nil {
t.Fatal(err)
}
return p
}
if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
return p
}
func TestResolveApp_MarkerHit(t *testing.T) {
@ -49,10 +57,7 @@ func TestResolveApp_MarkerHit(t *testing.T) {
t.Cleanup(swapScanLocations(func() []string { return nil }))
c := &commandContext{deps: Deps{}.withDefaults()}
got, err := c.resolveApp()
if err != nil {
t.Fatal(err)
}
got := c.resolveApp()
if got != bundle {
t.Fatalf("resolveApp = %q, want marker path %q", got, bundle)
}
@ -66,10 +71,7 @@ func TestResolveApp_MarkerMissThenScanHit(t *testing.T) {
t.Cleanup(swapScanLocations(func() []string { return []string{scanBundle} }))
c := &commandContext{deps: Deps{}.withDefaults()}
got, err := c.resolveApp()
if err != nil {
t.Fatal(err)
}
got := c.resolveApp()
if got != scanBundle {
t.Fatalf("resolveApp = %q, want scan path %q", got, scanBundle)
}
@ -82,10 +84,7 @@ func TestResolveApp_ScanMissReturnsEmpty(t *testing.T) {
}))
c := &commandContext{deps: Deps{}.withDefaults()}
got, err := c.resolveApp()
if err != nil {
t.Fatal(err)
}
got := c.resolveApp()
if got != "" {
t.Fatalf("resolveApp = %q, want empty", got)
}
@ -117,6 +116,101 @@ func TestAssetArchMapping(t *testing.T) {
}
}
func TestAssetName_PerOS(t *testing.T) {
got, err := assetName()
if err != nil {
// On unsupported arch (e.g. arm64 win/linux) an error is expected; the
// per-OS expectation below only applies on amd64/arm64 darwin.
if runtime.GOOS == "windows" || runtime.GOOS == "linux" {
if runtime.GOARCH != "amd64" {
return // unsupported-arch error is correct
}
}
t.Fatalf("assetName() unexpected error: %v", err)
}
switch runtime.GOOS {
case "darwin":
if got != "agent-orchestrator-darwin-arm64.zip" && got != "agent-orchestrator-darwin-x64.zip" {
t.Fatalf("darwin assetName = %q", got)
}
case "windows":
if got != "agent-orchestrator-win32-x64.exe" {
t.Fatalf("windows assetName = %q, want agent-orchestrator-win32-x64.exe", got)
}
case "linux":
if got != "agent-orchestrator-linux-x64.AppImage" {
t.Fatalf("linux assetName = %q, want agent-orchestrator-linux-x64.AppImage", got)
}
}
}
func TestRequireAMD64(t *testing.T) {
// Host-independent: requireAMD64 keys off runtime.GOARCH, which is amd64 or
// arm64 on every CI/dev host. Either branch is a valid assertion.
got, err := requireAMD64()
if runtime.GOARCH == "amd64" {
if err != nil || got != "x64" {
t.Fatalf("requireAMD64() on amd64 = (%q, %v), want (x64, nil)", got, err)
}
} else if err == nil {
t.Fatalf("requireAMD64() on %s = nil error, want unsupported-arch error", runtime.GOARCH)
}
}
func TestWindowsInstalledExe(t *testing.T) {
got := windowsInstalledExe("C:\\Users\\me\\AppData\\Local")
want := filepath.Join("C:\\Users\\me\\AppData\\Local", "Programs", "Agent Orchestrator", "agent-orchestrator.exe")
if got != want {
t.Fatalf("windowsInstalledExe = %q, want %q", got, want)
}
}
func TestKnownAppLocations_HostOS(t *testing.T) {
// knownAppLocations must return at least one candidate on every supported OS
// (given the relevant env). Windows needs LOCALAPPDATA; set it so the test is
// deterministic regardless of host.
if runtime.GOOS == "windows" {
t.Setenv("LOCALAPPDATA", "C:\\Users\\me\\AppData\\Local")
}
switch runtime.GOOS {
case "darwin", "windows", "linux":
if len(knownAppLocations()) == 0 {
t.Fatalf("knownAppLocations() empty on %s", runtime.GOOS)
}
}
}
func TestIsUsableBundle_RegularFileVsDir(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "agent-orchestrator.AppImage")
if err := os.WriteFile(file, []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
subdir := filepath.Join(dir, "Agent Orchestrator.app")
if err := os.MkdirAll(subdir, 0o750); err != nil {
t.Fatal(err)
}
switch runtime.GOOS {
case "darwin":
if !isUsableBundle(subdir) {
t.Fatal("darwin: dir bundle should be usable")
}
if isUsableBundle(file) {
t.Fatal("darwin: regular file should not be a usable bundle")
}
case "windows", "linux":
if !isUsableBundle(file) {
t.Fatal("win/linux: regular file should be usable")
}
if isUsableBundle(subdir) {
t.Fatal("win/linux: directory should not be a usable bundle")
}
}
if isUsableBundle(filepath.Join(dir, "missing")) {
t.Fatal("missing path should not be usable")
}
}
func TestDownloadURLUsesReleaseRepo(t *testing.T) {
orig := releaseRepo
releaseRepo = "owner/repo"
@ -159,6 +253,52 @@ func TestOpenApp_ArgConstruction(t *testing.T) {
}
}
func TestOpenApp_DetachedSpawnOnWinLinux(t *testing.T) {
if runtime.GOOS != "windows" && runtime.GOOS != "linux" {
t.Skip("openApp spawns detached only on windows/linux")
}
var gotCfg processStartConfig
c := &commandContext{deps: Deps{
StartProcess: func(cfg processStartConfig) error {
gotCfg = cfg
return nil
},
}.withDefaults()}
appPath := "/some/agent-orchestrator.AppImage"
opened, err := c.openApp(context.Background(), appPath)
if err != nil {
t.Fatal(err)
}
if !opened {
t.Fatal("openApp reported not opened")
}
if gotCfg.Path != appPath {
t.Fatalf("spawn path = %q, want %q", gotCfg.Path, appPath)
}
wantArgs := []string{"--installed-via=npm-bootstrap"}
if !reflect.DeepEqual(gotCfg.Args, wantArgs) {
t.Fatalf("spawn args = %v, want %v", gotCfg.Args, wantArgs)
}
}
func TestOpenApp_SpawnFailureFallsBackToManual(t *testing.T) {
if runtime.GOOS != "windows" && runtime.GOOS != "linux" {
t.Skip("detached-spawn fallback only on windows/linux")
}
c := &commandContext{deps: Deps{
StartProcess: func(processStartConfig) error { return os.ErrNotExist },
}.withDefaults()}
opened, err := c.openApp(context.Background(), "/some/app")
if err != nil {
t.Fatalf("openApp should swallow spawn errors, got %v", err)
}
if opened {
t.Fatal("openApp should report not-opened on spawn failure")
}
}
// TestDownload_IgnoresShortClientTimeout proves download() does not inherit the
// 2s deps.HTTPClient timeout (sized for loopback probes), which would otherwise
// fail every real release download. The server responds after a delay that

View File

@ -1,6 +1,7 @@
import type { ForgeConfig } from "@electron-forge/shared-types";
import { VitePlugin } from "@electron-forge/plugin-vite";
import MakerNSIS from "./makers/maker-nsis";
import MakerAppImage from "./makers/maker-appimage";
// Default GitHub release target (production). aoagents was the temporary rewrite
// home; releases land on AgentWrapper (spec §1.1).
@ -69,6 +70,18 @@ const config: ForgeConfig = {
["win32"],
),
{ name: "@electron-forge/maker-zip", platforms: ["darwin"], config: {} },
// Linux fetch-and-run artifact for `ao start`: a single self-contained
// AppImage the Go bootstrapper downloads and runs directly (see
// makers/maker-appimage.ts). The deb/rpm makers below stay for users who
// prefer a system package.
new MakerAppImage(
{
appId: "dev.agent-orchestrator.desktop",
productName: "Agent Orchestrator",
icon: "assets/icon.png",
},
["linux"],
),
{
name: "@electron-forge/maker-deb",
config: {

View File

@ -0,0 +1,65 @@
import path from "node:path";
import { MakerBase, type MakerOptions } from "@electron-forge/maker-base";
import type { ForgePlatform } from "@electron-forge/shared-types";
// Electron Forge has no first-party AppImage maker, so we bridge to
// electron-builder's `buildForge`, exactly as makers/maker-nsis.ts does for the
// Windows NSIS installer. AppImage is the Linux fetch-and-run artifact for the
// `ao start` bootstrapper: a single self-contained executable the Go agent can
// download from releases/latest/download and run directly, with no system
// package manager. The deb/rpm makers stay for users who want a system package.
//
// `buildForge` speaks Forge's legacy v5 function API, which Forge 7's class-based
// maker loader cannot resolve, so this thin MakerBase subclass adapts it.
export type MakerAppImageConfig = {
// electron-builder appId; required for a well-formed AppImage.
appId?: string;
// Display name for the app. Defaults to appName.
productName?: string;
// Path to the PNG icon used for the app and desktop entry.
icon?: string;
// Any extra electron-builder `appImage` options, merged over our defaults.
appImage?: Record<string, unknown>;
};
export default class MakerAppImage extends MakerBase<MakerAppImageConfig> {
name = "appimage";
defaultPlatforms: ForgePlatform[] = ["linux"];
isSupportedOnCurrentPlatform(): boolean {
return true;
}
async make({ dir, targetArch, appName }: MakerOptions): Promise<string[]> {
const { buildForge } = await import("app-builder-lib");
const cfg = this.config ?? {};
// Mirror buildForge's own output layout (<dir>/../make) so artifacts land
// where Forge's publisher expects them.
const output = path.join(path.dirname(path.resolve(dir)), "make");
return buildForge(
{ dir },
{
linux: [`appImage:${targetArch}`],
config: {
appId: cfg.appId,
productName: cfg.productName ?? appName,
directories: { output },
// Forge owns publishing (the workflow uploads via `gh release`).
// `null` stops electron-builder from inferring a GitHub publish
// target from package.json `repository` and trying to upload,
// which fails in CI with no GH_TOKEN set.
publish: null,
linux: {
...(cfg.icon ? { icon: cfg.icon } : {}),
},
appImage: {
...cfg.appImage,
},
},
},
);
}
}
export { MakerAppImage };

View File

@ -4,10 +4,9 @@
# docker build -f test/cli/Dockerfile -t ao-cli-smoke .
# docker run --rm --init ao-cli-smoke
#
# Run with --init so the real daemon spawned during the `start` test (it detaches
# via setsid) is reaped promptly after `stop` instead of lingering as a zombie.
# The suite does NOT depend on it — the stale-daemon case uses a fabricated dead
# PID — but --init keeps process accounting clean.
# `ao start` is now the desktop-app launcher (it no longer runs a daemon); the
# fresh-box check just proves the binary is sane and start reaches the fetch
# path. --init keeps process accounting clean for any short-lived child.
# ---- stage 1: build the binary (the "release" a user would download) ----
FROM golang:1.25-bookworm AS build

View File

@ -12,25 +12,25 @@ AO_BIN="${AO_BIN:-ao}"
tmp="$(mktemp -d)"
export AO_RUN_FILE="$tmp/running.json"
export AO_DATA_DIR="$tmp/data"
export AO_PORT="${AO_PORT:-3001}" # the container is isolated; 3001 is free
trap '"$AO_BIN" stop >/dev/null 2>&1 || true; rm -rf "$tmp"' EXIT
trap 'rm -rf "$tmp"' EXIT
fail() { echo "FAIL: $1" >&2; exit 1; }
echo "ao binary : $(command -v "$AO_BIN")"
"$AO_BIN" version >/dev/null || fail "version"
"$AO_BIN" doctor >/dev/null || fail "doctor"
"$AO_BIN" start >/dev/null || fail "start"
"$AO_BIN" status --json | grep -q '"state": "ready"' || fail "daemon not ready after start"
# the /shutdown control endpoint rejects a cross-origin caller (403) and survives
code="$(curl -s -o /dev/null -w '%{http_code}' -X POST \
-H 'Origin: https://evil.example' "http://127.0.0.1:$AO_PORT/shutdown")"
[ "$code" = "403" ] || fail "cross-origin /shutdown returned $code, want 403"
"$AO_BIN" status --json | grep -q '"state": "ready"' || fail "daemon died after rejected shutdown"
"$AO_BIN" stop >/dev/null || fail "stop"
"$AO_BIN" status --json | grep -q '"state": "stopped"' || fail "daemon not stopped"
# `ao start` is now the desktop-app launcher: it resolves an installed app or
# fetches the release, then opens it (it no longer runs a daemon). On a fresh
# container there is no installed app, so start reaches the fetch path. With no
# published asset for this platform it must exit non-zero with a clear `ao
# start:` error (an unreachable/404 download on amd64, or an unsupported-arch
# error on arm64), never a panic or a silent success. The full launcher
# behaviour is covered by the Go e2e suite; this only proves the fresh-box path
# is sane on whatever arch the runner uses.
if err="$("$AO_BIN" start 2>&1)"; then
fail "start unexpectedly succeeded on a fresh machine with no installed app"
fi
echo "$err" | grep -qiE "download|ao start:" || fail "start did not fail with a clear error; got: $err"
echo "fresh-install check: OK"