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:
parent
514946fd8b
commit
bc84b6d2a3
|
|
@ -125,7 +125,22 @@ jobs:
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
# Linux: deb/rpm build on this runner (issue #2191), but the stable asset
|
# Linux: deb/rpm AND an AppImage build on this runner (issue #2191). The
|
||||||
# name for the fetch-and-run bootstrapper is still undecided (deb/rpm vs
|
# AppImage is the fetch-and-run artifact for `ao start` (spec §11.3); we
|
||||||
# AppImage, spec §11.3). No alias is uploaded until that decision lands; the
|
# copy it to the stable, space-free name the bootstrapper fetches and
|
||||||
# versioned deb/rpm are still published by the forge publisher above.
|
# 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 }}
|
||||||
|
|
|
||||||
|
|
@ -84,12 +84,10 @@ func (c *commandContext) runStart(ctx context.Context, cmd *cobra.Command, opts
|
||||||
out := cmd.OutOrStdout()
|
out := cmd.OutOrStdout()
|
||||||
res := startResult{}
|
res := startResult{}
|
||||||
|
|
||||||
appPath, err := c.resolveApp()
|
appPath := c.resolveApp()
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
res.Resolved = appPath != ""
|
res.Resolved = appPath != ""
|
||||||
|
|
||||||
|
var err error
|
||||||
if appPath == "" {
|
if appPath == "" {
|
||||||
appPath, err = c.fetchApp(ctx)
|
appPath, err = c.fetchApp(ctx)
|
||||||
if err != nil {
|
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
|
// 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
|
// found (spec §6.2). Resolution order is fixed: marker path -> stat -> known
|
||||||
// location scan. It never compares versions (invariant 5).
|
// 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) {
|
if p := c.markerAppPath(); p != "" && isUsableBundle(p) {
|
||||||
return p, nil
|
return p
|
||||||
}
|
}
|
||||||
for _, p := range appScanLocations() {
|
for _, p := range appScanLocations() {
|
||||||
if isUsableBundle(p) {
|
if isUsableBundle(p) {
|
||||||
return p, nil
|
return p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", nil
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// appScanLocations is the known-location scan source. It is a package var so
|
// appScanLocations is the known-location scan source. It is a package var so
|
||||||
|
|
@ -169,19 +167,55 @@ func aoStateDir() (string, error) {
|
||||||
func knownAppLocations() []string {
|
func knownAppLocations() []string {
|
||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
case "darwin":
|
case "darwin":
|
||||||
paths := []string{filepath.Join("/Applications", appBundleName)}
|
paths := []string{"/Applications/" + appBundleName}
|
||||||
if home, err := os.UserHomeDir(); err == nil {
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
paths = append(paths, filepath.Join(home, "Applications", appBundleName))
|
paths = append(paths, filepath.Join(home, "Applications", appBundleName))
|
||||||
}
|
}
|
||||||
return paths
|
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:
|
default:
|
||||||
// Windows/Linux scan locations land in T6/T7.
|
|
||||||
return nil
|
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
|
// 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 {
|
func isUsableBundle(p string) bool {
|
||||||
if p == "" {
|
if p == "" {
|
||||||
return false
|
return false
|
||||||
|
|
@ -193,18 +227,31 @@ func isUsableBundle(p string) bool {
|
||||||
if runtime.GOOS == "darwin" {
|
if runtime.GOOS == "darwin" {
|
||||||
return info.IsDir()
|
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
|
// fetchApp downloads the latest desktop release for this platform and returns
|
||||||
// into a staging dir under ~/.ao/staging, and returns the bundle path (spec
|
// the resolved bundle path (spec §6.3). macOS unpacks a signed zip into staging,
|
||||||
// §6.3). Windows/Linux are tracked as T6/T7.
|
// 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) {
|
func (c *commandContext) fetchApp(ctx context.Context) (string, error) {
|
||||||
if runtime.GOOS != "darwin" {
|
switch runtime.GOOS {
|
||||||
return "", fmt.Errorf("ao start: fetch not yet implemented for %s (tracked as spec T6/T7)", 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 {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
@ -241,9 +288,91 @@ func (c *commandContext) fetchApp(ctx context.Context) (string, error) {
|
||||||
return appPath, nil
|
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.
|
// download streams url to dst using the injected HTTP client.
|
||||||
func (c *commandContext) download(ctx context.Context, url, dst string) error {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -273,14 +402,42 @@ func (c *commandContext) download(ctx context.Context, url, dst string) error {
|
||||||
return f.Close()
|
return f.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// darwinAssetName maps Go's runtime.GOARCH to the release asset name. The
|
// assetName maps the current GOOS/GOARCH to the stable release asset name the
|
||||||
// release pipeline publishes "x64" for amd64 (spec §6.3, §8).
|
// release pipeline publishes (spec §6.3, §8). The pipeline uses "x64" for amd64.
|
||||||
func darwinAssetName() (string, error) {
|
// - 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)
|
arch, err := assetArch(runtime.GOARCH)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("agent-orchestrator-darwin-%s.zip", arch), nil
|
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.
|
// 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
|
// 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
|
// (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) {
|
func (c *commandContext) openApp(ctx context.Context, appPath string) (bool, error) {
|
||||||
if runtime.GOOS != "darwin" {
|
switch runtime.GOOS {
|
||||||
// Non-darwin open lands in T6/T7; treat as "not opened" so the caller
|
case "darwin":
|
||||||
// prints manual-open instructions.
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
// `open` returns immediately; --args forwards the rest to the app.
|
// `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 {
|
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 false, fmt.Errorf("open %s: %w: %s", appPath, err, out)
|
||||||
}
|
}
|
||||||
return true, nil
|
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
|
// printDeprecationNotice explains the new role of the npm `ao` binary. Keep it
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
func makeBundle(t *testing.T, name string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
p := filepath.Join(t.TempDir(), name)
|
p := filepath.Join(t.TempDir(), name)
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
if err := os.MkdirAll(p, 0o750); err != nil {
|
if err := os.MkdirAll(p, 0o750); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
return p
|
return p
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveApp_MarkerHit(t *testing.T) {
|
func TestResolveApp_MarkerHit(t *testing.T) {
|
||||||
|
|
@ -49,10 +57,7 @@ func TestResolveApp_MarkerHit(t *testing.T) {
|
||||||
t.Cleanup(swapScanLocations(func() []string { return nil }))
|
t.Cleanup(swapScanLocations(func() []string { return nil }))
|
||||||
|
|
||||||
c := &commandContext{deps: Deps{}.withDefaults()}
|
c := &commandContext{deps: Deps{}.withDefaults()}
|
||||||
got, err := c.resolveApp()
|
got := c.resolveApp()
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got != bundle {
|
if got != bundle {
|
||||||
t.Fatalf("resolveApp = %q, want marker path %q", 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} }))
|
t.Cleanup(swapScanLocations(func() []string { return []string{scanBundle} }))
|
||||||
|
|
||||||
c := &commandContext{deps: Deps{}.withDefaults()}
|
c := &commandContext{deps: Deps{}.withDefaults()}
|
||||||
got, err := c.resolveApp()
|
got := c.resolveApp()
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got != scanBundle {
|
if got != scanBundle {
|
||||||
t.Fatalf("resolveApp = %q, want scan path %q", 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()}
|
c := &commandContext{deps: Deps{}.withDefaults()}
|
||||||
got, err := c.resolveApp()
|
got := c.resolveApp()
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got != "" {
|
if got != "" {
|
||||||
t.Fatalf("resolveApp = %q, want empty", 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) {
|
func TestDownloadURLUsesReleaseRepo(t *testing.T) {
|
||||||
orig := releaseRepo
|
orig := releaseRepo
|
||||||
releaseRepo = "owner/repo"
|
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
|
// TestDownload_IgnoresShortClientTimeout proves download() does not inherit the
|
||||||
// 2s deps.HTTPClient timeout (sized for loopback probes), which would otherwise
|
// 2s deps.HTTPClient timeout (sized for loopback probes), which would otherwise
|
||||||
// fail every real release download. The server responds after a delay that
|
// fail every real release download. The server responds after a delay that
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { ForgeConfig } from "@electron-forge/shared-types";
|
import type { ForgeConfig } from "@electron-forge/shared-types";
|
||||||
import { VitePlugin } from "@electron-forge/plugin-vite";
|
import { VitePlugin } from "@electron-forge/plugin-vite";
|
||||||
import MakerNSIS from "./makers/maker-nsis";
|
import MakerNSIS from "./makers/maker-nsis";
|
||||||
|
import MakerAppImage from "./makers/maker-appimage";
|
||||||
|
|
||||||
// Default GitHub release target (production). aoagents was the temporary rewrite
|
// Default GitHub release target (production). aoagents was the temporary rewrite
|
||||||
// home; releases land on AgentWrapper (spec §1.1).
|
// home; releases land on AgentWrapper (spec §1.1).
|
||||||
|
|
@ -69,6 +70,18 @@ const config: ForgeConfig = {
|
||||||
["win32"],
|
["win32"],
|
||||||
),
|
),
|
||||||
{ name: "@electron-forge/maker-zip", platforms: ["darwin"], config: {} },
|
{ 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",
|
name: "@electron-forge/maker-deb",
|
||||||
config: {
|
config: {
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -4,10 +4,9 @@
|
||||||
# docker build -f test/cli/Dockerfile -t ao-cli-smoke .
|
# docker build -f test/cli/Dockerfile -t ao-cli-smoke .
|
||||||
# docker run --rm --init ao-cli-smoke
|
# docker run --rm --init ao-cli-smoke
|
||||||
#
|
#
|
||||||
# Run with --init so the real daemon spawned during the `start` test (it detaches
|
# `ao start` is now the desktop-app launcher (it no longer runs a daemon); the
|
||||||
# via setsid) is reaped promptly after `stop` instead of lingering as a zombie.
|
# fresh-box check just proves the binary is sane and start reaches the fetch
|
||||||
# The suite does NOT depend on it — the stale-daemon case uses a fabricated dead
|
# path. --init keeps process accounting clean for any short-lived child.
|
||||||
# PID — but --init keeps process accounting clean.
|
|
||||||
|
|
||||||
# ---- stage 1: build the binary (the "release" a user would download) ----
|
# ---- stage 1: build the binary (the "release" a user would download) ----
|
||||||
FROM golang:1.25-bookworm AS build
|
FROM golang:1.25-bookworm AS build
|
||||||
|
|
|
||||||
|
|
@ -12,25 +12,25 @@ AO_BIN="${AO_BIN:-ao}"
|
||||||
tmp="$(mktemp -d)"
|
tmp="$(mktemp -d)"
|
||||||
export AO_RUN_FILE="$tmp/running.json"
|
export AO_RUN_FILE="$tmp/running.json"
|
||||||
export AO_DATA_DIR="$tmp/data"
|
export AO_DATA_DIR="$tmp/data"
|
||||||
export AO_PORT="${AO_PORT:-3001}" # the container is isolated; 3001 is free
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
trap '"$AO_BIN" stop >/dev/null 2>&1 || true; rm -rf "$tmp"' EXIT
|
|
||||||
|
|
||||||
fail() { echo "FAIL: $1" >&2; exit 1; }
|
fail() { echo "FAIL: $1" >&2; exit 1; }
|
||||||
|
|
||||||
echo "ao binary : $(command -v "$AO_BIN")"
|
echo "ao binary : $(command -v "$AO_BIN")"
|
||||||
"$AO_BIN" version >/dev/null || fail "version"
|
"$AO_BIN" version >/dev/null || fail "version"
|
||||||
"$AO_BIN" doctor >/dev/null || fail "doctor"
|
"$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"
|
# `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
|
||||||
# the /shutdown control endpoint rejects a cross-origin caller (403) and survives
|
# container there is no installed app, so start reaches the fetch path. With no
|
||||||
code="$(curl -s -o /dev/null -w '%{http_code}' -X POST \
|
# published asset for this platform it must exit non-zero with a clear `ao
|
||||||
-H 'Origin: https://evil.example' "http://127.0.0.1:$AO_PORT/shutdown")"
|
# start:` error (an unreachable/404 download on amd64, or an unsupported-arch
|
||||||
[ "$code" = "403" ] || fail "cross-origin /shutdown returned $code, want 403"
|
# error on arm64), never a panic or a silent success. The full launcher
|
||||||
"$AO_BIN" status --json | grep -q '"state": "ready"' || fail "daemon died after rejected shutdown"
|
# behaviour is covered by the Go e2e suite; this only proves the fresh-box path
|
||||||
|
# is sane on whatever arch the runner uses.
|
||||||
"$AO_BIN" stop >/dev/null || fail "stop"
|
if err="$("$AO_BIN" start 2>&1)"; then
|
||||||
"$AO_BIN" status --json | grep -q '"state": "stopped"' || fail "daemon not stopped"
|
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"
|
echo "fresh-install check: OK"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue