From 61a079ed6f856657ee8410e118e9c8e258802729 Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Sun, 7 Jun 2026 21:14:33 +0200 Subject: [PATCH] feat(onboarding): add interactive walkthrough command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `grounds onboarding` (aliases: onboard, quickstart) — a guided, explain-then-do walkthrough that takes a new developer from zero to a running app in five steps: Login → Workspace (Bundle) → init → push → play. Each step explains the concept, then runs the real command after a Ja/Überspringen confirm. Reuses the existing subcommands (login/init/cluster/push) via cobra Execute() — no duplicated logic. The Workspace step provisions a Bundle by default (resolves the latest released bundle ref via ListBundleReleases, falls back to main). Built on huh (already a dep); bails cleanly when stdin is not a TTY. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/grounds/commands/onboarding/onboarding.go | 257 ++++++++++++++++++ .../commands/onboarding/onboarding_test.go | 38 +++ cmd/grounds/main.go | 2 + 3 files changed, 297 insertions(+) create mode 100644 cmd/grounds/commands/onboarding/onboarding.go create mode 100644 cmd/grounds/commands/onboarding/onboarding_test.go diff --git a/cmd/grounds/commands/onboarding/onboarding.go b/cmd/grounds/commands/onboarding/onboarding.go new file mode 100644 index 0000000..5aee54b --- /dev/null +++ b/cmd/grounds/commands/onboarding/onboarding.go @@ -0,0 +1,257 @@ +// Package onboarding provides `grounds onboarding`: an interactive, +// explain-then-do walkthrough that takes a new developer from zero to a +// running app. Each step first explains the concept, then (after a +// confirm) runs the real command — reusing the existing subcommands so +// there is no duplicated logic. +package onboarding + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/groundsgg/grounds-cli/cmd/grounds/commands" + "github.com/groundsgg/grounds-cli/cmd/grounds/commands/cluster" + "github.com/groundsgg/grounds-cli/cmd/grounds/commands/push" + "github.com/groundsgg/grounds-cli/internal/api" + "github.com/groundsgg/grounds-cli/internal/auth" + "github.com/groundsgg/grounds-cli/internal/config" + "github.com/groundsgg/grounds-cli/internal/render" +) + +const totalSteps = 5 + +// NewOnboardingCommand returns the `grounds onboarding` command. +func NewOnboardingCommand() *cobra.Command { + return &cobra.Command{ + Use: "onboarding", + Aliases: []string{"onboard", "quickstart"}, + Short: "Interaktiver Einstieg: erklärt den Grounds-Dev-Flow Schritt für Schritt", + Long: `Ein geführter Walkthrough für neue Entwickler. Jeder Schritt wird erst +erklärt und dann nach deiner Bestätigung ausgeführt: + + Login → Workspace (Bundle) → App anlegen → push → spielen + +Überspringen ist überall möglich; jederzeit erneut aufrufbar.`, + RunE: func(cmd *cobra.Command, _ []string) error { + return run(cmd) + }, + } +} + +func run(cmd *cobra.Command) error { + w := cmd.OutOrStdout() + ctx := context.Background() + + // huh needs an interactive terminal; fail early with a clear message + // instead of a cryptic prompt error when piped or in CI. + if !term.IsTerminal(int(os.Stdin.Fd())) { + return fmt.Errorf("onboarding braucht ein interaktives Terminal (TTY)") + } + + cfg, err := config.Load("") + if err != nil { + return err + } + + // ── Welcome ────────────────────────────────────────────────────── + fmt.Fprintln(w) + fmt.Fprintln(w, render.Bold("👋 Willkommen bei Grounds")) + fmt.Fprintln(w, "Dieser Assistent bringt dich Schritt für Schritt zu einer laufenden App") + fmt.Fprintln(w, "und erklärt unterwegs, was passiert. Jeder Schritt wird erst erklärt,") + fmt.Fprintln(w, "dann nach deiner Bestätigung ausgeführt — Überspringen ist immer ok.") + fmt.Fprintln(w) + fmt.Fprintln(w, " Weg: "+render.Bold("Login → Workspace → App anlegen → push → spielen")) + start, err := confirm("Los geht's?", "") + if err != nil { + return err + } + if !start { + return nil + } + + // ── 1/5 Login ──────────────────────────────────────────────────── + section(w, 1, "Login", + "Grounds nutzt deinen Account (OAuth Device-Flow): du bekommst einen Code", + "+ Link, bestätigst im Browser, fertig. Das Token wird lokal gespeichert", + "und automatisch erneuert.") + if loggedIn(cfg) { + render.StatusLine(w, render.StatusOK, "Login", "Du bist bereits eingeloggt") + } else { + do, err := confirm("Jetzt einloggen?", "Öffnet den Device-Flow im Browser.") + if err != nil { + return err + } + if !do { + render.DetailLine(w, render.StatusWarn, + "Ohne Login geht's nicht weiter — abgebrochen. Neustart mit "+render.Command("grounds onboarding")) + return nil + } + if err := runSub(commands.NewLoginCommand(), nil, w); err != nil { + return err + } + } + + // ── 2/5 Workspace (Bundle) ─────────────────────────────────────── + section(w, 2, "Dein Workspace", + "Ein Workspace ist dein isolierter Dev-Cluster — nur deiner. Wir ziehen ihn", + "als Bundle hoch: die komplette Platform-Umgebung (Velocity-Proxy, Player-,", + "Social-, NATS-Services …), damit dein Plugin sofort etwas zum Reden hat —", + "kein leeres Cluster. Einmalig ~2-3 min.") + ref := latestBundleRef(ctx, cfg) + do, err := confirm( + fmt.Sprintf("Bundle-Workspace »%s« jetzt hochziehen? (~2-3 min)", ref), + "Voll ausgestattete Platform-Umgebung für dein Plugin.") + if err != nil { + return err + } + if do { + if err := runSub(cluster.NewClusterCommand(), []string{"up", "--bundle", ref}, w); err != nil { + render.StatusLine(w, render.StatusWarn, "Workspace", "Provisioning hatte ein Problem (Details oben)") + cont, _ := confirm("Trotzdem mit dem Onboarding weitermachen?", "") + if !cont { + return nil + } + } + } + + // ── 3/5 App anlegen ────────────────────────────────────────────── + section(w, 3, "Eine App anlegen", + "grounds.yaml beschreibt deine App(s): Typ (Paper-Plugin, Velocity, Gamemode,", + "Service …), Base-Image und Flavors. grounds init legt sie interaktiv an.") + if hasGroundsYaml() { + render.StatusLine(w, render.StatusOK, "Scaffold", "grounds.yaml existiert bereits — übersprungen") + } else { + do, err := confirm("grounds.yaml jetzt anlegen?", "Interaktiver Scaffold (Typ, Base-Image, Flavor).") + if err != nil { + return err + } + if do { + if err := runSub(commands.NewInitCommand(), nil, w); err != nil { + render.DetailLine(w, render.StatusWarn, "init übersprungen: "+err.Error()) + } + } + } + + // ── 4/5 push ───────────────────────────────────────────────────── + section(w, 4, "Deployen mit push", + "grounds push baut deine App (via Gradle-Plugin) und deployt sie in deinen", + "Workspace — ein Kommando für Build + Deploy.") + if do2, err := confirm("Jetzt »grounds push«?", "Baut + deployt in deinen Workspace."); err != nil { + return err + } else if do2 { + if err := runSub(push.NewPushCommand(), nil, w); err != nil { + render.DetailLine(w, render.StatusWarn, "push hatte ein Problem — siehe oben") + } + } + + // ── 5/5 Spielen & beobachten ───────────────────────────────────── + section(w, 5, "Spielen & beobachten", + "Verbinde dich mit dem Velocity-Proxy deines Workspaces (Minecraft-Client)", + "und schau live zu. Mit --target=staging bekommst du ephemere Preview-Envs.") + render.DetailLine(w, render.StatusOK, render.Command("grounds cluster status")+" — Workspace-Status & Endpunkte") + render.DetailLine(w, render.StatusOK, render.Command("grounds logs")+" — Live-Logs deiner App") + render.DetailLine(w, render.StatusOK, render.Command("grounds push --target=staging")+" — ephemere Preview-Env (7d TTL)") + + // ── Done ───────────────────────────────────────────────────────── + fmt.Fprintln(w) + render.StatusLine(w, render.StatusOK, "Onboarding", "Geschafft 🎉") + render.DetailLine(w, render.StatusOK, "Jederzeit wiederholen mit "+render.Command("grounds onboarding")) + return nil +} + +// section prints a styled step header followed by the explanation lines. +func section(w io.Writer, n int, title string, body ...string) { + fmt.Fprintln(w) + fmt.Fprintln(w, render.Bold(fmt.Sprintf("▸ Schritt %d/%d — %s", n, totalSteps, title))) + for _, line := range body { + fmt.Fprintln(w, " "+line) + } + fmt.Fprintln(w) +} + +// confirm shows a Ja/Überspringen prompt and returns the choice. +func confirm(title, desc string) (bool, error) { + var ok bool + form := huh.NewForm(huh.NewGroup( + huh.NewConfirm(). + Title(title). + Description(desc). + Affirmative("Ja"). + Negative("Überspringen"). + Value(&ok), + )) + if err := form.Run(); err != nil { + return false, err + } + return ok, nil +} + +// runSub executes an existing subcommand as if invoked directly, wiring +// its output to w and swallowing cobra's usage/error printing so the +// onboarding stays in control of presentation. +func runSub(c *cobra.Command, args []string, w io.Writer) error { + c.SetArgs(args) + c.SetOut(w) + c.SetErr(w) + c.SilenceUsage = true + c.SilenceErrors = true + return c.Execute() +} + +// loggedIn reports whether a usable (refresh-alive) credential is stored. +func loggedIn(cfg *config.Config) bool { + creds, err := auth.NewStore(cfg.Dir).Load() + return err == nil && creds != nil && creds.IsRefreshAlive() +} + +// latestBundleRef resolves the bundle ref the onboarding provisions: the +// newest published release, falling back to "main" if the catalog can't +// be reached (so the walkthrough still works offline-ish). +func latestBundleRef(ctx context.Context, cfg *config.Config) string { + c := newClient(cfg) + if c == nil { + return "main" + } + releases, err := c.ListBundleReleases(ctx) + if err == nil { + for _, r := range releases { + if r.IsLatest { + return r.Version + } + } + if len(releases) > 0 { + return releases[0].Version + } + } + return "main" +} + +// newClient mirrors the per-command buildClient helper (cluster.go) — the +// CLI has no shared constructor yet, so each command builds its own. +func newClient(cfg *config.Config) *api.Client { + ts := api.NewEnvTokenSource() + if ts == nil { + ts = &auth.FileTokenSource{ + Store: auth.NewStore(cfg.Dir), + Device: &auth.DeviceClient{ + Issuer: "https://account.grounds.gg/realms/grounds", + ClientID: "grounds-cli", + HTTP: &http.Client{Timeout: 30 * time.Second}, + }, + } + } + return api.New(cfg.APIURL, ts) +} + +func hasGroundsYaml() bool { + _, err := os.Stat("grounds.yaml") + return err == nil +} diff --git a/cmd/grounds/commands/onboarding/onboarding_test.go b/cmd/grounds/commands/onboarding/onboarding_test.go new file mode 100644 index 0000000..31efafc --- /dev/null +++ b/cmd/grounds/commands/onboarding/onboarding_test.go @@ -0,0 +1,38 @@ +package onboarding + +import ( + "bytes" + "strings" + "testing" +) + +func TestNewOnboardingCommand_Metadata(t *testing.T) { + cmd := NewOnboardingCommand() + if cmd.Use != "onboarding" { + t.Fatalf("Use = %q, want onboarding", cmd.Use) + } + wantAliases := map[string]bool{"onboard": true, "quickstart": true} + for _, a := range cmd.Aliases { + delete(wantAliases, a) + } + if len(wantAliases) != 0 { + t.Fatalf("missing aliases: %v", wantAliases) + } +} + +// In a test process stdin is not a TTY, so the command must bail with a +// clear message instead of attempting an interactive prompt. +func TestOnboarding_NonTTYBailsCleanly(t *testing.T) { + cmd := NewOnboardingCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected an error in a non-TTY environment, got nil") + } + if !strings.Contains(err.Error(), "interaktives Terminal") { + t.Fatalf("error = %q, want it to mention an interactive terminal", err.Error()) + } +} diff --git a/cmd/grounds/main.go b/cmd/grounds/main.go index d7590ce..482a6e9 100644 --- a/cmd/grounds/main.go +++ b/cmd/grounds/main.go @@ -10,6 +10,7 @@ import ( "github.com/groundsgg/grounds-cli/cmd/grounds/commands/cluster" "github.com/groundsgg/grounds-cli/cmd/grounds/commands/devspace" "github.com/groundsgg/grounds-cli/cmd/grounds/commands/logs" + "github.com/groundsgg/grounds-cli/cmd/grounds/commands/onboarding" "github.com/groundsgg/grounds-cli/cmd/grounds/commands/preview" "github.com/groundsgg/grounds-cli/cmd/grounds/commands/push" "github.com/groundsgg/grounds-cli/cmd/grounds/commands/workspace" @@ -33,6 +34,7 @@ func main() { root.AddCommand(commands.NewLogoutCommand()) root.AddCommand(commands.NewDoctorCommand()) root.AddCommand(commands.NewInitCommand()) + root.AddCommand(onboarding.NewOnboardingCommand()) root.AddCommand(bundle.NewBundleCommand()) root.AddCommand(cluster.NewClusterCommand()) root.AddCommand(devspace.NewDevspaceCommand())