diff --git a/README.md b/README.md index c51eb27..4e0fb9b 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,19 @@ brew tap ArdurAI/tap brew trust --formula ArdurAI/tap/sith brew install sith sith version +sith launch ``` Homebrew 6 requires explicit trust for third-party taps. The formula-scoped command keeps the trust boundary narrower than trusting every current and future formula in `ArdurAI/tap`. Older Homebrew versions that do not implement tap trust can omit that line. +`sith launch` is the one-command graphical entry point: it opens the native desktop on macOS and +the loopback-only browser UI on other supported platforms. Use `--mode desktop` or `--mode ui` to +override that selection. A demo machine without a default kubeconfig can import an existing bounded +directory for the session with `sith launch --kubeconfig-dir /path/to/kubeconfigs`; Sith neither +persists the selected path nor deploys anything to those clusters. + Release archives are also available for `darwin/amd64`, `darwin/arm64`, `linux/amd64`, and `linux/arm64`. Every archive has a checksum, an SPDX SBOM, a keyless Sigstore bundle, SLSA build provenance, and a platform-specific SBOM attestation. Verify those materials before installing; @@ -40,6 +47,8 @@ Sith requires a supported Go 1.26 toolchain. ```bash make build +./bin/sith launch # native desktop on macOS; loopback UI elsewhere +./bin/sith launch --mode ui --kubeconfig-dir /path/to/kubeconfigs ./bin/sith # interactive terminal: cache-first fleet view ./bin/sith tui # explicit equivalent ./bin/sith version diff --git a/internal/cli/launch.go b/internal/cli/launch.go new file mode 100644 index 0000000..ab93408 --- /dev/null +++ b/internal/cli/launch.go @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "fmt" + "runtime" + + "github.com/spf13/cobra" + + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/localops" +) + +type launchMode string + +const ( + launchModeAuto launchMode = "auto" + launchModeDesktop launchMode = "desktop" + launchModeUI launchMode = "ui" +) + +type launchOptions struct { + mode string + ui uiOptions +} + +func newLaunchCommand(reader connector.Reader, local localops.Client) *cobra.Command { + options := &launchOptions{ + mode: string(launchModeAuto), + ui: uiOptions{address: "127.0.0.1"}, + } + command := &cobra.Command{ + Use: "launch", + Short: "Open the local fleet IDE", + Long: "Open the local fleet IDE using the native desktop on macOS and the loopback-only UI " + + "on other supported platforms. Listener and browser flags require --mode ui.", + Example: " sith launch\n" + + " sith launch --kubeconfig-dir /path/to/kubeconfigs\n" + + " sith launch --mode ui --no-open", + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + mode, err := resolveLaunchMode(options.mode, runtime.GOOS) + if err != nil { + return err + } + switch mode { + case launchModeDesktop: + if err := rejectDesktopUIFlags(command); err != nil { + return err + } + if err := validateDesktopDependencies(reader, local, options.ui.kubeconfigDir); err != nil { + return err + } + return runDesktop(command.Context(), reader, local, options.ui.kubeconfigDir) + case launchModeUI: + selectedReader, selectedLocal, err := selectUIBackend(reader, local, options.ui.kubeconfigDir) + if err != nil { + return err + } + return runWebUI(command.Context(), command, selectedReader, selectedLocal, &options.ui) + default: + return fmt.Errorf("launch mode %q is unsupported", mode) + } + }, + } + command.Flags().StringVar(&options.mode, "mode", options.mode, "launch mode: auto, desktop, or ui") + bindUIFlags(command, &options.ui) + return command +} + +func resolveLaunchMode(requested, goos string) (launchMode, error) { + switch launchMode(requested) { + case launchModeAuto: + if goos == "darwin" { + return launchModeDesktop, nil + } + return launchModeUI, nil + case launchModeDesktop: + if goos != "darwin" { + return "", fmt.Errorf("launch mode %q is available only on macOS", requested) + } + return launchModeDesktop, nil + case launchModeUI: + return launchModeUI, nil + default: + return "", fmt.Errorf("invalid launch mode %q: expected auto, desktop, or ui", requested) + } +} + +func rejectDesktopUIFlags(command *cobra.Command) error { + for _, name := range []string{"address", "port", "no-open"} { + if command.Flags().Changed(name) { + return fmt.Errorf("--%s requires --mode ui", name) + } + } + return nil +} diff --git a/internal/cli/launch_test.go b/internal/cli/launch_test.go new file mode 100644 index 0000000..e36a307 --- /dev/null +++ b/internal/cli/launch_test.go @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/ArdurAI/sith/internal/fleet" +) + +func TestResolveLaunchMode(t *testing.T) { + t.Parallel() + tests := []struct { + name string + requested string + goos string + want launchMode + wantError bool + }{ + {name: "macOS auto", requested: "auto", goos: "darwin", want: launchModeDesktop}, + {name: "Linux auto", requested: "auto", goos: "linux", want: launchModeUI}, + {name: "Windows auto", requested: "auto", goos: "windows", want: launchModeUI}, + {name: "macOS desktop", requested: "desktop", goos: "darwin", want: launchModeDesktop}, + {name: "Linux desktop", requested: "desktop", goos: "linux", wantError: true}, + {name: "explicit UI", requested: "ui", goos: "darwin", want: launchModeUI}, + {name: "unknown", requested: "browser", goos: "darwin", wantError: true}, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + got, err := resolveLaunchMode(test.requested, test.goos) + if test.wantError { + if err == nil { + t.Fatalf("resolveLaunchMode(%q, %q) = %q, want error", test.requested, test.goos, got) + } + return + } + if err != nil || got != test.want { + t.Fatalf("resolveLaunchMode(%q, %q) = %q, %v, want %q", test.requested, test.goos, got, err, test.want) + } + }) + } +} + +func TestLaunchCommandIsRegistered(t *testing.T) { + stdout, stderr, exitCode := runCLI(t, []string{"--help"}, fleet.StubSource{}) + if exitCode != 0 || stderr != "" || !strings.Contains(stdout, "launch") || + !strings.Contains(stdout, "Open the local fleet IDE") { + t.Fatalf("root help exit/stdout/stderr = %d/%q/%q", exitCode, stdout, stderr) + } +} + +func TestLaunchRejectsUnknownModeBeforeBackendSelection(t *testing.T) { + stdout, stderr, exitCode := runCLI(t, []string{"launch", "--mode", "browser"}, fleet.StubSource{}) + if exitCode == 0 || stdout != "" || !strings.Contains(stderr, "invalid launch mode") { + t.Fatalf("launch exit/stdout/stderr = %d/%q/%q", exitCode, stdout, stderr) + } +} + +func TestLaunchUIRequiresLocalBackend(t *testing.T) { + stdout, stderr, exitCode := runCLI(t, []string{"launch", "--mode", "ui", "--no-open"}, fleet.StubSource{}) + if exitCode == 0 || stdout != "" || !strings.Contains(stderr, "requires a Kubernetes reader") { + t.Fatalf("launch exit/stdout/stderr = %d/%q/%q", exitCode, stdout, stderr) + } +} + +func TestLaunchUIStartsOnLoopback(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + stdout, stderr, exitCode := runUICLI(ctx, t, []string{ + "launch", "--mode", "ui", "--port", "0", "--no-open", + }, &cacheReader{}, &fakeLocalClient{}) + if exitCode != 0 || stderr != "" || !strings.Contains(stdout, "sith ui listening on http://127.0.0.1:") { + t.Fatalf("launch exit/stdout/stderr = %d/%q/%q", exitCode, stdout, stderr) + } +} + +func TestSelectUIBackendAcceptsExplicitDirectoryWithoutDefaultBackend(t *testing.T) { + directory := writeLaunchKubeconfig(t) + reader, local, err := selectUIBackend(nil, nil, directory) + if err != nil || reader == nil || local == nil { + t.Fatalf("selectUIBackend() = %#v, %#v, %v, want explicit directory backend", reader, local, err) + } +} + +func TestLaunchUIStartsFromExplicitDirectoryWithoutDefaultBackend(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("SITH_KUBECONFIG", "") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + var stdout, stderr bytes.Buffer + exitCode := executeBackendContext(ctx, []string{ + "launch", "--mode", "ui", "--port", "0", "--no-open", + "--kubeconfig-dir", writeLaunchKubeconfig(t), + }, backend{source: fleet.StubSource{}}, &stdout, &stderr) + if exitCode != 0 || stderr.Len() != 0 || + !strings.Contains(stdout.String(), "sith ui listening on http://127.0.0.1:") { + t.Fatalf("launch exit/stdout/stderr = %d/%q/%q", exitCode, stdout.String(), stderr.String()) + } +} + +func writeLaunchKubeconfig(t *testing.T) string { + t.Helper() + directory := t.TempDir() + config := []byte(`apiVersion: v1 +kind: Config +clusters: + - name: demo + cluster: + server: https://demo.invalid +users: + - name: demo + user: {} +contexts: + - name: demo + context: + cluster: demo + user: demo +current-context: demo +`) + if err := os.WriteFile(filepath.Join(directory, "config.yaml"), config, 0o600); err != nil { + t.Fatal(err) + } + return directory +} + +func TestDesktopLaunchRejectsUIOnlyFlags(t *testing.T) { + command := &cobra.Command{Use: "launch"} + options := &uiOptions{address: "127.0.0.1"} + bindUIFlags(command, options) + if err := command.ParseFlags([]string{"--no-open"}); err != nil { + t.Fatal(err) + } + if err := rejectDesktopUIFlags(command); err == nil || !strings.Contains(err.Error(), "--mode ui") { + t.Fatalf("rejectDesktopUIFlags() error = %v, want explicit UI-mode guidance", err) + } +} + +func TestDesktopLaunchAcceptsSharedKubeconfigDirectoryFlag(t *testing.T) { + command := &cobra.Command{Use: "launch"} + options := &uiOptions{address: "127.0.0.1"} + bindUIFlags(command, options) + if err := command.ParseFlags([]string{"--kubeconfig-dir", t.TempDir()}); err != nil { + t.Fatal(err) + } + if err := rejectDesktopUIFlags(command); err != nil { + t.Fatalf("rejectDesktopUIFlags() error = %v, want shared directory flag accepted", err) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index f85bd9c..ef83c7d 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -153,6 +153,7 @@ func newRootCommand(runtime backend, stdout, stderr io.Writer) *cobra.Command { newClustersCommand(options, runtime.source), newUICommand(runtime.reader, runtime.local), newDesktopCommand(runtime.reader, runtime.local), + newLaunchCommand(runtime.reader, runtime.local), newHubCommand(), } if runtime.reader != nil { diff --git a/internal/cli/ui.go b/internal/cli/ui.go index edfcabb..ec5dcd7 100644 --- a/internal/cli/ui.go +++ b/internal/cli/ui.go @@ -37,24 +37,40 @@ func newUICommand(reader connector.Reader, local localops.Client) *cobra.Command Short: "Start the loopback-only local fleet IDE", Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { - if reader == nil || local == nil { - return fmt.Errorf("local fleet UI requires a Kubernetes reader and local operations client") + selectedReader, selectedLocal, err := selectUIBackend(reader, local, options.kubeconfigDir) + if err != nil { + return err } - if options.kubeconfigDir != "" { - adapter, err := kubeconfig.New(kubeconfig.WithDirectory(options.kubeconfigDir)) - if err != nil { - return fmt.Errorf("import kubeconfig directory: %w", err) - } - reader, local = adapter, adapter - } - return runWebUI(command.Context(), command, reader, local, options) + return runWebUI(command.Context(), command, selectedReader, selectedLocal, options) }, } + bindUIFlags(command, options) + return command +} + +func bindUIFlags(command *cobra.Command, options *uiOptions) { command.Flags().StringVar(&options.address, "address", options.address, "loopback listen address") command.Flags().IntVar(&options.port, "port", 0, "loopback listen port; 0 selects an available port") command.Flags().BoolVar(&options.noOpen, "no-open", false, "do not open the system browser") command.Flags().StringVar(&options.kubeconfigDir, "kubeconfig-dir", "", "import kubeconfig files from this directory for this local UI session") - return command +} + +func selectUIBackend( + reader connector.Reader, + local localops.Client, + kubeconfigDirectory string, +) (connector.Reader, localops.Client, error) { + if kubeconfigDirectory != "" { + adapter, err := kubeconfig.New(kubeconfig.WithDirectory(kubeconfigDirectory)) + if err != nil { + return nil, nil, fmt.Errorf("import kubeconfig directory: %w", err) + } + return adapter, adapter, nil + } + if reader == nil || local == nil { + return nil, nil, fmt.Errorf("local fleet UI requires a Kubernetes reader and local operations client") + } + return reader, local, nil } func runWebUI( diff --git a/sessions/2026-07-23-demo-launch.md b/sessions/2026-07-23-demo-launch.md new file mode 100644 index 0000000..6e0edd9 --- /dev/null +++ b/sessions/2026-07-23-demo-launch.md @@ -0,0 +1,18 @@ +# Session — 2026-07-23 — demo-launch + +**Builder:** Gnani Rahul Nutakki · **Model/effort:** Codex, high · **Branch:** gnanirahulnutakki/feat/demo-launch +**Slice(s):** Phase-L demo readiness · #315 · **Status:** done + +--- + +[G] Goal: Make the completed local fleet client discoverable through one safe graphical launch command for issue #315. +[S] Scope: `internal/cli`, README first-run guidance, command and launch smoke tests, and this journal. Out of scope: demo fixture deployment, Hub release policy, GHCR visibility, governed writes, and cluster mutation. +[A] Action: Reconciled live `dev`, releases, roadmap/build-sequence demo criteria, existing command surfaces, duplicate issues, and release blockers; selected orchestration over the existing desktop and loopback UI surfaces. +[A] Action: Added `sith launch` with closed `auto|desktop|ui` selection, explicit UI-only flag validation, shared bounded directory import, root registration, and first-run documentation. Refactored the existing UI command to accept an explicit directory without requiring a default kubeconfig backend. +[T] Test: Focused CLI race tests and twenty repeated package runs passed. The built binary launched the loopback UI from the provided self-managed test kubeconfig directory, served HTTP 200, and shut down cleanly without creating a cluster workload. +[T] Test: `make ci`, `make e2e-isolation`, both 50,000-case tenancy fuzzers, Helm and multi-architecture OCI contracts, the real two-cluster Kind suite, `go mod tidy -diff`, `go mod verify`, and the double-build `make release-check` all passed; Kind cleanup left no clusters. +[C] Checkpoint #1: this commit — one-command local demo launcher and complete local verification; next: publish PR into `dev`, require green PR and exact post-merge checks, then resume the broader demo-readiness gap audit. + +--- + +**Session close:** implementation and local verification complete · **Open questions touched:** Q12 — preserve the existing graphical surfaces and add an auto-selecting entry point rather than creating a new UI