diff --git a/cmd/subrouter/setup_plan.go b/cmd/subrouter/setup_plan.go new file mode 100644 index 000000000..c9292d6fc --- /dev/null +++ b/cmd/subrouter/setup_plan.go @@ -0,0 +1,379 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + + "golang.org/x/term" + + "github.com/manaflow-ai/subrouter/internal/accounts" +) + +// Setup asks for one confirmation covering the whole reviewed change set rather +// than a yes/no per item. Per-item prompts produce half-installed machines: a +// user who declines background startup gets a daemon that stops working at the +// next reboot and no longer resembles any state we test. Enter-to-apply is +// already opt-in because nothing changes until the key is pressed, and the +// uncommon choices stay reachable behind one keystroke. + +// setupAction identifies a unit of work in the plan. +type setupAction string + +const ( + actionBackground setupAction = "background" + actionConfigCodex setupAction = "config-codex" + actionConfigClaude setupAction = "config-claude" + actionAdoptLocal setupAction = "adopt-local" +) + +// setupItem is one line of the review screen. +type setupItem struct { + Action setupAction + // Summary is the imperative description shown on the review screen. + Summary string + // Detail is the exact change, shown by `d`. + Detail string + // Change marks new work (+), a modification of existing state (~), or + // something already true (=), which is listed but never applied. + Change string + // Selected reports whether applying the plan performs this item. + Selected bool + // Optional reports whether `e` may toggle it. Required work is not + // togglable, because a machine without it is not set up. + Optional bool +} + +type setupPlan struct { + Items []setupItem +} + +func (p setupPlan) find(action setupAction) (setupItem, bool) { + for _, item := range p.Items { + if item.Action == action { + return item, true + } + } + return setupItem{}, false +} + +func (p setupPlan) selected(action setupAction) bool { + item, ok := p.find(action) + return ok && item.Selected && item.Change != "=" +} + +// pending reports whether applying the plan would change anything. +func (p setupPlan) pending() bool { + for _, item := range p.Items { + if item.Selected && item.Change != "=" { + return true + } + } + return false +} + +func (p *setupPlan) toggle(action setupAction) bool { + for i := range p.Items { + if p.Items[i].Action != action || !p.Items[i].Optional { + continue + } + p.Items[i].Selected = !p.Items[i].Selected + return true + } + return false +} + +// backgroundSummary names the platform mechanism, because "run in the +// background" means a different object to inspect on each OS. +func backgroundSummary(goos string) string { + switch goos { + case "darwin": + return "Run Subrouter in the background after you sign in (LaunchAgent)" + case "linux": + return "Run Subrouter in the background after you sign in (systemd user service)" + case "windows": + return "Run Subrouter in the background after you sign in (scheduled task)" + default: + return "Run Subrouter in the background after you sign in" + } +} + +// setupPlanInput is the observed state the plan is computed from. +type setupPlanInput struct { + GOOS string + DaemonInstalled bool + CodexConfigured bool + ClaudeConfigured bool + LocalAccounts int + TeamModeReady bool + // WantBackground and WantConfig carry --no-background and --no-config. + WantBackground bool + WantConfig bool +} + +func buildSetupPlan(in setupPlanInput) setupPlan { + change := func(done bool) string { + if done { + return "=" + } + return "+" + } + plan := setupPlan{Items: []setupItem{ + { + Action: actionBackground, + Summary: backgroundSummary(in.GOOS), + Detail: backgroundDetail(in.GOOS), + Change: change(in.DaemonInstalled), + Selected: in.WantBackground, + Optional: true, + }, + { + Action: actionConfigCodex, + Summary: "Configure Codex to route through Subrouter", + Detail: "sets openai_base_url and chatgpt_base_url in ~/.codex/config.toml to the local daemon", + Change: change(in.CodexConfigured), + Selected: in.WantConfig, + Optional: true, + }, + { + Action: actionConfigClaude, + Summary: "Configure Claude Code to route through Subrouter", + Detail: "points Claude Code's base URL at the local daemon", + Change: change(in.ClaudeConfigured), + Selected: in.WantConfig, + Optional: true, + }, + }} + // Only offer a credential decision when there is somewhere to hand them to. + // Without a vault the accounts simply stay local, and rendering a + // deselectable "keep" line implies setup might remove them. + if in.LocalAccounts > 0 && in.TeamModeReady { + // Say plainly that handing accounts to the vault stops this machine + // refreshing them. The provider rotates refresh tokens on use, so two + // refreshers invalidate each other; a user who does not know that will + // read this line as a harmless copy. + summary := fmt.Sprintf("Hand %d existing account(s) to the team vault", in.LocalAccounts) + detail := "uploads them, then stops refreshing them here so only the vault holds their token chains" + plan.Items = append(plan.Items, setupItem{ + Action: actionAdoptLocal, + Summary: summary, + Detail: detail, + Change: "~", + Selected: in.TeamModeReady, + Optional: true, + }) + } + return plan +} + +func backgroundDetail(goos string) string { + switch goos { + case "darwin": + return "writes ~/Library/LaunchAgents/" + defaultDaemonLabel + ".plist and loads it" + case "linux": + return "writes ~/.config/systemd/user/" + defaultSystemdServiceName + ".service and enables it" + case "windows": + return `registers the scheduled task \\Subrouter\\Daemon to start at logon` + default: + return "installs a per-user service that starts at login" + } +} + +// renderSetupPlan writes the review screen. Selected items are marked with their +// change type; deselected ones are dimmed to a dash so the screen always shows +// every choice that exists rather than hiding what was turned off. +func renderSetupPlan(plan setupPlan, out io.Writer) { + fmt.Fprintln(out, "Subrouter is ready to set up this computer.") + fmt.Fprintln(out) + for _, item := range plan.Items { + marker := item.Change + if !item.Selected { + marker = "-" + } + if item.Change == "=" && item.Selected { + fmt.Fprintf(out, " %s %s (already done)\n", marker, item.Summary) + continue + } + fmt.Fprintf(out, " %s %s\n", marker, item.Summary) + } + fmt.Fprintln(out) + if !plan.pending() { + fmt.Fprintln(out, "Nothing to do. Press Enter to exit.") + } else { + fmt.Fprintln(out, "Press Enter to apply.") + } + fmt.Fprintln(out, "[d] details [e] edit options [Ctrl-C] cancel") +} + +func renderSetupDetails(plan setupPlan, out io.Writer) { + fmt.Fprintln(out) + for _, item := range plan.Items { + state := "will apply" + switch { + case item.Change == "=": + state = "already done" + case !item.Selected: + state = "skipped" + } + fmt.Fprintf(out, " %s [%s]\n %s\n", item.Summary, state, item.Detail) + } + fmt.Fprintln(out) +} + +func renderSetupOptions(plan setupPlan, out io.Writer) { + fmt.Fprintln(out) + index := 0 + for _, item := range plan.Items { + if !item.Optional { + continue + } + index++ + mark := " " + if item.Selected { + mark = "x" + } + fmt.Fprintf(out, " %d. [%s] %s\n", index, mark, item.Summary) + } + fmt.Fprintln(out, "\nType a number to toggle, or press Enter to go back.") +} + +// optionalActions lists togglable actions in display order, so a typed number on +// the options screen maps to the same item the user is looking at. +func (p setupPlan) optionalActions() []setupAction { + var out []setupAction + for _, item := range p.Items { + if item.Optional { + out = append(out, item.Action) + } + } + return out +} + +// reviewSetupPlan runs the interactive review. It returns the plan to apply and +// whether the user confirmed. EOF counts as cancellation: an unattended terminal +// must never be treated as approval. +func reviewSetupPlan(plan setupPlan, in io.Reader, out io.Writer) (setupPlan, bool) { + reader := bufio.NewReader(in) + for { + renderSetupPlan(plan, out) + line, err := reader.ReadString('\n') + if err != nil && strings.TrimSpace(line) == "" { + fmt.Fprintln(out, "\ncancelled; nothing was changed") + return plan, false + } + switch strings.ToLower(strings.TrimSpace(line)) { + case "": + return plan, true + case "d": + renderSetupDetails(plan, out) + case "e": + plan = editSetupOptions(plan, reader, out) + case "q": + fmt.Fprintln(out, "cancelled; nothing was changed") + return plan, false + default: + fmt.Fprintln(out, "unrecognised key; press Enter to apply, d for details, e to edit") + } + } +} + +func editSetupOptions(plan setupPlan, reader *bufio.Reader, out io.Writer) setupPlan { + for { + renderSetupOptions(plan, out) + line, err := reader.ReadString('\n') + choice := strings.TrimSpace(line) + if choice == "" { + return plan + } + actions := plan.optionalActions() + var index int + if _, scanErr := fmt.Sscanf(choice, "%d", &index); scanErr != nil || + index < 1 || index > len(actions) { + fmt.Fprintln(out, "no such option") + if err != nil { + return plan + } + continue + } + plan.toggle(actions[index-1]) + if err != nil { + return plan + } + } +} + +// currentGOOS exists so tests can render every platform's wording. +func currentGOOS() string { return runtime.GOOS } + +// setupPlanOptions carries the flag-derived preferences into plan construction. +type setupPlanOptions struct { + wantBackground bool + wantConfig bool +} + +// clientRoutesToLocal reports whether a client config file already points at the +// local daemon. Reading the file beats trusting a flag: the plan must describe +// what is actually on disk, or "already done" is a guess. +func clientRoutesToLocal(path, localURL string) bool { + body, err := os.ReadFile(path) + if err != nil { + return false + } + target := strings.TrimSuffix(localURL, "/") + return strings.Contains(string(body), target) +} + +// setupStdin returns stdin when a human is present, and nil otherwise. A pipe or +// a cron job cannot approve a change set, and treating its silence as approval is +// how unattended runs surprise people. +func setupStdin() io.Reader { + // A character-device check is not enough: /dev/null is one, and reading a + // change-set approval from /dev/null is exactly the mistake to avoid. Only a + // real terminal has a human behind it. + if !term.IsTerminal(int(os.Stdin.Fd())) { + return nil + } + return os.Stdin +} + +// planForSetup observes the machine and builds the reviewable change set. +func planForSetup(_ context.Context, store accounts.CodexStore, opts setupPlanOptions) (setupPlan, error) { + controller, err := newServiceController() + installed := err == nil && controller.installed() + + local := localBaseURL() + codexPath, codexErr := defaultCodexConfigPath() + codexConfigured := codexErr == nil && clientRoutesToLocal(codexPath, local) + + claudeConfigured := false + if home, homeErr := os.UserHomeDir(); homeErr == nil { + claudeConfigured = clientRoutesToLocal( + filepath.Join(home, ".claude", "settings.json"), local) + } + + localAccounts := 0 + if list, listErr := store.ListStored(); listErr == nil { + localAccounts = len(list) + } + + teamReady := false + if cloudConfig, cloudErr := cloudModeConfig(); cloudErr == nil { + teamReady = cloudConfig.TeamModeReady() + } + + return buildSetupPlan(setupPlanInput{ + GOOS: currentGOOS(), + DaemonInstalled: installed, + CodexConfigured: codexConfigured, + ClaudeConfigured: claudeConfigured, + LocalAccounts: localAccounts, + TeamModeReady: teamReady, + WantBackground: opts.wantBackground, + WantConfig: opts.wantConfig, + }), nil +} diff --git a/cmd/subrouter/setup_plan_test.go b/cmd/subrouter/setup_plan_test.go new file mode 100644 index 000000000..c327fc295 --- /dev/null +++ b/cmd/subrouter/setup_plan_test.go @@ -0,0 +1,203 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func basePlanInput() setupPlanInput { + return setupPlanInput{ + GOOS: "darwin", + LocalAccounts: 2, + TeamModeReady: true, + WantBackground: true, + WantConfig: true, + } +} + +// The common path is one keystroke, and that keystroke must approve the whole +// reviewed set rather than the first of four questions. +func TestReviewAppliesOnEnter(t *testing.T) { + var out bytes.Buffer + plan, confirmed := reviewSetupPlan(buildSetupPlan(basePlanInput()), strings.NewReader("\n"), &out) + if !confirmed { + t.Fatal("Enter did not confirm the plan") + } + if !plan.selected(actionBackground) { + t.Fatal("background startup was not selected by default") + } + screen := out.String() + for _, want := range []string{ + "Subrouter is ready to set up this computer.", + "Press Enter to apply.", + "[d] details [e] edit options [Ctrl-C] cancel", + } { + if !strings.Contains(screen, want) { + t.Errorf("review screen missing %q", want) + } + } +} + +// Silence is not consent: EOF without input must cancel, because an unattended +// terminal cannot approve anything. +func TestReviewTreatsEOFAsCancel(t *testing.T) { + var out bytes.Buffer + _, confirmed := reviewSetupPlan(buildSetupPlan(basePlanInput()), strings.NewReader(""), &out) + if confirmed { + t.Fatal("EOF was treated as approval") + } + if !strings.Contains(out.String(), "nothing was changed") { + t.Errorf("cancel path did not say nothing changed: %q", out.String()) + } +} + +func TestReviewShowsDetailsWithoutApplying(t *testing.T) { + var out bytes.Buffer + _, confirmed := reviewSetupPlan(buildSetupPlan(basePlanInput()), strings.NewReader("d\n\n"), &out) + if !confirmed { + t.Fatal("plan was not applied after viewing details") + } + if !strings.Contains(out.String(), "openai_base_url") { + t.Errorf("details did not show the exact Codex change: %q", out.String()) + } +} + +// `e` reaches the uncommon choices without adding a prompt to the normal path. +func TestReviewEditTogglesAnOption(t *testing.T) { + var out bytes.Buffer + // e, toggle item 1 (background), Enter to leave the editor, Enter to apply. + plan, confirmed := reviewSetupPlan( + buildSetupPlan(basePlanInput()), + strings.NewReader("e\n1\n\n\n"), + &out, + ) + if !confirmed { + t.Fatal("plan was not applied after editing") + } + if plan.selected(actionBackground) { + t.Fatal("toggling item 1 did not deselect background startup") + } + if !plan.selected(actionConfigCodex) { + t.Fatal("editing one option changed another") + } +} + +func TestReviewRejectsUnknownOptionNumber(t *testing.T) { + var out bytes.Buffer + plan, _ := reviewSetupPlan( + buildSetupPlan(basePlanInput()), + strings.NewReader("e\n99\n\n\n"), + &out, + ) + if !strings.Contains(out.String(), "no such option") { + t.Errorf("out-of-range choice was accepted silently: %q", out.String()) + } + if !plan.selected(actionBackground) { + t.Error("an invalid choice changed the plan") + } +} + +// --no-background and --no-config deselect without needing the review screen. +func TestFlagsDeselectWork(t *testing.T) { + in := basePlanInput() + in.WantBackground = false + in.WantConfig = false + plan := buildSetupPlan(in) + for _, action := range []setupAction{actionBackground, actionConfigCodex, actionConfigClaude} { + if plan.selected(action) { + t.Errorf("%s stayed selected despite the flag", action) + } + } +} + +// Already-satisfied work is listed and marked, never re-applied, so re-running +// setup is safe and the screen still shows the whole picture. +func TestPlanMarksExistingStateAsDone(t *testing.T) { + in := basePlanInput() + in.DaemonInstalled = true + in.CodexConfigured = true + plan := buildSetupPlan(in) + if plan.selected(actionBackground) || plan.selected(actionConfigCodex) { + t.Fatal("already-satisfied items would be applied again") + } + var out bytes.Buffer + renderSetupPlan(plan, &out) + if !strings.Contains(out.String(), "already done") { + t.Errorf("screen did not mark existing state: %q", out.String()) + } +} + +func TestPlanWithNothingToDoSaysSo(t *testing.T) { + plan := buildSetupPlan(setupPlanInput{GOOS: "linux", DaemonInstalled: true, CodexConfigured: true, ClaudeConfigured: true}) + if plan.pending() { + t.Fatal("plan reports pending work when everything is done") + } + var out bytes.Buffer + renderSetupPlan(plan, &out) + if !strings.Contains(out.String(), "Nothing to do") { + t.Errorf("screen did not say there is nothing to do: %q", out.String()) + } +} + +// Handing accounts to the vault stops this machine refreshing them, which is the +// destructive part; the screen must say so rather than calling it "adopt". With +// no vault configured there is nothing to decide, so no line appears at all. +func TestPlanStatesCredentialOwnershipChange(t *testing.T) { + in := basePlanInput() + plan := buildSetupPlan(in) + noVault := basePlanInput() + noVault.TeamModeReady = false + if _, ok := buildSetupPlan(noVault).find(actionAdoptLocal); ok { + t.Error("a credential decision was offered with no vault to hand accounts to") + } + item, ok := plan.find(actionAdoptLocal) + if !ok { + t.Fatal("no credential item in the plan") + } + if !strings.Contains(item.Summary, "team vault") { + t.Errorf("summary = %q, want it to name the vault", item.Summary) + } + if !strings.Contains(item.Detail, "stops refreshing them here") { + t.Errorf("detail = %q, want it to state the ownership change", item.Detail) + } +} + +// Each platform names its own mechanism, since "in the background" is a +// different object to inspect on each OS. +func TestPlanNamesPlatformMechanism(t *testing.T) { + for goos, want := range map[string]string{ + "darwin": "LaunchAgent", + "linux": "systemd user service", + "windows": "scheduled task", + } { + in := basePlanInput() + in.GOOS = goos + item, ok := buildSetupPlan(in).find(actionBackground) + if !ok { + t.Fatalf("%s: no background item", goos) + } + if !strings.Contains(item.Summary, want) { + t.Errorf("%s summary = %q, want %q", goos, item.Summary, want) + } + } +} + +func TestClientRoutesToLocalReadsDisk(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + if err := os.WriteFile(path, []byte(`openai_base_url = "http://127.0.0.1:31415/v1"`), 0o600); err != nil { + t.Fatal(err) + } + if !clientRoutesToLocal(path, "http://127.0.0.1:31415") { + t.Error("configured client reported as unconfigured") + } + if clientRoutesToLocal(path, "http://127.0.0.1:39999") { + t.Error("a different port matched") + } + if clientRoutesToLocal(filepath.Join(dir, "missing.toml"), "http://127.0.0.1:31415") { + t.Error("a missing file reported as configured") + } +} diff --git a/cmd/subrouter/sr_cloud.go b/cmd/subrouter/sr_cloud.go index 50d380bd7..fabf86668 100644 --- a/cmd/subrouter/sr_cloud.go +++ b/cmd/subrouter/sr_cloud.go @@ -174,9 +174,30 @@ func (r srRunner) cloudSetup(ctx context.Context, args []string) error { noLogin := flags.Bool("no-login", false, "install the daemon without signing in to cmux.com") noInstall := flags.Bool("no-install", false, "start and verify the existing daemon") storage := flags.String("storage", "", "credential storage: team or local") + // Forwarded to runSetup, which owns the review screen. Declared here too + // because this flag set parses first and rejects anything it does not know. + planOnly := flags.Bool("plan", false, "print the change set and exit without modifying anything") + assumeYes := flags.Bool("yes", false, "apply the plan without the review screen") + noBackground := flags.Bool("no-background", false, "do not start Subrouter after login") + noConfig := flags.Bool("no-config", false, "do not configure Codex or Claude Code") if err := flags.Parse(args); err != nil { return err } + forwarded := []string{} + for flagName, enabled := range map[string]*bool{ + "--plan": planOnly, "--yes": assumeYes, + "--no-background": noBackground, "--no-config": noConfig, + } { + if *enabled { + forwarded = append(forwarded, flagName) + } + } + sort.Strings(forwarded) + // `--plan` promises to change nothing, so it must not fall through to the + // login and storage writes below. + if *planOnly { + return runSetup(ctx, r.store, forwarded, r.out) + } path, err := broker.DefaultConfigPath() if err != nil { @@ -224,7 +245,7 @@ func (r srRunner) cloudSetup(ctx context.Context, args []string) error { if err := broker.SaveConfig(path, config); err != nil { return err } - setupArgs := []string{} + setupArgs := append([]string{}, forwarded...) if *noInstall { setupArgs = append(setupArgs, "--no-install") } diff --git a/cmd/subrouter/sr_setup.go b/cmd/subrouter/sr_setup.go index e7f2c1583..e100b8b62 100644 --- a/cmd/subrouter/sr_setup.go +++ b/cmd/subrouter/sr_setup.go @@ -28,11 +28,46 @@ func runSetup(ctx context.Context, store accounts.CodexStore, args []string, out flags := flag.NewFlagSet("setup", flag.ContinueOnError) flags.SetOutput(io.Discard) var skipInstall bool + var planOnly, assumeYes, noBackground, noConfig bool flags.BoolVar(&skipInstall, "no-install", false, "do not install or update the daemon; only start and verify it") + flags.BoolVar(&planOnly, "plan", false, "print the change set and exit without modifying anything") + flags.BoolVar(&assumeYes, "yes", false, "apply the plan without the review screen") + flags.BoolVar(&noBackground, "no-background", false, "do not start Subrouter after login") + flags.BoolVar(&noConfig, "no-config", false, "do not configure Codex or Claude Code") if err := flags.Parse(args); err != nil { return err } + plan, err := planForSetup(ctx, store, setupPlanOptions{ + wantBackground: !noBackground, + wantConfig: !noConfig, + }) + if err != nil { + return err + } + if planOnly { + renderSetupPlan(plan, out) + return nil + } + if !assumeYes { + if in := setupStdin(); in != nil { + confirmed, reviewed := reviewSetupPlan(plan, in, out) + if !reviewed { + return nil + } + plan = confirmed + } else { + // No human is available to approve a change set, and silence is not + // consent. Print what would happen and change nothing. + renderSetupPlan(plan, out) + fmt.Fprintf(out, "\nnoninteractive terminal: re-run with --yes to apply\n") + return nil + } + } + if !plan.selected(actionBackground) { + skipInstall = true + } + controller, err := newServiceController() if err != nil { return err