508 lines
18 KiB
Go
508 lines
18 KiB
Go
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
|
|
}
|
|
|
|
// TODO(spec §6.4): legacy first-boot import now belongs to the desktop app; standalone `ao import` still available.
|
|
|
|
// 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)
|
|
}
|