diff --git a/.github/workflows/frontend-release.yml b/.github/workflows/frontend-release.yml index 4002582ba..c1dbf7895 100644 --- a/.github/workflows/frontend-release.yml +++ b/.github/workflows/frontend-release.yml @@ -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 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 (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/-.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 }} diff --git a/backend/internal/cli/start.go b/backend/internal/cli/start.go index c217dfb84..185ea4b12 100644 --- a/backend/internal/cli/start.go +++ b/backend/internal/cli/start.go @@ -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) { - arch, err := assetArch(runtime.GOARCH) - if err != nil { - return "", err +// 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) } - return fmt.Sprintf("agent-orchestrator-darwin-%s.zip", arch), nil +} + +// 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. + 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 } - // `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 } // printDeprecationNotice explains the new role of the npm `ao` binary. Keep it diff --git a/backend/internal/cli/start_test.go b/backend/internal/cli/start_test.go index 52c5c8128..efcd5f81c 100644 --- a/backend/internal/cli/start_test.go +++ b/backend/internal/cli/start_test.go @@ -31,11 +31,19 @@ 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 err := os.MkdirAll(p, 0o750); err != nil { + 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 @@ -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 diff --git a/frontend/forge.config.ts b/frontend/forge.config.ts index 39ae77de5..c36159742 100644 --- a/frontend/forge.config.ts +++ b/frontend/forge.config.ts @@ -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: { diff --git a/frontend/makers/maker-appimage.ts b/frontend/makers/maker-appimage.ts new file mode 100644 index 000000000..0a31321e1 --- /dev/null +++ b/frontend/makers/maker-appimage.ts @@ -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; +}; + +export default class MakerAppImage extends MakerBase { + name = "appimage"; + defaultPlatforms: ForgePlatform[] = ["linux"]; + + isSupportedOnCurrentPlatform(): boolean { + return true; + } + + async make({ dir, targetArch, appName }: MakerOptions): Promise { + const { buildForge } = await import("app-builder-lib"); + const cfg = this.config ?? {}; + // Mirror buildForge's own output layout (/../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 }; diff --git a/test/cli/Dockerfile b/test/cli/Dockerfile index 6ed08cc6f..ce947d148 100644 --- a/test/cli/Dockerfile +++ b/test/cli/Dockerfile @@ -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 diff --git a/test/cli/install-check.sh b/test/cli/install-check.sh index f4bcacd47..3af4756c3 100755 --- a/test/cli/install-check.sh +++ b/test/cli/install-check.sh @@ -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"