diff --git a/cmd/rune/main.go b/cmd/rune/main.go index a2b4377..d10affa 100644 --- a/cmd/rune/main.go +++ b/cmd/rune/main.go @@ -62,10 +62,12 @@ func main() { os.Exit(runVerify(ctx, args, os.Stdout, os.Stderr)) case "version": os.Exit(runVersion(os.Stdout)) + case "update": + os.Exit(runUpdate(ctx, args, os.Stdout, os.Stderr)) case "mcp-server": os.Exit(runMCPServer(ctx, args, os.Stderr)) case "runed": - os.Exit(runRuned(ctx, args, os.Stderr)) + os.Exit(runRuned(ctx, args, os.Stdout, os.Stderr)) case "-h", "--help", "help": printHelp(os.Stdout) os.Exit(0) @@ -87,6 +89,9 @@ Usage: run read-only health checks rune version print version and manifest URL + rune update [--check] [--json] [--manifest-url URL] + update installed binaries to the latest version from manifest + (--check skip actual update) rune mcp-server [args...] forward stdio to ~/.rune/bin/rune-mcp (plugin-manifest entry point) rune runed [args...] @@ -94,6 +99,8 @@ Usage: rune runed --detach [args...] supervise runed as a background daemon: detach + log to ~/.runed/logs/daemon.log + auto-restart on crash + rune runed --status + query running supervisor Environment: RUNE_HOME override ~/.rune/ (rune plugin realm: config + rune-mcp) diff --git a/cmd/rune/runed.go b/cmd/rune/runed.go index a54db8c..c88601d 100644 --- a/cmd/rune/runed.go +++ b/cmd/rune/runed.go @@ -17,13 +17,18 @@ import ( // to become a process group leader, redirect stdio to ~/.runed/logs/daemon.log, // take ~/.runed/supervisor.lock to prevent race, and watch runed in a restart loop. // The user-facing invocation returns immediately once supervisor is launched. -func runRuned(ctx context.Context, args []string, stderr io.Writer) int { +func runRuned(ctx context.Context, args []string, stdout, stderr io.Writer) int { paths, err := bootstrap.Resolve() if err != nil { fmt.Fprintf(stderr, "rune: cannot resolve home directories: %v\n", err) return 1 } + // `rune runed --status`: query running supervisor; read-only + if hasFlag(args, "--status") { + return runedStatus(paths, stdout) + } + // If a llama-server is present in ~/.runed/bin, point runed at it so it skips // re-download. When it's absent, leave the env UNSET so runed // self-bootstraps llama-server on first boot. Set on the process env (rather @@ -62,6 +67,7 @@ func runRuned(ctx context.Context, args []string, stderr io.Writer) int { RunedArgs: forwardedArgs, LogPath: paths.DaemonLog, LockPath: paths.SupervisorLock, + SocketPath: paths.SupervisorSock, } if err := supervisor.RunDetached(ctx, cfg); err != nil { fmt.Fprintf(stderr, "rune: supervisor: %v\n", err) @@ -83,3 +89,29 @@ func extractDetachFlag(args []string) (detach bool, rest []string) { return detach, rest } + +func hasFlag(args []string, flag string) bool { + for _, a := range args { + if a == flag { + return true + } + } + + return false +} + +func runedStatus(paths *bootstrap.Paths, stdout io.Writer) int { + resp, err := supervisor.SupervisorRequest(paths.SupervisorSock, supervisor.Request{Cmd: "status"}) + if err != nil { + fmt.Fprintln(stdout, "runed supervisor: not running") + return 1 + } + if !resp.OK { + fmt.Fprintf(stdout, "runed supervisor: error: %s\n", resp.Error) + return 1 + } + + fmt.Fprintf(stdout, "runed supervisor: running (pid %d)\n", resp.PID) + + return 0 +} diff --git a/cmd/rune/runed_test.go b/cmd/rune/runed_test.go index 401a217..9b7fb92 100644 --- a/cmd/rune/runed_test.go +++ b/cmd/rune/runed_test.go @@ -1,8 +1,16 @@ package main import ( + "bytes" + "context" + "encoding/json" + "net" + "path/filepath" "reflect" + "strings" "testing" + + "github.com/CryptoLabInc/rune-cli/internal/bootstrap" ) func TestExtractDetachFlag(t *testing.T) { @@ -56,3 +64,63 @@ func TestExtractDetachFlag(t *testing.T) { }) } } + +func runedEnv(t *testing.T) *bootstrap.Paths { + t.Helper() + + dir := t.TempDir() + t.Setenv("RUNE_HOME", filepath.Join(dir, "rune")) + t.Setenv("RUNED_HOME", filepath.Join(dir, "runed")) + + paths, err := bootstrap.Resolve() + if err != nil { + t.Fatal(err) + } + if err := paths.EnsureDirs(); err != nil { + t.Fatal(err) + } + + return paths +} + +func TestRunRuned_StatusNotRunning(t *testing.T) { + runedEnv(t) // no supervisor listening + + var stdout, stderr bytes.Buffer + if code := runRuned(context.Background(), []string{"--status"}, &stdout, &stderr); code != 1 { + t.Errorf("exit = %d, want 1 (no supervisor)", code) + } + if !strings.Contains(stdout.String(), "not running") { + t.Errorf("stdout = %q", stdout.String()) + } +} + +func TestRunRuned_StatusRunning(t *testing.T) { + paths := runedEnv(t) + + // Simulate listener + ln, err := net.Listen("unix", paths.SupervisorSock) + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + var req map[string]any + _ = json.NewDecoder(conn).Decode(&req) + _ = json.NewEncoder(conn).Encode(map[string]any{"ok": true, "pid": 4321}) + }() + + var stdout, stderr bytes.Buffer + if code := runRuned(context.Background(), []string{"--status"}, &stdout, &stderr); code != 0 { + t.Errorf("exit = %d, want 0 (stderr=%q)", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "running") || !strings.Contains(stdout.String(), "4321") { + t.Errorf("stdout = %q, want running + pid 4321", stdout.String()) + } +} diff --git a/cmd/rune/update.go b/cmd/rune/update.go new file mode 100644 index 0000000..9cf3778 --- /dev/null +++ b/cmd/rune/update.go @@ -0,0 +1,135 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "os" + + "github.com/CryptoLabInc/rune-cli/internal/bootstrap" +) + +func runUpdate(ctx context.Context, args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("update", flag.ContinueOnError) + fs.SetOutput(stderr) + + check := fs.Bool("check", false, "report available updates without applying") + jsonOut := fs.Bool("json", false, "emit JSON") + manifest := fs.String("manifest-url", manifestURL, "override manifest URL") + if err := fs.Parse(args); err != nil { + return 2 + } + if fs.NArg() > 0 { + fmt.Fprintf(stderr, "rune update: unexpected argument: %v\n", fs.Args()) + return 2 + } + + // Fall-back + if *manifest == "" { + if env := os.Getenv("RUNE_MANIFEST"); env != "" { + *manifest = env + } + } + if *manifest == "" { + fmt.Fprintln(stderr, "rune update: no manifest URL configured (set --manifest-url or RUNE_MANIFEST)") + return 2 + } + + plan, err := bootstrap.CheckUpdate(ctx, *manifest, nil) + if err != nil { + fmt.Fprintf(stderr, "rune update: %v\n", err) + return 1 + } + + if *check { + return reportUpdatePlan(stdout, plan, *jsonOut) + } + + return applyUpdate(ctx, *manifest, plan, stdout, stderr, *jsonOut) +} + +func reportUpdatePlan(w io.Writer, plan *bootstrap.UpdateList, jsonOut bool) int { + if jsonOut { + _ = json.NewEncoder(w).Encode(plan) + return 0 + } + + if !plan.HasUpdates() { + fmt.Fprintln(w, "rune: all binaries are up to date") + return 0 + } + + fmt.Fprintln(w, "Available updates:") + for _, a := range plan.Outdated() { + fmt.Fprintf(w, " %s: %s -> %s\n", a.Step, a.Installed, a.Available) + } + + return 0 +} + +type updateSummary struct { + Applied []appliedUpdate `json:"applied"` + Deferred []string `json:"deferred,omitempty"` + Error string `json:"error,omitempty"` +} + +type appliedUpdate struct { + Step string `json:"step"` + From string `json:"from"` + To string `json:"to"` +} + +func applyUpdate(ctx context.Context, manifest string, plan *bootstrap.UpdateList, stdout, stderr io.Writer, jsonOut bool) int { + out := updateSummary{Applied: []appliedUpdate{}} + + logf := func(string, ...any) {} + if !jsonOut { + logf = func(format string, a ...any) { fmt.Fprintf(stderr, format+"\n", a...) } + } + + if !plan.HasUpdates() { + if jsonOut { + _ = json.NewEncoder(stdout).Encode(out) + } else { + fmt.Fprintln(stdout, "rune: all binaries are up to date") + } + return 0 + } + + exit := 0 + for _, a := range plan.Outdated() { + switch a.Step { + case bootstrap.StepRuneMCP: + to, err := bootstrap.UpdateArtifact(ctx, manifest, a.Step, logf) + if err != nil { + out.Error = err.Error() + exit = 1 + + if !jsonOut { + fmt.Fprintf(stderr, "rune update: %s: %v\n", a.Step, err) + } + + continue + } + + out.Applied = append(out.Applied, appliedUpdate{Step: a.Step, From: a.Installed, To: to}) + if !jsonOut { + fmt.Fprintf(stdout, "updated %s: %s -> %s (applies on the next session; run /mcp to reconnect now)\n", a.Step, a.Installed, to) + } + case bootstrap.StepRuned: + // TODO: send reload request to restart updated runed + out.Deferred = append(out.Deferred, a.Step) + if !jsonOut { + fmt.Fprintf(stdout, "%s: %s -> %s available (not applied; live daemon update not yet implemented)\n", a.Step, a.Installed, a.Available) + } + } + } + + if jsonOut { + _ = json.NewEncoder(stdout).Encode(out) + } + + return exit +} diff --git a/cmd/rune/update_test.go b/cmd/rune/update_test.go new file mode 100644 index 0000000..637ab01 --- /dev/null +++ b/cmd/rune/update_test.go @@ -0,0 +1,276 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/CryptoLabInc/rune-cli/internal/bootstrap" +) + +func updateManifestServer(t *testing.T, runeMCPVer, runedVer string) string { + t.Helper() + + mux := http.NewServeMux() + mux.HandleFunc("/manifest.json", func(w http.ResponseWriter, r *http.Request) { + m := map[string]any{ + "version": 1, + "rune_mcp_version": runeMCPVer, + "runed_version": runedVer, + "platforms": map[string]any{ + bootstrap.PlatformTuple(): map[string]any{ + "runed": map[string]any{"url": "http://example.test/runed", "sha256": "aa", "size": 1}, + "rune_mcp": map[string]any{"url": "http://example.test/rune-mcp", "sha256": "bb", "size": 1}, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(m) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + return srv.URL + "/manifest.json" +} + +func setTestEnv(t *testing.T) *bootstrap.Paths { + t.Helper() + + dir := t.TempDir() + t.Setenv("RUNE_HOME", filepath.Join(dir, "rune")) + t.Setenv("RUNED_HOME", filepath.Join(dir, "runed")) + + paths, err := bootstrap.Resolve() + if err != nil { + t.Fatal(err) + } + if err := paths.EnsureDirs(); err != nil { + t.Fatal(err) + } + + return paths +} + +func writeAudit(t *testing.T, paths *bootstrap.Paths, url, mcpVer, runedVer string) { + t.Helper() + + rec := &bootstrap.Manifest{Version: 1, RuneMCPVersion: mcpVer, RunedVersion: runedVer} + artifact := map[string]bootstrap.InstalledArtifact{ + bootstrap.StepRuneMCP: {Path: paths.RuneMCPBinary}, + bootstrap.StepRuned: {Path: paths.RunedBinary}, + } + + if err := bootstrap.WriteInstalledManifest(paths, url, rec, artifact); err != nil { + t.Fatalf("WriteInstalledManifest: %v", err) + } +} + +func TestRunUpdate_NoManifest(t *testing.T) { + saved := manifestURL + manifestURL = "" + defer func() { manifestURL = saved }() + t.Setenv("RUNE_MANIFEST", "") + + var stdout, stderr bytes.Buffer + if code := runUpdate(context.Background(), nil, &stdout, &stderr); code != 2 { + t.Errorf("exit = %d, want 2", code) + } + if !strings.Contains(stderr.String(), "no manifest URL") { + t.Errorf("stderr = %q", stderr.String()) + } +} + +func TestRunUpdate_ExtraArg(t *testing.T) { + var stdout, stderr bytes.Buffer + if code := runUpdate(context.Background(), []string{"extra"}, &stdout, &stderr); code != 2 { + t.Errorf("exit = %d, want 2", code) + } + if !strings.Contains(stderr.String(), "unexpected argument") { + t.Errorf("stderr = %q", stderr.String()) + } +} + +func TestRunUpdate_BadFlag(t *testing.T) { + var stdout, stderr bytes.Buffer + if code := runUpdate(context.Background(), []string{"--bad"}, &stdout, &stderr); code != 2 { + t.Errorf("exit = %d, want 2", code) + } + if stdout.Len() != 0 { + t.Errorf("flag errors must not land on stdout: %q", stdout.String()) + } +} + +func TestRunUpdate_CheckReportsOutdated(t *testing.T) { + paths := setTestEnv(t) + url := updateManifestServer(t, "v0.2.0", "v0.2.0") + t.Setenv("RUNE_MANIFEST", url) + writeAudit(t, paths, url, "v0.1.0", "v0.2.0") // rune-mcp old, runed current + + var stdout, stderr bytes.Buffer + if code := runUpdate(context.Background(), []string{"--check"}, &stdout, &stderr); code != 0 { + t.Fatalf("exit = %d, want 0 (stderr=%q)", code, stderr.String()) + } + + out := stdout.String() + if !strings.Contains(out, "rune_mcp") || !strings.Contains(out, "v0.2.0") { + t.Errorf("expected rune_mcp update reported, got %q", out) + } + if strings.Contains(out, "runed:") { + t.Errorf("runed is current and must not be listed: %q", out) + } +} + +func TestRunUpdate_CheckJSON(t *testing.T) { + paths := setTestEnv(t) + url := updateManifestServer(t, "v0.2.0", "v0.2.0") + t.Setenv("RUNE_MANIFEST", url) + writeAudit(t, paths, url, "v0.1.0", "v0.2.0") + + var stdout, stderr bytes.Buffer + if code := runUpdate(context.Background(), []string{"--check", "--json"}, &stdout, &stderr); code != 0 { + t.Fatalf("exit = %d, want 0 (stderr=%q)", code, stderr.String()) + } + + var plan bootstrap.UpdateList + if err := json.Unmarshal(stdout.Bytes(), &plan); err != nil { + t.Fatalf("stdout is not a valid UpdateList JSON: %v\n%s", err, stdout.String()) + } + if !plan.HasUpdates() { + t.Errorf("expected an update in the JSON plan: %+v", plan) + } +} + +func TestRunUpdate_CheckUpToDate(t *testing.T) { + paths := setTestEnv(t) + url := updateManifestServer(t, "v0.2.0", "v0.2.0") + t.Setenv("RUNE_MANIFEST", url) + writeAudit(t, paths, url, "v0.2.0", "v0.2.0") + + var stdout, stderr bytes.Buffer + if code := runUpdate(context.Background(), []string{"--check"}, &stdout, &stderr); code != 0 { + t.Errorf("exit = %d, want 0", code) + } + if !strings.Contains(stdout.String(), "up to date") { + t.Errorf("expected up-to-date message, got %q", stdout.String()) + } +} + +// TODO: runed is not implemented yet +func dummyUpdateServer(t *testing.T, mcpBytes []byte, mcpVer, runedVer string) string { + t.Helper() + + sum := sha256.Sum256(mcpBytes) + mcpSHA := hex.EncodeToString(sum[:]) + + var srv *httptest.Server + mux := http.NewServeMux() + mux.HandleFunc("/manifest.json", func(w http.ResponseWriter, r *http.Request) { + m := map[string]any{ + "version": 1, + "rune_mcp_version": mcpVer, + "runed_version": runedVer, + "platforms": map[string]any{ + bootstrap.PlatformTuple(): map[string]any{ + "runed": map[string]any{"url": srv.URL + "/runed", "sha256": "dummy-not-downloaded", "size": 1}, + "rune_mcp": map[string]any{"url": srv.URL + "/rune-mcp", "sha256": mcpSHA, "size": len(mcpBytes)}, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(m) + }) + mux.HandleFunc("/rune-mcp", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(mcpBytes))) + _, _ = w.Write(mcpBytes) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + return srv.URL + "/manifest.json" +} + +func TestRunUpdate_ApplyRuneMCP(t *testing.T) { + paths := setTestEnv(t) + mcp := []byte("freshly-updated-rune-mcp") + url := dummyUpdateServer(t, mcp, "v0.2.0", "v0.1.0") + t.Setenv("RUNE_MANIFEST", url) + writeAudit(t, paths, url, "v0.1.0", "v0.1.0") // rune-mcp old, runed current + + var stdout, stderr bytes.Buffer + if code := runUpdate(context.Background(), nil, &stdout, &stderr); code != 0 { + t.Fatalf("exit = %d, want 0 (stderr=%q)", code, stderr.String()) + } + if b, _ := os.ReadFile(paths.RuneMCPBinary); string(b) != string(mcp) { + t.Errorf("rune-mcp not swapped on disk: got %q", b) + } + if !strings.Contains(stdout.String(), "updated") { + t.Errorf("expected an applied message, got %q", stdout.String()) + } + + plan, err := bootstrap.CheckUpdate(context.Background(), url, nil) + if err != nil { + t.Fatalf("CheckUpdate: %v", err) + } + if plan.HasUpdates() { + t.Errorf("after apply nothing should be outdated: %+v", plan.Artifacts) + } +} + +func TestRunUpdate_ApplyJSON(t *testing.T) { + paths := setTestEnv(t) + mcp := []byte("mcp-json-apply") + url := dummyUpdateServer(t, mcp, "v0.2.0", "v0.2.0") + t.Setenv("RUNE_MANIFEST", url) + writeAudit(t, paths, url, "v0.1.0", "v0.2.0") // rune-mcp old, runed current + + var stdout, stderr bytes.Buffer + if code := runUpdate(context.Background(), []string{"--json"}, &stdout, &stderr); code != 0 { + t.Fatalf("exit = %d, want 0 (stderr=%q)", code, stderr.String()) + } + + var sum struct { + Applied []struct { + Step string `json:"step"` + From string `json:"from"` + To string `json:"to"` + } `json:"applied"` + Deferred []string `json:"deferred"` + Error string `json:"error"` + } + + if err := json.Unmarshal(stdout.Bytes(), &sum); err != nil { + t.Fatalf("stdout is not a valid update summary JSON: %v\n%s", err, stdout.String()) + } + if len(sum.Applied) != 1 || sum.Applied[0].Step != bootstrap.StepRuneMCP || sum.Applied[0].To != "v0.2.0" { + t.Errorf("applied summary wrong: %+v", sum.Applied) + } + if sum.Error != "" { + t.Errorf("unexpected error: %q", sum.Error) + } +} + +func TestRunUpdate_RunedOutdated(t *testing.T) { + paths := setTestEnv(t) + url := updateManifestServer(t, "v0.2.0", "v0.2.0") + t.Setenv("RUNE_MANIFEST", url) + writeAudit(t, paths, url, "v0.2.0", "v0.1.0") // rune-mcp current, runed old + + var stdout, stderr bytes.Buffer + if code := runUpdate(context.Background(), nil, &stdout, &stderr); code != 0 { + t.Fatalf("exit = %d, want 0 (runed deferral, stderr=%q)", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "not applied") { + t.Errorf("expected runed deferral message, got %q", stdout.String()) + } +} diff --git a/internal/bootstrap/audit.go b/internal/bootstrap/audit.go index 3f78b17..4e2c554 100644 --- a/internal/bootstrap/audit.go +++ b/internal/bootstrap/audit.go @@ -29,7 +29,7 @@ type InstalledArtifact struct { } func WriteInstalledManifest(paths *Paths, manifestURL string, manifest *Manifest, artifacts map[string]InstalledArtifact) error { - record := InstalledManifest{ + record := &InstalledManifest{ ManifestURL: manifestURL, ManifestVersion: manifest.Version, RuneMCPVersion: manifest.RuneMCPVersion, @@ -38,7 +38,12 @@ func WriteInstalledManifest(paths *Paths, manifestURL string, manifest *Manifest InstalledAt: time.Now().UTC().Format(time.RFC3339), Artifacts: artifacts, } - data, err := json.MarshalIndent(&record, "", " ") + + return writeManifest(paths, record) +} + +func writeManifest(paths *Paths, record *InstalledManifest) error { + data, err := json.MarshalIndent(record, "", " ") if err != nil { return fmt.Errorf("installed.json: marshal: %w", err) } diff --git a/internal/bootstrap/paths.go b/internal/bootstrap/paths.go index 626b33b..217753d 100644 --- a/internal/bootstrap/paths.go +++ b/internal/bootstrap/paths.go @@ -53,6 +53,7 @@ type Paths struct { LlamaServer string // ~/.runed/bin/llama-server (extracted from runed tarball; RUNED_LLAMA_SERVER env points here) InstallLock string // ~/.runed/install.lock SupervisorLock string // ~/.runed/supervisor.lock (`rune runed --detach` hold it during lifetime) + SupervisorSock string // ~/.runed/supervisor.sock (control channel: status/reload/stop, served while the supervisor runs) DaemonLog string // ~/.runed/logs/daemon.log Cache string // ~/.runed/cache } @@ -109,6 +110,7 @@ func newPaths(runeHome, runedHome string) *Paths { LlamaServer: filepath.Join(runedBin, "llama-server"), InstallLock: filepath.Join(runedHome, "install.lock"), SupervisorLock: filepath.Join(runedHome, "supervisor.lock"), + SupervisorSock: filepath.Join(runedHome, "supervisor.sock"), DaemonLog: filepath.Join(runedHome, "logs", "daemon.log"), Cache: filepath.Join(runedHome, "cache"), } diff --git a/internal/bootstrap/update.go b/internal/bootstrap/update.go new file mode 100644 index 0000000..a2fffd5 --- /dev/null +++ b/internal/bootstrap/update.go @@ -0,0 +1,200 @@ +package bootstrap + +import ( + "context" + "fmt" + "os" + "strings" + "time" +) + +type ArtifactVersion struct { + Step string `json:"step"` // StepRuned | StepRuneMCP + Installed string `json:"installed"` + Available string `json:"available"` // available versions from updated manifest + Outdated bool `json:"outdated"` +} + +type UpdateList struct { + Artifacts []ArtifactVersion `json:"artifacts"` +} + +func (p UpdateList) HasUpdates() bool { + for _, a := range p.Artifacts { + if a.Outdated { + return true + } + } + + return false +} + +func (p UpdateList) Outdated() []ArtifactVersion { + var out []ArtifactVersion + for _, a := range p.Artifacts { + if a.Outdated { + out = append(out, a) + } + } + + return out +} + +// Strip "v" prefix and build metadata +func normalizeVersion(v string) string { + v = strings.TrimSpace(v) + + // Strip "v/V" prefix + if len(v) > 1 && (v[0] == 'v' || v[0] == 'V') && v[1] >= '0' && v[1] <= '9' { + v = v[1:] + } + + // Drop build metadata + if i := strings.IndexByte(v, '+'); i > 0 { + v = v[:i] + } + return v +} + +func planUpdate(installed *InstalledManifest, manifest *Manifest) UpdateList { + plan := UpdateList{} + if manifest == nil { + return plan // no updates + } + + for _, step := range []string{StepRuned, StepRuneMCP} { + var inst, avail string + + switch step { + case StepRuned: + avail = manifest.RunedVersion + if installed != nil { + inst = installed.RunedVersion + } + case StepRuneMCP: + avail = manifest.RuneMCPVersion + if installed != nil { + inst = installed.RuneMCPVersion + } + } + + plan.Artifacts = append(plan.Artifacts, ArtifactVersion{ + Step: step, + Installed: inst, + Available: avail, + Outdated: avail != "" && inst != "" && normalizeVersion(inst) != normalizeVersion(avail), + }) + } + return plan +} + +func CheckUpdate(ctx context.Context, manifestURL string, logf func(format string, args ...any)) (*UpdateList, error) { + if logf == nil { + logf = func(string, ...any) {} + } + + // Fetch manifest + manifest, err := FetchManifest(ctx, manifestURL, logf) + if err != nil { + return nil, err + } + + paths, err := Resolve() + if err != nil { + return nil, err + } + + // Get local installed info + installed, _ := ReadInstalledManifest(paths) // nil: unknown version + plan := planUpdate(installed, manifest) + + return &plan, nil +} + +// Swap binary only; rune-mcp applies on next spawn and runed needs manual restart +func UpdateArtifact(ctx context.Context, manifestURL, step string, logf func(format string, args ...any)) (string, error) { + if logf == nil { + logf = func(string, ...any) {} + } + + if step != StepRuned && step != StepRuneMCP { + return "", fmt.Errorf("update: unknown artifact %q", step) + } + + manifest, err := FetchManifest(ctx, manifestURL, logf) + if err != nil { + return "", err + } + + arts, err := manifest.ArtifactsForCurrentPlatform() + if err != nil { + return "", err + } + + paths, err := Resolve() + if err != nil { + return "", err + } + + // Update (re-download / verify / atomic-swap) + if _, err := Install(ctx, InstallOptions{ + ManifestURL: manifestURL, + Target: []string{step}, + Force: true, + Log: logf, + }); err != nil { + return "", err + } + + var spec ArtifactSpec + var dest, version string + switch step { + case StepRuneMCP: + spec, dest, version = arts.RuneMCP, paths.RuneMCPBinary, manifest.RuneMCPVersion + case StepRuned: + spec, dest, version = arts.Runed, paths.RunedBinary, manifest.RunedVersion + } + + // Update install audit + unlock, err := acquireInstallLock(ctx, paths.InstallLock, InstallLockTimeout) + if err != nil { + return "", fmt.Errorf("update: acquire lock for audit write: %w", err) + } + defer unlock() + + rec, _ := ReadInstalledManifest(paths) + if rec == nil { + rec = &InstalledManifest{ManifestVersion: manifest.Version, Platform: PlatformTuple()} + } + if rec.Artifacts == nil { + rec.Artifacts = map[string]InstalledArtifact{} + } + + entry := InstalledArtifact{URL: spec.URL, SHA256: spec.SHA256, Path: dest, Size: spec.Size} + if info, statErr := os.Stat(dest); statErr == nil { + entry.Size = info.Size() + } + + entry.DestSHA256 = spec.SHA256 + if spec.Extract != "" { + if h, hErr := FileSHA256(dest); hErr == nil { + entry.DestSHA256 = h + } else { + entry.DestSHA256 = "" + logf("warning: cannot hash %s for audit: %v", dest, hErr) + } + } + rec.Artifacts[step] = entry + + switch step { + case StepRuneMCP: + rec.RuneMCPVersion = version + case StepRuned: + rec.RunedVersion = version + } + rec.ManifestURL = manifestURL + rec.ManifestVersion = manifest.Version + rec.InstalledAt = time.Now().UTC().Format(time.RFC3339) + + return version, writeManifest(paths, rec) +} diff --git a/internal/bootstrap/update_test.go b/internal/bootstrap/update_test.go new file mode 100644 index 0000000..3a73018 --- /dev/null +++ b/internal/bootstrap/update_test.go @@ -0,0 +1,216 @@ +package bootstrap + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestNormalizeVersion(t *testing.T) { + cases := map[string]string{ + "v0.1.0": "0.1.0", + "0.1.0": "0.1.0", + "V0.1.0": "0.1.0", + "v0.1.0+build.7": "0.1.0", + "v0.1.0-alpha.4": "0.1.0-alpha.4", + " v0.1.0 ": "0.1.0", + "": "", + "version1": "version1", + "Version1.0": "Version1.0", + "v": "v", + } + + for in, want := range cases { + if got := normalizeVersion(in); got != want { + t.Errorf("normalizeVersion(%q) = %q, want %q", in, got, want) + } + } +} + +func TestPlanUpdate(t *testing.T) { + manifest := &Manifest{Version: 1, RuneMCPVersion: "v0.2.0", RunedVersion: "v0.2.0"} + + t.Run("outdated when versions differ", func(t *testing.T) { + installed := &InstalledManifest{RuneMCPVersion: "v0.1.0", RunedVersion: "v0.2.0"} + + plan := planUpdate(installed, manifest) + if !plan.HasUpdates() { + t.Fatal("expected an update") + } + + out := plan.Outdated() + if len(out) != 1 || out[0].Step != StepRuneMCP { + t.Fatalf("expected only rune_mcp outdated, got %+v", out) + } + if out[0].Installed != "v0.1.0" || out[0].Available != "v0.2.0" { + t.Errorf("version fields wrong: %+v", out[0]) + } + }) + + t.Run("not outdated when v-prefix and build metadata", func(t *testing.T) { + installed := &InstalledManifest{RuneMCPVersion: "0.2.0+build.3", RunedVersion: "v0.2.0"} + if planUpdate(installed, manifest).HasUpdates() { + t.Error("v-prefix / build-metadata differences must not count as update") + } + }) + + t.Run("prerelease difference is update", func(t *testing.T) { + m := &Manifest{Version: 1, RuneMCPVersion: "v0.2.0-alpha.5", RunedVersion: "v0.2.0"} + installed := &InstalledManifest{RuneMCPVersion: "v0.2.0-alpha.4", RunedVersion: "v0.2.0"} + if !planUpdate(installed, m).HasUpdates() { + t.Error("pre-release tags must count as update") + } + }) + + t.Run("unknown installed is not flagged", func(t *testing.T) { + if planUpdate(nil, manifest).HasUpdates() { + t.Error("unknown installed versions must not be flagged") + } + }) + + t.Run("empty installed version is not flagged", func(t *testing.T) { + installed := &InstalledManifest{} // blank version + if planUpdate(installed, manifest).HasUpdates() { + t.Error("blank installed version is unknown, not outdated") + } + }) + + t.Run("empty manifest version gives nothing", func(t *testing.T) { + m := &Manifest{Version: 1} + installed := &InstalledManifest{RuneMCPVersion: "v0.1.0", RunedVersion: "v0.1.0"} + if planUpdate(installed, m).HasUpdates() { + t.Error("empty manifest versions must not be flagged") + } + }) + + t.Run("nil manifest (no panic, no updates)", func(t *testing.T) { + installed := &InstalledManifest{RuneMCPVersion: "v0.1.0", RunedVersion: "v0.1.0"} + plan := planUpdate(installed, nil) + if plan.HasUpdates() || len(plan.Artifacts) != 0 { + t.Errorf("nil manifest should be empty plan, got %+v", plan) + } + }) +} + +func TestCheckUpdate(t *testing.T) { + setRealms(t) + t.Setenv("RUNE_MANIFEST", "") + fx := newFixture(t) + + paths, err := Resolve() + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if err := paths.EnsureDirs(); err != nil { + t.Fatalf("EnsureDirs: %v", err) + } + + // rune-mcp: old, runed: latest + rec := &Manifest{Version: 1, RuneMCPVersion: "v0.0.1", RunedVersion: "v0.1.0-test"} + arts := map[string]InstalledArtifact{ + StepRuneMCP: {Path: paths.RuneMCPBinary}, + StepRuned: {Path: paths.RunedBinary}, + } + if err := WriteInstalledManifest(paths, fx.manifestURL(), rec, arts); err != nil { + t.Fatalf("WriteInstalledManifest: %v", err) + } + + plan, err := CheckUpdate(context.Background(), fx.manifestURL(), nil) + if err != nil { + t.Fatalf("CheckUpdate: %v", err) + } + + out := plan.Outdated() + if len(out) != 1 || out[0].Step != StepRuneMCP { + t.Fatalf("expected rune_mcp outdated (v0.0.1 -> v0.1.0-test), got %+v", plan.Artifacts) + } + if out[0].Installed != "v0.0.1" || out[0].Available != "v0.1.0-test" { + t.Errorf("version fields wrong: %+v", out[0]) + } +} + +func TestCheckUpdate_NotInstalled(t *testing.T) { + setRealms(t) + t.Setenv("RUNE_MANIFEST", "") + fx := newFixture(t) + + plan, err := CheckUpdate(context.Background(), fx.manifestURL(), nil) + if err != nil { + t.Fatalf("CheckUpdate: %v", err) + } + if plan.HasUpdates() { + t.Errorf("with no install audit, nothing should be outdated: %+v", plan.Artifacts) + } +} + +func TestUpdateArtifact_UpdateSingleArtifact(t *testing.T) { + rune, _ := setRealms(t) + t.Setenv("RUNE_MANIFEST", "") + fx := newFixture(t) + + paths, err := Resolve() + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if err := paths.EnsureDirs(); err != nil { + t.Fatalf("EnsureDirs: %v", err) + } + + // Simulate installed artfiact - rune-mcp: old, runed: latest + rec := &Manifest{Version: 1, RuneMCPVersion: "v0.0.1", RunedVersion: "v0.1.0-test"} + arts := map[string]InstalledArtifact{ + StepRuneMCP: {Path: paths.RuneMCPBinary, SHA256: "old-mcp", DestSHA256: "old-mcp"}, + StepRuned: {Path: paths.RunedBinary, SHA256: "runed-sha", DestSHA256: "runed-sha"}, + } + if err := WriteInstalledManifest(paths, fx.manifestURL(), rec, arts); err != nil { + t.Fatalf("WriteInstalledManifest: %v", err) + } + + got, err := UpdateArtifact(context.Background(), fx.manifestURL(), StepRuneMCP, nil) + if err != nil { + t.Fatalf("UpdateArtifact: %v", err) + } + if got != "v0.1.0-test" { + t.Errorf("returned version = %q, want v0.1.0-test", got) + } + + // Reinstall rune-mcp + if b, _ := os.ReadFile(filepath.Join(rune, "bin", "rune-mcp")); string(b) != string(fx.runeMCP) { + t.Errorf("rune-mcp not re-installed on disk: got %q", b) + } + + after, err := ReadInstalledManifest(paths) + if err != nil { + t.Fatalf("ReadInstalledManifest: %v", err) + } + // rune-mcp should be updated + if after.RuneMCPVersion != "v0.1.0-test" { + t.Errorf("rune_mcp_version = %q, want v0.1.0-test", after.RuneMCPVersion) + } + if after.Artifacts[StepRuneMCP].DestSHA256 == "old-mcp" { + t.Errorf("rune_mcp DestSHA256 not refreshed: %q", after.Artifacts[StepRuneMCP].DestSHA256) + } + // runed should not be updated + if after.RunedVersion != "v0.1.0-test" { + t.Errorf("runed_version changed unexpectedly: %q", after.RunedVersion) + } + if after.Artifacts[StepRuned].DestSHA256 != "runed-sha" { + t.Errorf("runed DestSHA256 should be preserved, got %q", after.Artifacts[StepRuned].DestSHA256) + } + + // Check after update + plan, err := CheckUpdate(context.Background(), fx.manifestURL(), nil) + if err != nil { + t.Fatalf("CheckUpdate: %v", err) + } + if plan.HasUpdates() { + t.Errorf("rune-mcp should be up to date after UpdateArtifact: %+v", plan.Artifacts) + } +} + +func TestUpdateArtifact_UnknownArtifact(t *testing.T) { + if _, err := UpdateArtifact(context.Background(), "http://unused", "llama-server", nil); err == nil { + t.Error("expected error for an unknown artifact") + } +} diff --git a/internal/supervisor/control.go b/internal/supervisor/control.go new file mode 100644 index 0000000..4b88cc6 --- /dev/null +++ b/internal/supervisor/control.go @@ -0,0 +1,90 @@ +package supervisor + +import ( + "encoding/json" + "fmt" + "io" + "net" + "os" + "time" +) + +type Request struct { + Cmd string `json:"cmd"` // "status" (TODO: "reload", "stop") +} + +type Response struct { + OK bool `json:"ok"` + PID int `json:"pid,omitempty"` // supervisor pid + Error string `json:"error,omitempty"` +} + +const connTimeout = 5 * time.Second +const maxControlMsg = 4 << 10 // 4 KB + +func listenControl(path string) net.Listener { + _ = os.Remove(path) + + ln, err := net.Listen("unix", path) + if err != nil { + fmt.Fprintf(os.Stderr, "supervisor: control socket listen %s: %v\n", path, err) + return nil + } + + _ = os.Chmod(path, 0o600) + + return ln +} + +func serveControl(ln net.Listener) { + for { + conn, err := ln.Accept() + if err != nil { + return // shutdown + } + + go handleControlConn(conn) + } +} + +func handleControlConn(conn net.Conn) { + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(connTimeout)) + + var req Request + if err := json.NewDecoder(io.LimitReader(conn, maxControlMsg)).Decode(&req); err != nil { + _ = json.NewEncoder(conn).Encode(Response{OK: false, Error: "bad request"}) + return + } + _ = json.NewEncoder(conn).Encode(dispatchControl(req)) +} + +func dispatchControl(req Request) Response { + switch req.Cmd { + case "status": + return Response{OK: true, PID: os.Getpid()} + default: + return Response{OK: false, Error: "unknown command: " + req.Cmd} + } +} + +func SupervisorRequest(socketPath string, req Request) (Response, error) { + conn, err := net.DialTimeout("unix", socketPath, 2*time.Second) + if err != nil { + return Response{}, err + } + defer conn.Close() + + _ = conn.SetDeadline(time.Now().Add(connTimeout)) + + if err := json.NewEncoder(conn).Encode(req); err != nil { + return Response{}, err + } + + var resp Response + if err := json.NewDecoder(io.LimitReader(conn, maxControlMsg)).Decode(&resp); err != nil { + return Response{}, err + } + + return resp, nil +} diff --git a/internal/supervisor/supervisor.go b/internal/supervisor/supervisor.go index 9231d08..deb47e8 100644 --- a/internal/supervisor/supervisor.go +++ b/internal/supervisor/supervisor.go @@ -17,6 +17,7 @@ type Config struct { RunedArgs []string LogPath string // default: ~/.runed/logs/daemon.log LockPath string // default: ~/.runed/supervisor.lock + SocketPath string // default: ~/.runed/supervisor.sock; empty = no control channel BackoffSchedule []time.Duration // nil for DefaultBackoff MaxCrashes int // 0 for DefaultMaxCrashes @@ -104,6 +105,14 @@ func runWatcher(ctx context.Context, cfg Config) error { signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) defer signal.Stop(sigCh) + // Listen control channel + if cfg.SocketPath != "" { + if ln := listenControl(cfg.SocketPath); ln != nil { + defer ln.Close() + go serveControl(ln) + } + } + var crashes []time.Time backoffIdx := 0 diff --git a/internal/supervisor/supervisor_test.go b/internal/supervisor/supervisor_test.go index 356c09c..aaefcac 100644 --- a/internal/supervisor/supervisor_test.go +++ b/internal/supervisor/supervisor_test.go @@ -223,6 +223,51 @@ func TestWatcher_ForwardSignalToChild(t *testing.T) { } } +func TestWatcher_ControlStatus(t *testing.T) { + t.Setenv(fakeRunedEnv, "sleep") + + cfg := testWatcherConfig(t) + cfg.SocketPath = filepath.Join(t.TempDir(), "supervisor.sock") + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- runWatcher(ctx, cfg) }() + + var resp Response + deadline := time.Now().Add(2 * time.Second) + for { + r, err := SupervisorRequest(cfg.SocketPath, Request{Cmd: "status"}) + if err == nil { + resp = r + break + } + if time.Now().After(deadline) { + t.Fatalf("control socket did not answer within deadlien: %v", err) + } + time.Sleep(10 * time.Millisecond) + } + + if !resp.OK || resp.PID != os.Getpid() { + t.Errorf("status = %+v, want OK with supervisor's pid %d", resp, os.Getpid()) + } + + // Reject unknown command + if r, err := SupervisorRequest(cfg.SocketPath, Request{Cmd: "unknown"}); err != nil || r.OK { + t.Errorf("unknown cmd: resp=%+v err=%v, want OK=false", r, err) + } + + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("watcher did not exit after cancel") + } + + if _, err := SupervisorRequest(cfg.SocketPath, Request{Cmd: "status"}); err == nil { + t.Error("control socket should be gone after the watcher exits") + } +} + func TestWatcher_RetriesStartFailure(t *testing.T) { cfg := testWatcherConfig(t) cfg.RunedBinary = filepath.Join(t.TempDir(), "no-such-runed")