-
Notifications
You must be signed in to change notification settings - Fork 0
feat: implement first-run setup wizard #584
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
188fada
a0ece24
dfa8e30
d1309ec
f0d2e8f
37604cf
90032ad
1862130
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| package cmd | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "io" | ||
| "net/http" | ||
| "net/url" | ||
| "os" | ||
| "os/exec" | ||
| "path/filepath" | ||
| "runtime" | ||
| "time" | ||
|
|
||
| "github.com/Aureliolo/synthorg/cli/internal/config" | ||
| "github.com/Aureliolo/synthorg/cli/internal/docker" | ||
| "github.com/Aureliolo/synthorg/cli/internal/ui" | ||
| "github.com/spf13/cobra" | ||
| ) | ||
|
|
||
| // setupClient is the shared HTTP client for setup API requests. | ||
| // Per-request timeouts are controlled via context.WithTimeout. | ||
| var setupClient = &http.Client{ | ||
| CheckRedirect: func(_ *http.Request, _ []*http.Request) error { | ||
| return http.ErrUseLastResponse | ||
| }, | ||
| } | ||
|
|
||
| var setupCmd = &cobra.Command{ | ||
| Use: "setup", | ||
| Short: "Re-open the first-run setup wizard", | ||
| Long: `Reset the setup_complete flag and open the setup wizard in the browser. | ||
|
|
||
| This is useful when you want to re-configure providers, company settings, | ||
| or add agents through the guided setup flow. Requires the SynthOrg stack | ||
| to be running ('synthorg start').`, | ||
| RunE: runSetup, | ||
| } | ||
|
|
||
| func init() { | ||
| rootCmd.AddCommand(setupCmd) | ||
| } | ||
|
|
||
| func runSetup(cmd *cobra.Command, _ []string) error { | ||
| ctx := cmd.Context() | ||
| dir := resolveDataDir() | ||
|
|
||
| state, err := config.Load(dir) | ||
| if err != nil { | ||
| return fmt.Errorf("loading config: %w", err) | ||
| } | ||
|
|
||
| safeDir, err := safeStateDir(state) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| composePath := filepath.Join(safeDir, "compose.yml") | ||
| if _, err := os.Stat(composePath); errors.Is(err, os.ErrNotExist) { | ||
|
|
||
| return fmt.Errorf("compose.yml not found in %s -- run 'synthorg init' first", safeDir) | ||
| } | ||
|
|
||
| out := ui.NewUI(cmd.OutOrStdout()) | ||
| errOut := ui.NewUI(cmd.ErrOrStderr()) | ||
|
|
||
| // Verify Docker is available and containers are running. | ||
| info, err := docker.Detect(ctx) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| psOut, err := docker.ComposeExecOutput(ctx, info, safeDir, "ps", "--format", "json") | ||
| if err != nil || psOut == "" || psOut == "[]" || psOut == "[]\n" { | ||
| return fmt.Errorf("no containers running -- run 'synthorg start' first") | ||
| } | ||
|
Comment on lines
+72
to
+75
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, let's look at the file and the specific lines in question
cat -n cli/cmd/setup.go | head -80Repository: Aureliolo/synthorg Length of output: 2768 🏁 Script executed: # Find the docker package definition
fd -t f "\.go$" | xargs grep -l "func.*ComposeExecOutput" | head -5Repository: Aureliolo/synthorg Length of output: 126 🏁 Script executed: # Check if there are any tests or documentation about Docker output
rg "docker.*ps.*format.*json" -A 3 -B 3Repository: Aureliolo/synthorg Length of output: 1883 🏁 Script executed: # Let's look at the docker.ComposeExecOutput function
cat -n cli/internal/docker/client.go | head -150Repository: Aureliolo/synthorg Length of output: 6252 🏁 Script executed: # Check the parseContainerDetails function to understand JSON parsing
cat -n cli/internal/diagnostics/collect.go | grep -A 30 "func parseContainerDetails"Repository: Aureliolo/synthorg Length of output: 1053 🏁 Script executed: # Let's also look at the test file to understand expected behavior
cat -n cli/internal/docker/client_test.goRepository: Aureliolo/synthorg Length of output: 5864 Parse the JSON output instead of checking for an empty string. When 🤖 Prompt for AI Agents |
||
|
|
||
| // Reset the setup_complete flag via the settings API. | ||
| out.Step("Resetting setup flag...") | ||
| if err := resetSetupFlag(ctx, state); err != nil { | ||
| return fmt.Errorf("resetting setup flag: %w", err) | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| out.Success("Setup flag reset") | ||
|
|
||
| // Open browser to the setup page. | ||
| setupURL := fmt.Sprintf("http://localhost:%d/setup", state.WebPort) | ||
| out.Step(fmt.Sprintf("Opening %s", setupURL)) | ||
| if err := openBrowser(ctx, setupURL); err != nil { | ||
| errOut.Warn(fmt.Sprintf("Could not open browser: %v", err)) | ||
| errOut.Hint(fmt.Sprintf("Open %s manually in your browser.", setupURL)) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| // resetSetupFlag calls DELETE /api/v1/settings/api/setup_complete to reset | ||
| // the first-run flag so the setup wizard re-appears. | ||
| func resetSetupFlag(ctx context.Context, state config.State) error { | ||
| apiURL := fmt.Sprintf("http://localhost:%d/api/v1/settings/api/setup_complete", state.BackendPort) | ||
|
|
||
| ctx, cancel := context.WithTimeout(ctx, 10*time.Second) | ||
| defer cancel() | ||
|
|
||
| req, err := http.NewRequestWithContext(ctx, http.MethodDelete, apiURL, nil) | ||
| if err != nil { | ||
| return fmt.Errorf("creating request: %w", err) | ||
| } | ||
| req.Header.Set("Authorization", "Bearer "+buildLocalJWT(state.JWTSecret)) | ||
|
|
||
| resp, err := setupClient.Do(req) | ||
| if err != nil { | ||
| return fmt.Errorf("request failed: %w", err) | ||
| } | ||
| defer func() { | ||
| _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64*1024)) | ||
| _ = resp.Body.Close() | ||
| }() | ||
|
|
||
| if resp.StatusCode >= 400 { | ||
| return fmt.Errorf("API returned status %d", resp.StatusCode) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // openBrowser opens a URL in the default browser. Only localhost HTTP(S) | ||
| // URLs are permitted to prevent arbitrary command execution. | ||
| func openBrowser(ctx context.Context, rawURL string) error { | ||
| parsed, err := url.Parse(rawURL) | ||
| if err != nil { | ||
| return fmt.Errorf("invalid URL %q: %w", rawURL, err) | ||
| } | ||
| if parsed.Scheme != "http" && parsed.Scheme != "https" { | ||
| return fmt.Errorf("refusing to open URL with scheme %q -- only http and https are allowed", parsed.Scheme) | ||
| } | ||
| host := parsed.Hostname() | ||
| if host != "localhost" && host != "127.0.0.1" { | ||
| return fmt.Errorf("refusing to open URL with host %q -- only localhost and 127.0.0.1 are allowed", host) | ||
| } | ||
|
|
||
| var cmd *exec.Cmd | ||
| switch runtime.GOOS { | ||
| case "windows": | ||
| cmd = exec.CommandContext(ctx, "rundll32", "url.dll,FileProtocolHandler", rawURL) | ||
| case "darwin": | ||
| cmd = exec.CommandContext(ctx, "open", rawURL) | ||
| default: | ||
| cmd = exec.CommandContext(ctx, "xdg-open", rawURL) | ||
| } | ||
| if err := cmd.Start(); err != nil { | ||
| return fmt.Errorf("starting browser: %w", err) | ||
| } | ||
| go func() { _ = cmd.Wait() }() // reap child, prevent zombie | ||
| return nil | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.