package cli import ( "context" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "runtime" "github.com/spf13/cobra" "github.com/aoagents/agent-orchestrator/backend/internal/config" ) // releaseRepo is the GitHub "owner/repo" that `ao start` fetches the desktop app // from. It defaults to the production target and is overridable at build time so // a test binary fetches from the fork without a source edit: // // go build -ldflags "-X github.com/aoagents/agent-orchestrator/backend/internal/cli.releaseRepo=harshitsinghbhandari/agent-orchestrator" ./cmd/ao // // Mirrors how version.go's Version var is stamped by release tooling. var releaseRepo = "AgentWrapper/agent-orchestrator" // appBundleName is the macOS bundle directory name produced by electron-forge // (spaced, per frontend/forge.config.ts). const appBundleName = "Agent Orchestrator.app" // appStateFileName is the marker the desktop app writes under ~/.ao on every // launch (spec §5). `ao start` is a read-only consumer of it. const appStateFileName = "app-state.json" // appState mirrors the app-written ~/.ao/app-state.json marker (spec §5). Only // the desktop app writes it; `ao start` reads it as a fast-path hint and never // trusts appPath without stat-ing it (invariant 2). type appState struct { SchemaVersion int `json:"schemaVersion"` AppPath string `json:"appPath"` Version string `json:"version"` InstalledAt string `json:"installedAt"` LastReconciledAt string `json:"lastReconciledAt"` InstallSource string `json:"installSource"` } type startOptions struct { json bool } // startResult is the JSON shape emitted with --json: what `ao start` resolved, // whether it fetched, whether it opened, and the resulting bundle path. type startResult struct { Resolved bool `json:"resolved"` Fetched bool `json:"fetched"` Opened bool `json:"opened"` AppPath string `json:"appPath"` } func newStartCommand(ctx *commandContext) *cobra.Command { opts := startOptions{} cmd := &cobra.Command{ Use: "start", Short: "Fetch (if needed) and open the Agent Orchestrator desktop app", Long: "Fetch (if needed) and open the Agent Orchestrator desktop app.\n\n" + "The desktop app now owns the daemon, state, and updates. `ao start` no\n" + "longer runs a daemon: it resolves the installed app (or downloads the\n" + "latest release), opens it, and exits.", Args: noArgs, RunE: func(cmd *cobra.Command, args []string) error { return ctx.runStart(cmd.Context(), cmd, opts) }, } cmd.Flags().BoolVar(&opts.json, "json", false, "Output start result as JSON") return cmd } // runStart implements the spec §6.1 algorithm: resolve the installed app, fetch // it if absent, open it, then print the deprecation notice. It never blocks or // supervises the launched app. func (c *commandContext) runStart(ctx context.Context, cmd *cobra.Command, opts startOptions) error { out := cmd.OutOrStdout() res := startResult{} appPath := c.resolveApp() res.Resolved = appPath != "" var err error if appPath == "" { appPath, err = c.fetchApp(ctx) if err != nil { return err } res.Fetched = true } res.AppPath = appPath opened, err := c.openApp(ctx, appPath) if err != nil { return err } res.Opened = opened if opts.json { return writeJSON(out, res) } c.printDeprecationNotice(out) if !opened { c.printManualOpen(out, appPath) } return nil } // 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 { if p := c.markerAppPath(); p != "" && isUsableBundle(p) { return p } for _, p := range appScanLocations() { if isUsableBundle(p) { return p } } return "" } // appScanLocations is the known-location scan source. It is a package var so // tests can point the scan at a temp bundle instead of real system paths. var appScanLocations = knownAppLocations // markerAppPath reads ~/.ao/app-state.json and returns its recorded appPath, or // "" if the marker is missing/unreadable. It does not stat the path; callers do. func (c *commandContext) markerAppPath() string { dir, err := aoStateDir() if err != nil { return "" } data, err := os.ReadFile(filepath.Join(dir, appStateFileName)) if err != nil { return "" } var st appState if err := json.Unmarshal(data, &st); err != nil { return "" } return st.AppPath } // aoStateDir resolves the canonical ~/.ao home, honoring AO_DATA_DIR exactly as // the daemon's config does (the marker lives beside running.json under ~/.ao). func aoStateDir() (string, error) { cfg, err := config.Load() if err != nil { return "", err } // running.json lives directly under ~/.ao; the marker sits beside it. return filepath.Dir(cfg.RunFilePath), nil } // knownAppLocations lists the platform's standard install paths to scan when the // marker misses (covers website installs and stale markers, spec §6.2). func knownAppLocations() []string { switch runtime.GOOS { case "darwin": 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: 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; 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 } info, err := os.Stat(p) if err != nil { return false } if runtime.GOOS == "darwin" { return info.IsDir() } // 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 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) { 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) } } // 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 } url := downloadURL(asset) stateDir, err := aoStateDir() if err != nil { return "", err } staging := filepath.Join(stateDir, "staging") // Clear any stale or partial prior unpack so ditto extracts into a clean dir // (a leftover bundle could otherwise merge with the new one). 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) } zipPath := filepath.Join(staging, asset) if err := c.download(ctx, url, zipPath); err != nil { return "", fmt.Errorf("download %s: %w", url, err) } // ditto preserves the .app code signature; plain unzip corrupts it (spec §6.3). if out, err := c.deps.CommandOutput(ctx, "ditto", "-x", "-k", zipPath, staging); err != nil { return "", fmt.Errorf("ditto unpack: %w: %s", err, out) } appPath := filepath.Join(staging, appBundleName) if !isUsableBundle(appPath) { return "", fmt.Errorf("ao start: %s not found in unpacked release at %s", appBundleName, staging) } 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, http.NoBody) if err != nil { return err } // deps.HTTPClient carries a short (2s) timeout sized for loopback daemon // probes; a release asset is hundreds of MB. Copy the client and drop the // timeout, relying on ctx for cancellation. The Transport is preserved so // tests still reach their httptest server. client := *c.deps.HTTPClient client.Timeout = 0 resp, err := client.Do(req) if err != nil { return err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("unexpected status %s", resp.Status) } f, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) if err != nil { return err } defer func() { _ = f.Close() }() if _, err := io.Copy(f, resp.Body); err != nil { return err } return f.Close() } // 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. func assetArch(goarch string) (string, error) { switch goarch { case "arm64": return "arm64", nil case "amd64": return "x64", nil default: return "", fmt.Errorf("ao start: unsupported architecture %q", goarch) } } // downloadURL builds the constant releases/latest/download URL for asset. func downloadURL(asset string) string { return fmt.Sprintf("https://github.com/%s/releases/latest/download/%s", releaseRepo, asset) } // 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. 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) { 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 // honest: Track B (live auto-update) is not done, so it does not promise it. func (c *commandContext) printDeprecationNotice(w io.Writer) { _, _ = fmt.Fprint(w, "Agent Orchestrator is now a desktop app, and the npm `ao` is just its launcher.\n"+ "The app is distributed from the website and GitHub Releases; it owns the daemon and updates itself.\n"+ "You can keep running `ao start` to fetch (if needed) and open it.\n") } // printManualOpen tells the user how to open the bundle when `ao start` could // not launch it for them (non-darwin, or a failed launch handled upstream). func (c *commandContext) printManualOpen(w io.Writer, appPath string) { _, _ = fmt.Fprintf(w, "Could not open the app automatically. Open it manually: %s\n", appPath) }