package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "regexp" "runtime" "strconv" "strings" "time" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/glamour" "github.com/charmbracelet/lipgloss" "github.com/muesli/termenv" ) type question struct { key string title string prompt string def string secret bool example string options []string } var questions = []question{ {"PLATFORM_ACCOUNT", "Autonomy Studio account", "Enter the email address used in Autonomy Studio.", "", false, "name@example.com", nil}, {"PLATFORM_REGISTRATION_CODE", "Registration code", "Enter the 6-digit code created in Launcher Platform management.", "", false, "000000", nil}, {"USAGE_TYPE", "Usage type", "Select how this device will be used.", "개인용", false, "", []string{"개인용", "교보재용"}}, {"DEVICE_HOSTNAME", "Device hostname", "Hostname to register this device with. Press Enter to keep the detected hostname.", "", false, "", nil}, {"WORKSPACE_ROOT", "Workspace", "Which directory should the platform expose as the workspace?", "~/autonomy-studio/workspaces", false, "", nil}, {"CAMERA_VIDEO_DEVICE", "Video device", "Which V4L2 camera device should be used?", "/dev/video0", false, "/dev/video0", nil}, } type model struct { input textinput.Model envPath string index int values map[string]string choices map[string]int provision map[string]string provisionSummary string showProvision bool loading bool err error done bool width int height int } type provisionResultMsg struct { values map[string]string summary string err error } var ( accent = lipgloss.Color("81") accentStrong = lipgloss.Color("87") muted = lipgloss.Color("245") panelBorder = lipgloss.Color("240") panelStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(panelBorder).Padding(1, 2) brandStyle = lipgloss.NewStyle().Bold(true).Foreground(accentStrong) titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")) promptStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")) valueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")) mutedStyle = lipgloss.NewStyle().Foreground(muted) errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) okStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("42")) inputStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(accent).Padding(0, 1) keyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) selectedStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("24")).Padding(0, 1) optionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("250")).Padding(0, 1) ) func init() { lipgloss.SetColorProfile(termenv.ANSI256) } func envDefault(key, fallback string) string { if value := os.Getenv(key); value != "" { return value } return fallback } func initialModel(envPath string) model { input := textinput.New() input.Prompt = "" input.CharLimit = 512 input.Width = 56 input.Focus() values := map[string]string{} m := model{input: input, envPath: envPath, values: values, choices: map[string]int{}, provision: map[string]string{}, width: 86, height: 24} m.loadQuestion() return m } func (m *model) loadQuestion() { q := questions[m.index] value := envDefault(q.key, questionDefault(q)) if existing, ok := m.values[q.key]; ok { value = existing } if len(q.options) > 0 { m.choices[q.key] = optionIndex(q.options, value) m.input.SetValue("") return } if _, ok := m.values[q.key]; ok { m.input.SetValue(value) } else if q.key == "DEVICE_HOSTNAME" || q.key == "WORKSPACE_ROOT" { m.input.SetValue(value) } else { m.input.SetValue("") } m.input.Placeholder = q.example m.input.EchoMode = textinput.EchoNormal if q.secret { m.input.EchoMode = textinput.EchoPassword m.input.EchoCharacter = '*' } } func questionDefault(q question) string { if q.key == "DEVICE_HOSTNAME" { hostname, _ := os.Hostname() if strings.TrimSpace(hostname) != "" { return strings.TrimSpace(hostname) } } if q.key == "WORKSPACE_ROOT" { return envDefault("WORKSPACE_ROOT", "~/autonomy-studio/workspaces") } return q.def } func questionExample(q question) string { return q.example } func (m model) Init() tea.Cmd { return textinput.Blink } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case provisionResultMsg: m.loading = false if msg.err != nil { m.err = msg.err return m, nil } m.err = nil m.provision = msg.values m.provisionSummary = msg.summary m.showProvision = true return m, nil case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height m.input.Width = max(28, min(64, msg.Width-24)) case tea.KeyMsg: if isTerminalColorResponse(msg.String()) { return m, nil } if m.loading { switch msg.String() { case "ctrl+c", "esc": return m, tea.Quit } return m, nil } if m.showProvision { switch msg.String() { case "ctrl+c", "esc": return m, tea.Quit case "enter": m.showProvision = false m.index++ m.loadQuestion() return m, nil case "left": m.showProvision = false m.index = 2 m.loadQuestion() return m, nil } return m, nil } switch msg.String() { case "ctrl+c", "esc": return m, tea.Quit case "enter": q := questions[m.index] value := m.currentValue() if err := m.validate(q, value); err != nil { m.err = err break } m.err = nil m.values[q.key] = value if q.key == "WORKSPACE_ROOT" { account := strings.TrimSpace(m.values["PLATFORM_ACCOUNT"]) registrationCode := strings.TrimSpace(m.values["PLATFORM_REGISTRATION_CODE"]) usageType := strings.TrimSpace(m.values["USAGE_TYPE"]) deviceHostname := strings.TrimSpace(m.values["DEVICE_HOSTNAME"]) workspaceRoot := strings.TrimSpace(value) m.loading = true return m, provisionWithRegistrationCode(account, registrationCode, usageType, deviceHostname, workspaceRoot, m.width) } if m.index == len(questions)-1 { m.err = m.writeEnv() m.done = m.err == nil return m, tea.Quit } m.index++ m.loadQuestion() case "backspace", "ctrl+h", "ctrl+?": m.err = nil case "up": if len(questions[m.index].options) > 0 { m.prevChoice() } case "down": if len(questions[m.index].options) > 0 { m.nextChoice() } case "left": if m.index > 0 { m.values[questions[m.index].key] = m.currentValue() m.index-- m.loadQuestion() m.err = nil return m, nil } } } if len(questions[m.index].options) > 0 { return m, nil } var cmd tea.Cmd m.input, cmd = m.input.Update(msg) m.input.SetValue(sanitizeInput(m.input.Value())) return m, cmd } func (m model) currentValue() string { q := questions[m.index] if len(q.options) > 0 { index := m.choices[q.key] if index < 0 || index >= len(q.options) { index = optionIndex(q.options, questionDefault(q)) } return q.options[index] } value := strings.TrimSpace(sanitizeInput(m.input.Value())) if value == "" { return questionDefault(q) } return value } func (m *model) nextChoice() { q := questions[m.index] if len(q.options) == 0 { return } m.choices[q.key] = (m.choices[q.key] + 1) % len(q.options) m.err = nil } func (m *model) prevChoice() { q := questions[m.index] if len(q.options) == 0 { return } next := m.choices[q.key] - 1 if next < 0 { next = len(q.options) - 1 } m.choices[q.key] = next m.err = nil } func optionIndex(options []string, value string) int { for i, option := range options { if strings.EqualFold(option, value) { return i } } return 0 } func (m model) validate(q question, value string) error { if q.key == "PLATFORM_ACCOUNT" && !strings.Contains(value, "@") { return fmt.Errorf("Enter the account email address.") } if q.key == "PLATFORM_REGISTRATION_CODE" && !regexp.MustCompile(`^\d{6}$`).MatchString(strings.TrimSpace(value)) { return fmt.Errorf("Enter the 6-digit registration code.") } return nil } func isTerminalColorResponse(value string) bool { return strings.Contains(value, "rgb:") || strings.HasPrefix(value, "]10;") || strings.HasPrefix(value, "]11;") } func sanitizeInput(value string) string { value = stripAfter(value, "\x1b]10;") value = stripAfter(value, "\x1b]11;") value = stripAfter(value, "]10;") value = stripAfter(value, "]11;") value = stripAfter(value, "rgb:") if value == "]" || value == "\\" || value == "\x1b\\" { return "" } return strings.ReplaceAll(value, "\x1b\\", "") } func stripAfter(value string, marker string) string { if index := strings.Index(value, marker); index >= 0 { return value[:index] } return value } func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" } func provisionDevice(account string, password string, usageType string, deviceHostname string, workspaceRoot string, width int) tea.Cmd { return func() tea.Msg { values, summary, err := fetchProvision(account, password, usageType, deviceHostname, workspaceRoot, width) return provisionResultMsg{values: values, summary: summary, err: err} } } func buildProvisionDevice(deviceHostname string, workspaceRoot string, usageType string) map[string]string { device := detectDevice() if strings.TrimSpace(deviceHostname) != "" { device["hostname"] = strings.TrimSpace(deviceHostname) } sshUser := envDefault("PLATFORM_AGENT_USER", envDefault("USER", "")) if sshUser != "" { device["sshUser"] = sshUser } device["workspaceRoot"] = strings.TrimSpace(workspaceRoot) device["usageType"] = normalizeUsageType(usageType) return device } func provisionWithRegistrationCode(account string, registrationCode string, usageType string, deviceHostname string, workspaceRoot string, width int) tea.Cmd { return func() tea.Msg { account = strings.TrimSpace(account) registrationCode = strings.TrimSpace(registrationCode) usageType = normalizeUsageType(usageType) if strings.TrimSpace(workspaceRoot) == "" { workspaceRoot = "~/autonomy-studio/workspaces" } if account == "" || registrationCode == "" { return provisionResultMsg{err: fmt.Errorf("Autonomy Studio account and registration code are required.")} } baseURL := strings.TrimRight(strings.TrimSpace(envDefault("PLATFORM_PROVISION_URL", "https://portal-api.autonomystudio.net/api/provision")), "/") if baseURL == "" { return provisionResultMsg{err: fmt.Errorf("PLATFORM_PROVISION_URL is required for device registration.")} } device := buildProvisionDevice(deviceHostname, workspaceRoot, usageType) payload := map[string]any{ "account": account, "userCode": registrationCode, "hostname": device["hostname"], "os": device["os"], "arch": device["arch"], "kernel": device["kernel"], "distro": device["distro"], "usageType": usageType, "sshUser": device["sshUser"], "workspaceRoot": workspaceRoot, "device": device, } body, status, err := postProvision(baseURL+"/device-authorizations/consume", payload) if err != nil { return provisionResultMsg{err: fmt.Errorf("Device registration failed: %w", err)} } if status < 200 || status >= 300 { return provisionResultMsg{err: fmt.Errorf("Device registration failed: HTTP %d: %s", status, strings.TrimSpace(string(body)))} } values, err := parseProvisionValues(body) if err != nil { return provisionResultMsg{err: err} } return provisionResultMsg{values: values, summary: renderProvisionSummary(values, device, usageType, "Registered", width)} } } func fetchProvision(account string, password string, usageType string, deviceHostname string, workspaceRoot string, width int) (map[string]string, string, error) { account = strings.TrimSpace(account) password = strings.TrimSpace(password) usageType = normalizeUsageType(usageType) deviceHostname = strings.TrimSpace(deviceHostname) workspaceRoot = strings.TrimSpace(workspaceRoot) if workspaceRoot == "" { workspaceRoot = "~/autonomy-studio/workspaces" } if account == "" || password == "" { return nil, "", fmt.Errorf("Autonomy Studio account and password are required.") } url := strings.TrimSpace(envDefault("PLATFORM_PROVISION_URL", "https://portal-api.autonomystudio.net/api/provision")) if url == "" { return nil, "", fmt.Errorf("PLATFORM_PROVISION_URL is required for account provisioning.") } device := detectDevice() if deviceHostname != "" { device["hostname"] = deviceHostname } sshUser := envDefault("PLATFORM_AGENT_USER", envDefault("USER", "")) if sshUser != "" { device["sshUser"] = sshUser } device["workspaceRoot"] = workspaceRoot payload := map[string]any{ "account": account, "password": password, "hostname": device["hostname"], "os": device["os"], "arch": device["arch"], "kernel": device["kernel"], "distro": device["distro"], "device": device, "usageType": usageType, "sshUser": sshUser, "workspaceRoot": workspaceRoot, } device["usageType"] = usageType respBody, status, err := postProvision(url, payload) if err != nil { return nil, "", fmt.Errorf("Provision request failed: %w", err) } if status == http.StatusBadRequest { legacyPayload := map[string]any{ "account": account, "password": password, "hostname": device["hostname"], "usageType": usageType, "sshUser": sshUser, "workspaceRoot": workspaceRoot, } respBody, status, err = postProvision(url, legacyPayload) if err != nil { return nil, "", fmt.Errorf("Provision retry failed: %w", err) } } if status < 200 || status >= 300 { detail := strings.TrimSpace(string(respBody)) if detail != "" { return nil, "", fmt.Errorf("Provision request failed: HTTP %d: %s", status, detail) } return nil, "", fmt.Errorf("Provision request failed: HTTP %d", status) } values, err := parseProvisionValues(respBody) if err != nil { return nil, "", err } return values, renderProvisionSummary(values, device, usageType, "Received", width), nil } func parseProvisionValues(respBody []byte) (map[string]string, error) { var data map[string]any if err := json.Unmarshal(respBody, &data); err != nil { return nil, fmt.Errorf("Provision response must be JSON.") } values := map[string]string{} setProvision(values, "INSTALL_TAILSCALE", firstBool(data, "installTailscale", "install_tailscale", "tailscaleEnabled", "tailscale_enabled")) setProvision(values, "TAILSCALE_AUTH_KEY", firstString(data, "tailscaleAuthKey", "tailscale_auth_key", "authKey", "auth_key")) setProvision(values, "TAILSCALE_LOGIN_SERVER", firstString(data, "tailscaleLoginServer", "tailscale_login_server", "loginServer", "login_server")) setProvision(values, "TAILSCALE_HOSTNAME", firstString(data, "tailscaleHostname", "tailscale_hostname", "hostname")) setProvision(values, "TAILSCALE_EXTRA_ARGS", firstString(data, "tailscaleExtraArgs", "tailscale_extra_args", "extraArgs", "extra_args")) setProvision(values, "AGENT_GATEWAY_TOKEN", firstString(data, "agentGatewayToken", "agent_gateway_token", "gatewayToken", "gateway_token")) if values["INSTALL_TAILSCALE"] == "" && values["TAILSCALE_AUTH_KEY"] != "" { values["INSTALL_TAILSCALE"] = "true" } return values, nil } func normalizeUsageType(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "교보재용", "use for class", "class", "education", "educational", "training", "classroom": return "class" default: return "personal" } } func postProvision(url string, payload map[string]any) ([]byte, int, error) { body, err := json.Marshal(payload) if err != nil { return nil, 0, err } req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) if err != nil { return nil, 0, err } req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 20 * time.Second} resp, err := client.Do(req) if err != nil { return nil, 0, err } defer resp.Body.Close() respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) return respBody, resp.StatusCode, nil } func detectDevice() map[string]string { hostname, _ := os.Hostname() device := map[string]string{ "hostname": hostname, "os": runtime.GOOS, "arch": runtime.GOARCH, "kernel": strings.TrimSpace(runOutput("uname", "-r")), "distro": osReleasePrettyName(), } if device["kernel"] == "" { device["kernel"] = "-" } if device["distro"] == "" { device["distro"] = runtime.GOOS } return device } func runOutput(name string, args ...string) string { output, err := exec.Command(name, args...).Output() if err != nil { return "" } return string(output) } func osReleasePrettyName() string { content, err := os.ReadFile("/etc/os-release") if err != nil { return "" } for _, line := range strings.Split(string(content), "\n") { key, value, ok := strings.Cut(line, "=") if !ok || key != "PRETTY_NAME" { continue } value = strings.Trim(value, `"`) return strings.TrimSpace(value) } return "" } func setProvision(values map[string]string, key string, value string) { if strings.TrimSpace(value) != "" { values[key] = strings.TrimSpace(value) } } func firstString(data map[string]any, keys ...string) string { for _, key := range keys { if value, ok := data[key]; ok { switch v := value.(type) { case string: return v case float64: return strconv.FormatFloat(v, 'f', -1, 64) case bool: if v { return "true" } return "false" } } } return "" } func firstBool(data map[string]any, keys ...string) string { for _, key := range keys { if value, ok := data[key]; ok { switch v := value.(type) { case bool: if v { return "true" } return "false" case string: switch strings.ToLower(strings.TrimSpace(v)) { case "true", "1", "yes", "y", "on": return "true" case "false", "0", "no", "n", "off": return "false" } } } } return "" } func renderProvisionSummary(values map[string]string, device map[string]string, usageType string, status string, width int) string { authKeyStatus := "not provided" if values["TAILSCALE_AUTH_KEY"] != "" { authKeyStatus = "received" } loginServer := displayValue(valueOrDash(values["TAILSCALE_LOGIN_SERVER"])) hostname := valueOrDash(values["TAILSCALE_HOSTNAME"]) extraArgs := valueOrDash(values["TAILSCALE_EXTRA_ARGS"]) gatewayTokenStatus := "not provided" if values["AGENT_GATEWAY_TOKEN"] != "" { gatewayTokenStatus = "received" } installTailscale := valueOr(values["INSTALL_TAILSCALE"], "false") header := renderMarkdown(fmt.Sprintf(`# Provisioning Summary Status: **%s** ## Network `, status), width) rows := [][2]string{ {"Usage type", normalizeUsageType(usageType)}, {"Device hostname", valueOrDash(device["hostname"])}, {"Device OS", valueOrDash(device["distro"])}, {"Architecture", valueOrDash(device["arch"])}, {"Kernel", valueOrDash(device["kernel"])}, {"Install Tailscale", installTailscale}, {"Tailscale auth key", authKeyStatus}, {"Login server", loginServer}, {"Tailscale hostname", hostname}, {"Extra args", extraArgs}, {"Agent gateway token", gatewayTokenStatus}, } return strings.TrimSpace(header + "\n" + renderSummaryTable(rows, width) + "\n\n" + mutedStyle.Render("These values will be applied when installation starts.")) } func renderMarkdown(markdown string, width int) string { renderer, err := glamour.NewTermRenderer( glamour.WithStandardStyle("dark"), glamour.WithWordWrap(max(40, width-14)), ) if err != nil { return markdown } rendered, err := renderer.Render(markdown) if err != nil { return markdown } return strings.TrimSpace(rendered) } func renderSummaryTable(rows [][2]string, width int) string { tableWidth := min(max(56, width-18), 82) labelWidth := 22 valueWidth := max(18, tableWidth-labelWidth-7) headerStyle := lipgloss.NewStyle().Bold(true).Foreground(accent) keyStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("250")) valueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("229")) borderStyle := lipgloss.NewStyle(). Border(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("238")). Padding(0, 1) lines := []string{ headerStyle.Width(labelWidth).Render("Setting") + " " + headerStyle.Width(valueWidth).Render("Value"), mutedStyle.Render(strings.Repeat("─", labelWidth) + " " + strings.Repeat("─", valueWidth)), } for _, row := range rows { lines = append(lines, keyStyle.Width(labelWidth).Render(row[0])+" "+ valueStyle.Width(valueWidth).Render(clip(row[1], valueWidth)), ) } return borderStyle.Width(tableWidth).Render(strings.Join(lines, "\n")) } func clip(value string, width int) string { if width < 4 || len(value) <= width { return value } return value[:width-3] + "..." } func valueOr(value string, fallback string) string { if strings.TrimSpace(value) == "" { return fallback } return strings.TrimSpace(value) } func valueOrDash(value string) string { return valueOr(value, "-") } func displayValue(value string) string { return strings.ReplaceAll(value, "://", "[:]//") } func (m model) writeEnv() error { lines := make([]string, 0, len(questions)) defaults := map[string]string{ "PLATFORM_AGENT_DIR": envDefault("PLATFORM_AGENT_DIR", "/opt/platform-agent"), "PLATFORM_AGENT_SERVICE": envDefault("PLATFORM_AGENT_SERVICE", "platform-agent"), "PLATFORM_AGENT_USER": envDefault("PLATFORM_AGENT_USER", envDefault("USER", "ubuntu")), "PLATFORM_AGENT_REPO_URL": envDefault("PLATFORM_AGENT_REPO_URL", ""), "ROBOT_STACK": envDefault("ROBOT_STACK", "autoware"), "ROS_DOMAIN_ID": envDefault("ROS_DOMAIN_ID", "0"), "CAMERA_ENABLED": envDefault("CAMERA_ENABLED", "true"), "CAMERA_SIGNALING_URL": envDefault("CAMERA_SIGNALING_URL", "ws://127.0.0.1:3000"), "KASMVNC_GEOMETRY": envDefault("KASMVNC_GEOMETRY", "1600x900"), } for key, value := range defaults { lines = append(lines, fmt.Sprintf("%s=%s", key, shellQuote(value))) } for key, value := range m.provision { lines = append(lines, fmt.Sprintf("%s=%s", key, shellQuote(value))) } for _, q := range questions { if q.key == "PLATFORM_ACCOUNT" || q.key == "PLATFORM_REGISTRATION_CODE" || q.key == "PLATFORM_PASSWORD" { continue } value := strings.TrimSpace(m.values[q.key]) if value == "" { value = questionDefault(q) } lines = append(lines, fmt.Sprintf("%s=%s", q.key, shellQuote(value))) } return os.WriteFile(m.envPath, []byte(strings.Join(lines, "\n")+"\n"), 0600) } func (m model) View() string { contentWidth := min(max(64, m.width-8), 92) innerWidth := contentWidth - 6 var b strings.Builder b.WriteString(header(m.index, len(questions), innerWidth) + "\n\n") if m.loading { b.WriteString(statusPanel("Registering platform", "Validating the account and registration code.", innerWidth) + "\n\n") b.WriteString(mutedStyle.Render("Please keep this terminal open. Esc cancels setup.")) return renderShell(b.String(), contentWidth) } if m.showProvision { b.WriteString(okStyle.Render("Connection settings received") + "\n") b.WriteString(mutedStyle.Render("Review the detected device and network settings before continuing.") + "\n\n") b.WriteString(m.provisionSummary + "\n") if m.err != nil { b.WriteString("\n" + errStyle.Render(m.err.Error()) + "\n") } b.WriteString("\n" + footerHint("Enter", "continue", "←", "previous", "Esc", "cancel")) return renderShell(b.String(), contentWidth) } q := questions[m.index] if answers := recentAnswers(m); len(answers) > 0 { b.WriteString(answerSummary(answers, innerWidth) + "\n\n") } b.WriteString(sectionLabel(stepName(m.index)) + "\n") b.WriteString(promptStyle.Render(q.title) + "\n") b.WriteString(mutedStyle.Render(wrap(q.prompt, innerWidth)) + "\n") if example := questionExample(q); example != "" { b.WriteString(keyStyle.Render("Example ") + mutedStyle.Render(example) + "\n") } b.WriteString("\n") if len(q.options) > 0 { b.WriteString(renderOptions(q.options, m.choices[q.key], innerWidth) + "\n") } else { b.WriteString(inputStyle.Width(innerWidth-2).Render(m.input.View()) + "\n") } if m.err != nil { b.WriteString("\n" + errStyle.Render(m.err.Error()) + "\n") } if len(q.options) > 0 { b.WriteString("\n" + footerHint("↑/↓", "select", "Enter", nextAction(m.index), "←", "previous", "Esc", "cancel")) } else { b.WriteString("\n" + footerHint("Enter", nextAction(m.index), "←", "previous", "Esc", "cancel")) } return renderShell(b.String(), contentWidth) } func renderOptions(options []string, selected int, width int) string { lines := make([]string, len(options)) for i, option := range options { if i == selected { lines[i] = selectedStyle.Width(width - 2).Render("● " + option) } else { lines[i] = optionStyle.Width(width - 2).Render("○ " + option) } } return strings.Join(lines, "\n\n") } func header(index, total, width int) string { steps := []string{"Device", "Connection", "Services"} current := 0 switch { case index >= 3: current = 2 case index >= 2: current = 1 } parts := make([]string, 0, len(steps)) for i, step := range steps { label := fmt.Sprintf(" %d %s ", i+1, step) switch { case i < current: parts = append(parts, lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render("✓"+label)) case i == current: parts = append(parts, lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("24")).Render(label)) default: parts = append(parts, mutedStyle.Render(label)) } } return brandStyle.Render("AUTONOMY STUDIO") + "\n" + titleStyle.Render("Platform Agent Setup") + "\n\n" + strings.Join(parts, mutedStyle.Render(" ─ ")) } func recentAnswers(m model) []string { start := max(0, m.index-2) lines := []string{} for i := start; i < m.index; i++ { q := questions[i] value := m.values[q.key] if q.secret && value != "" { value = "********" } if value == "" { value = questionDefault(q) } lines = append(lines, q.title+"\t"+value) } return lines } func renderShell(content string, width int) string { return lipgloss.NewStyle().Margin(1, 2).Render(panelStyle.Width(width).Render(content)) } func sectionLabel(value string) string { return lipgloss.NewStyle().Bold(true).Foreground(accent).Render(strings.ToUpper(value)) } func stepName(index int) string { switch { case index < 2: return "Device" case index == 2: return "Connection" default: return "Services" } } func nextAction(index int) string { if index == len(questions)-1 { return "install" } return "next" } func footerHint(parts ...string) string { items := make([]string, 0, len(parts)/2) for i := 0; i+1 < len(parts); i += 2 { items = append(items, lipgloss.NewStyle().Bold(true).Foreground(accent).Render(parts[i])+mutedStyle.Render(" "+parts[i+1])) } return strings.Join(items, mutedStyle.Render(" · ")) } func answerSummary(answers []string, width int) string { rows := make([]string, 0, len(answers)) for _, answer := range answers { key, value, ok := strings.Cut(answer, "\t") if ok { rows = append(rows, mutedStyle.Width(22).Render(key)+valueStyle.Render(clip(value, max(18, width-27)))) } } if len(rows) == 0 { return "" } return lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(panelBorder).Padding(0, 1).Render(strings.Join(rows, "\n")) } func statusPanel(title, description string, width int) string { spinner := lipgloss.NewStyle().Bold(true).Foreground(accentStrong).Render("◌") return lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(accent).Padding(1, 2).Width(width - 2).Render( spinner + " " + promptStyle.Render(title) + "\n" + mutedStyle.Render(wrap(description, width-10)), ) } func wrap(text string, width int) string { return lipgloss.NewStyle().Width(width).Render(text) } func min(a, b int) int { if a < b { return a } return b } func max(a, b int) int { if a > b { return a } return b } func main() { if len(os.Args) < 2 { fmt.Fprintln(os.Stderr, "usage: installer-tui ") os.Exit(2) } program := tea.NewProgram(initialModel(os.Args[1]), tea.WithAltScreen()) finalModel, err := program.Run() if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } if m, ok := finalModel.(model); ok && !m.done { os.Exit(130) } }