From 71d0de0fccb0feaf7dca1fed469c39b052baf6da Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:49:16 +0100 Subject: [PATCH 1/3] feat: default sandbox to enabled, polish CLI output, add sandbox CI build - Default Sandbox to true in DefaultState() and setup form so sandboxed code execution is on by default - Add cli/internal/ui package with lipgloss-styled output (Logo, Step, Success, Warn, Error, KeyValue, Hint) and box-drawing ASCII banner with version display - Replace plain fmt.Fprintf in init.go and start.go with styled UI - Promote charmbracelet/lipgloss from indirect to direct dependency - Add build-sandbox job to docker.yml (parallel with backend/web), with Trivy + Grype scans, cosign signing, and SLSA L3 attestation - Update update-release job with sandbox image row, digest, and verification commands --- .github/workflows/docker.yml | 182 +++++++++++++++++++++++++++++- cli/cmd/init.go | 16 ++- cli/cmd/start.go | 23 ++-- cli/go.mod | 2 +- cli/internal/config/state.go | 1 + cli/internal/config/state_test.go | 3 + cli/internal/ui/logo.go | 6 + cli/internal/ui/ui.go | 94 +++++++++++++++ cli/internal/ui/ui_test.go | 99 ++++++++++++++++ 9 files changed, 407 insertions(+), 19 deletions(-) create mode 100644 cli/internal/ui/logo.go create mode 100644 cli/internal/ui/ui.go create mode 100644 cli/internal/ui/ui_test.go diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 8e76ab61c3..07da531ba1 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -391,11 +391,178 @@ jobs: subject-digest: ${{ steps.push.outputs.digest }} push-to-registry: true + build-sandbox: + name: Build Sandbox + needs: [version] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + attestations: write + outputs: + digest: ${{ steps.push.outputs.digest }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Log in to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 + with: + images: ghcr.io/aureliolo/synthorg-sandbox + tags: | + type=raw,value=${{ needs.version.outputs.app_version }},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=sha,prefix=sha- + + # Build locally first, scan, then push only if scans pass + - name: Build image + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + with: + context: . + file: docker/sandbox/Dockerfile + push: false + load: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64 + + # SHA tag always exists (version/semver tags only on v* tags) + - name: Compute scan image ref + id: scan-ref + run: echo "ref=ghcr.io/aureliolo/synthorg-sandbox:sha-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + + # Single Trivy run for CRITICAL + HIGH. + - name: Trivy scan + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + image-ref: ${{ steps.scan-ref.outputs.ref }} + format: json + output: trivy-sandbox.json + exit-code: "0" + severity: CRITICAL,HIGH + trivyignores: .github/.trivyignore.yaml + + - name: Evaluate Trivy results + run: | + if [ ! -f trivy-sandbox.json ]; then + echo "::error::trivy-sandbox.json not found — Trivy scan may have failed to produce output" + exit 1 + fi + echo "## Trivy Scan — Sandbox" + TOTAL=$(jq '[.Results[]?.Vulnerabilities[]?] | length' trivy-sandbox.json) + if [ "$TOTAL" -eq 0 ]; then + echo "No CRITICAL or HIGH vulnerabilities found." + echo "## Trivy Scan — Sandbox: No CRITICAL or HIGH vulnerabilities found." >> "$GITHUB_STEP_SUMMARY" + else + TABLE=$(jq -r ' + ["SEVERITY","CVE","PACKAGE","VERSION","TITLE"], + (.Results[]?.Vulnerabilities[]? | + [.Severity, .VulnerabilityID, .PkgName, .InstalledVersion, (.Title // "")[0:60]]) | + @tsv + ' trivy-sandbox.json | column -t -s$'\t') + echo "$TABLE" + printf '## Trivy Scan — Sandbox\n```\n%s\n```\n' "$TABLE" >> "$GITHUB_STEP_SUMMARY" + fi + + CRITICAL=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL")] | length' trivy-sandbox.json) + HIGH=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "HIGH")] | length' trivy-sandbox.json) + echo "Critical: $CRITICAL, High: $HIGH" + echo "**Critical: $CRITICAL, High: $HIGH**" >> "$GITHUB_STEP_SUMMARY" + if [ "$HIGH" -gt 0 ]; then + echo "::warning::Found $HIGH HIGH severity vulnerabilities" + fi + if [ "$CRITICAL" -gt 0 ]; then + echo "::error::Found $CRITICAL CRITICAL vulnerabilities — failing build" + exit 1 + fi + + - name: Upload Trivy report (sandbox) + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: trivy-sandbox-report + path: trivy-sandbox.json + retention-days: 30 + + - name: Grype scan + uses: anchore/scan-action@7037fa011853d5a11690026fb85feee79f4c946c # v7.3.2 + with: + image: ${{ steps.scan-ref.outputs.ref }} + fail-build: true + severity-cutoff: critical + config: .github/.grype.yaml + + - name: CIS Docker Benchmark (sandbox) + continue-on-error: true + env: + IMAGE_REF: ${{ steps.scan-ref.outputs.ref }} + run: | + command -v trivy >/dev/null 2>&1 || { echo "::warning::trivy not on PATH, skipping CIS scan"; exit 0; } + trivy image --compliance docker-cis-1.6.0 --format table "$IMAGE_REF" + + # Push only after scans pass — prevents publishing vulnerable images. + # NOTE: Separate build invocation; GHA cache ensures deterministic layers, + # but manifest digest differs due to SBOM + provenance attestation layers. + - name: Push image + if: github.event_name != 'pull_request' + id: push + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + with: + context: . + file: docker/sandbox/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + platforms: linux/amd64 + sbom: true + provenance: true + + - name: Install cosign + if: github.event_name != 'pull_request' + uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 + + - name: Sign image + if: github.event_name != 'pull_request' + env: + DIGEST: ${{ steps.push.outputs.digest }} + run: | + if [ -z "$DIGEST" ]; then + echo "::error::Push step did not produce a digest — cannot sign image" + exit 1 + fi + cosign sign --yes ghcr.io/aureliolo/synthorg-sandbox@${DIGEST} + + - name: Attest build provenance (SLSA Level 3) + if: github.event_name != 'pull_request' + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-name: ghcr.io/aureliolo/synthorg-sandbox + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + # Append container image references to the GitHub Release (version tags only) update-release: name: Update Release Notes if: startsWith(github.ref, 'refs/tags/v') && github.event_name != 'pull_request' - needs: [version, build-backend, build-web] + needs: [version, build-backend, build-web, build-sandbox] runs-on: ubuntu-latest permissions: contents: write @@ -408,12 +575,13 @@ jobs: VERSION: ${{ needs.version.outputs.app_version }} BACKEND_DIGEST: ${{ needs.build-backend.outputs.digest }} WEB_DIGEST: ${{ needs.build-web.outputs.digest }} + SANDBOX_DIGEST: ${{ needs.build-sandbox.outputs.digest }} run: | set -euo pipefail # Validate required inputs - if [ -z "$VERSION" ] || [ -z "$BACKEND_DIGEST" ] || [ -z "$WEB_DIGEST" ]; then - echo "::error::Missing required values: VERSION='$VERSION' BACKEND_DIGEST='$BACKEND_DIGEST' WEB_DIGEST='$WEB_DIGEST'" + if [ -z "$VERSION" ] || [ -z "$BACKEND_DIGEST" ] || [ -z "$WEB_DIGEST" ] || [ -z "$SANDBOX_DIGEST" ]; then + echo "::error::Missing required values: VERSION='$VERSION' BACKEND_DIGEST='$BACKEND_DIGEST' WEB_DIGEST='$WEB_DIGEST' SANDBOX_DIGEST='$SANDBOX_DIGEST'" exit 1 fi @@ -441,10 +609,12 @@ jobs: |-------|------| | Backend | `docker pull ghcr.io/aureliolo/synthorg-backend:VERSION_PH` | | Web | `docker pull ghcr.io/aureliolo/synthorg-web:VERSION_PH` | + | Sandbox | `docker pull ghcr.io/aureliolo/synthorg-sandbox:VERSION_PH` | **Digests** (for pinning): - Backend: `ghcr.io/aureliolo/synthorg-backend@BACKEND_DIGEST_PH` - Web: `ghcr.io/aureliolo/synthorg-web@WEB_DIGEST_PH` + - Sandbox: `ghcr.io/aureliolo/synthorg-sandbox@SANDBOX_DIGEST_PH` BLOCK @@ -468,6 +643,7 @@ jobs: IMAGES=${IMAGES//VERSION_PH/$VERSION} IMAGES=${IMAGES//BACKEND_DIGEST_PH/$BACKEND_DIGEST} IMAGES=${IMAGES//WEB_DIGEST_PH/$WEB_DIGEST} + IMAGES=${IMAGES//SANDBOX_DIGEST_PH/$SANDBOX_DIGEST} # Idempotent: strip existing Container Images section before appending. # Uses awk to delete only this section (up to the next ## heading), not to EOF. diff --git a/cli/cmd/init.go b/cli/cmd/init.go index fde0013cbc..63443572e5 100644 --- a/cli/cmd/init.go +++ b/cli/cmd/init.go @@ -12,6 +12,7 @@ import ( "github.com/Aureliolo/synthorg/cli/internal/compose" "github.com/Aureliolo/synthorg/cli/internal/config" + "github.com/Aureliolo/synthorg/cli/internal/ui" "github.com/Aureliolo/synthorg/cli/internal/version" "github.com/charmbracelet/huh" "github.com/spf13/cobra" @@ -67,12 +68,14 @@ func runInit(cmd *cobra.Command, _ []string) error { return err } - composePath := filepath.Join(safeDir, "compose.yml") - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nSynthOrg initialized in %s\n", safeDir) - _, _ = fmt.Fprintf(cmd.OutOrStdout(), " Compose file: %s\n", composePath) - _, _ = fmt.Fprintf(cmd.OutOrStdout(), " Config: %s\n", config.StatePath(safeDir)) - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\nKeep compose.yml and config.json private — they contain your JWT secret.\n") - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Run 'synthorg start' to launch.\n") + out := ui.NewUI(cmd.OutOrStdout()) + out.Logo(version.Version) + out.Success("SynthOrg initialized") + out.KeyValue("Data dir", safeDir) + out.KeyValue("Compose file", filepath.Join(safeDir, "compose.yml")) + out.KeyValue("Config", config.StatePath(safeDir)) + out.Warn("Keep compose.yml and config.json private — they contain your JWT secret.") + out.Hint("Run 'synthorg start' to launch.") return nil } @@ -94,6 +97,7 @@ func runSetupForm() (setupAnswers, error) { dir: defaults.DataDir, backendPortStr: fmt.Sprintf("%d", defaults.BackendPort), webPortStr: fmt.Sprintf("%d", defaults.WebPort), + sandbox: defaults.Sandbox, dockerSock: defaultDockerSock(), logLevel: defaults.LogLevel, genJWT: true, diff --git a/cli/cmd/start.go b/cli/cmd/start.go index 08500ac1b1..3ab83eda7d 100644 --- a/cli/cmd/start.go +++ b/cli/cmd/start.go @@ -12,6 +12,7 @@ import ( "github.com/Aureliolo/synthorg/cli/internal/config" "github.com/Aureliolo/synthorg/cli/internal/docker" "github.com/Aureliolo/synthorg/cli/internal/health" + "github.com/Aureliolo/synthorg/cli/internal/ui" "github.com/spf13/cobra" ) @@ -43,40 +44,44 @@ func runStart(cmd *cobra.Command, args []string) error { return fmt.Errorf("compose.yml not found in %s — run 'synthorg init' first", safeDir) } + out := ui.NewUI(cmd.OutOrStdout()) + errOut := ui.NewUI(cmd.ErrOrStderr()) + info, err := docker.Detect(ctx) if err != nil { return err } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Docker %s, Compose %s\n", info.DockerVersion, info.ComposeVersion) + out.Success(fmt.Sprintf("Docker %s, Compose %s", info.DockerVersion, info.ComposeVersion)) // Check minimum versions. for _, w := range docker.CheckMinVersions(info) { - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Warning: %s\n", w) + errOut.Warn(w) } // Pull latest images. - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Pulling images...") + out.Step("Pulling images...") if err := composeRun(ctx, cmd, info, safeDir, "pull"); err != nil { return fmt.Errorf("pulling images: %w", err) } // Start containers. - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Starting containers...") + out.Step("Starting containers...") if err := composeRun(ctx, cmd, info, safeDir, "up", "-d"); err != nil { return fmt.Errorf("starting containers: %w", err) } // Wait for health. - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Waiting for backend to become healthy...") + out.Step("Waiting for backend to become healthy...") healthURL := fmt.Sprintf("http://localhost:%d/api/v1/health", state.BackendPort) if err := health.WaitForHealthy(ctx, healthURL, 90*time.Second, 2*time.Second, 5*time.Second); err != nil { - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Containers are running but health check failed. Run 'synthorg doctor' for diagnostics.\n") + errOut.Error("Containers are running but health check failed.") + errOut.Hint("Run 'synthorg doctor' for diagnostics.") return fmt.Errorf("health check did not pass: %w", err) } - _, _ = fmt.Fprintln(cmd.OutOrStdout(), "SynthOrg is running!") - _, _ = fmt.Fprintf(cmd.OutOrStdout(), " API: http://localhost:%d/api/v1/health\n", state.BackendPort) - _, _ = fmt.Fprintf(cmd.OutOrStdout(), " Dashboard: http://localhost:%d\n", state.WebPort) + out.Success("SynthOrg is running!") + out.KeyValue("API", fmt.Sprintf("http://localhost:%d/api/v1/health", state.BackendPort)) + out.KeyValue("Dashboard", fmt.Sprintf("http://localhost:%d", state.WebPort)) return nil } diff --git a/cli/go.mod b/cli/go.mod index ee9fe3da14..1206b162ec 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -4,6 +4,7 @@ go 1.26 require ( github.com/charmbracelet/huh v1.0.0 + github.com/charmbracelet/lipgloss v1.1.0 github.com/spf13/cobra v1.10.2 ) @@ -14,7 +15,6 @@ require ( github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect diff --git a/cli/internal/config/state.go b/cli/internal/config/state.go index bfd61c73a2..f29df1817f 100644 --- a/cli/internal/config/state.go +++ b/cli/internal/config/state.go @@ -29,6 +29,7 @@ func DefaultState() State { ImageTag: "latest", BackendPort: 8000, WebPort: 3000, + Sandbox: true, LogLevel: "info", } } diff --git a/cli/internal/config/state_test.go b/cli/internal/config/state_test.go index cd142d25e2..1b310d33a0 100644 --- a/cli/internal/config/state_test.go +++ b/cli/internal/config/state_test.go @@ -22,6 +22,9 @@ func TestDefaultState(t *testing.T) { if s.LogLevel != "info" { t.Errorf("LogLevel = %q, want info", s.LogLevel) } + if !s.Sandbox { + t.Error("Sandbox should default to true") + } if s.DataDir == "" { t.Error("DataDir should not be empty") } diff --git a/cli/internal/ui/logo.go b/cli/internal/ui/logo.go new file mode 100644 index 0000000000..023237e041 --- /dev/null +++ b/cli/internal/ui/logo.go @@ -0,0 +1,6 @@ +package ui + +// logo is a compact ASCII art banner for SynthOrg using double-line box-drawing. +const logo = `╔═╗ ╦ ╦ ╔╗╔ ╔╦╗ ╦ ╦ ╔═╗ ╦═╗ ╔═╗ +╚═╗ ╚╦╝ ║║║ ║ ╠═╣ ║ ║ ╠╦╝ ║ ╦ +╚═╝ ╩ ╝╚╝ ╩ ╩ ╩ ╚═╝ ╩╚═ ╚═╝` diff --git a/cli/internal/ui/ui.go b/cli/internal/ui/ui.go new file mode 100644 index 0000000000..4d8ec417fe --- /dev/null +++ b/cli/internal/ui/ui.go @@ -0,0 +1,94 @@ +// Package ui provides styled CLI output using lipgloss. +package ui + +import ( + "fmt" + "io" + + "github.com/charmbracelet/lipgloss" +) + +// Status icons for CLI output. +const ( + IconSuccess = "✓" + IconInProgress = "●" + IconWarning = "!" + IconError = "✗" + IconHint = "→" +) + +// UI provides styled CLI output bound to a specific writer. +type UI struct { + w io.Writer + r *lipgloss.Renderer +} + +// NewUI creates a UI bound to the given writer. +// The renderer auto-detects terminal capabilities from the writer. +func NewUI(w io.Writer) *UI { + return &UI{ + w: w, + r: lipgloss.NewRenderer(w), + } +} + +func (u *UI) brandStyle() lipgloss.Style { + return u.r.NewStyle().Foreground(lipgloss.Color("99")) +} + +func (u *UI) successStyle() lipgloss.Style { + return u.r.NewStyle().Foreground(lipgloss.Color("42")) +} + +func (u *UI) warnStyle() lipgloss.Style { + return u.r.NewStyle().Foreground(lipgloss.Color("214")) +} + +func (u *UI) errorStyle() lipgloss.Style { + return u.r.NewStyle().Foreground(lipgloss.Color("196")) +} + +func (u *UI) mutedStyle() lipgloss.Style { + return u.r.NewStyle().Foreground(lipgloss.Color("245")) +} + +func (u *UI) labelStyle() lipgloss.Style { + return u.r.NewStyle().Foreground(lipgloss.Color("43")) +} + +// Logo renders the SynthOrg ASCII art logo in brand color with a version tag. +func (u *UI) Logo(version string) { + art := u.brandStyle().Bold(true).Render(logo) + ver := u.mutedStyle().Render(version) + _, _ = fmt.Fprintf(u.w, "%s %s\n", art, ver) +} + +// Step prints an in-progress status line. +func (u *UI) Step(msg string) { + _, _ = fmt.Fprintf(u.w, "%s %s\n", u.brandStyle().Render(IconInProgress), msg) +} + +// Success prints a success status line. +func (u *UI) Success(msg string) { + _, _ = fmt.Fprintf(u.w, "%s %s\n", u.successStyle().Render(IconSuccess), msg) +} + +// Warn prints a warning status line. +func (u *UI) Warn(msg string) { + _, _ = fmt.Fprintf(u.w, "%s %s\n", u.warnStyle().Render(IconWarning), msg) +} + +// Error prints an error status line. +func (u *UI) Error(msg string) { + _, _ = fmt.Fprintf(u.w, "%s %s\n", u.errorStyle().Render(IconError), msg) +} + +// KeyValue prints a labeled key-value pair. +func (u *UI) KeyValue(key, value string) { + _, _ = fmt.Fprintf(u.w, " %s %s\n", u.labelStyle().Render(key+":"), value) +} + +// Hint prints a hint/suggestion line in muted color. +func (u *UI) Hint(msg string) { + _, _ = fmt.Fprintf(u.w, "%s %s\n", u.mutedStyle().Render(IconHint), u.mutedStyle().Render(msg)) +} diff --git a/cli/internal/ui/ui_test.go b/cli/internal/ui/ui_test.go new file mode 100644 index 0000000000..fb061a1b52 --- /dev/null +++ b/cli/internal/ui/ui_test.go @@ -0,0 +1,99 @@ +package ui + +import ( + "bytes" + "strings" + "testing" +) + +func TestLogo(t *testing.T) { + var buf bytes.Buffer + u := NewUI(&buf) + u.Logo("v1.2.3") + out := buf.String() + // Box-drawing banner doesn't spell "SynthOrg" literally — check structure. + if !strings.Contains(out, "╔") { + t.Error("Logo output missing expected box-drawing content") + } + if !strings.Contains(out, "v1.2.3") { + t.Error("Logo output missing version string") + } +} + +func TestSuccess(t *testing.T) { + var buf bytes.Buffer + u := NewUI(&buf) + u.Success("all good") + out := buf.String() + if !strings.Contains(out, IconSuccess) { + t.Errorf("Success output missing icon %q: %s", IconSuccess, out) + } + if !strings.Contains(out, "all good") { + t.Errorf("Success output missing message: %s", out) + } +} + +func TestStep(t *testing.T) { + var buf bytes.Buffer + u := NewUI(&buf) + u.Step("doing work") + out := buf.String() + if !strings.Contains(out, IconInProgress) { + t.Errorf("Step output missing icon %q: %s", IconInProgress, out) + } + if !strings.Contains(out, "doing work") { + t.Errorf("Step output missing message: %s", out) + } +} + +func TestWarn(t *testing.T) { + var buf bytes.Buffer + u := NewUI(&buf) + u.Warn("careful") + out := buf.String() + if !strings.Contains(out, IconWarning) { + t.Errorf("Warn output missing icon %q: %s", IconWarning, out) + } + if !strings.Contains(out, "careful") { + t.Errorf("Warn output missing message: %s", out) + } +} + +func TestError(t *testing.T) { + var buf bytes.Buffer + u := NewUI(&buf) + u.Error("bad thing") + out := buf.String() + if !strings.Contains(out, IconError) { + t.Errorf("Error output missing icon %q: %s", IconError, out) + } + if !strings.Contains(out, "bad thing") { + t.Errorf("Error output missing message: %s", out) + } +} + +func TestKeyValue(t *testing.T) { + var buf bytes.Buffer + u := NewUI(&buf) + u.KeyValue("Data dir", "/tmp/test") + out := buf.String() + if !strings.Contains(out, "Data dir:") { + t.Errorf("KeyValue output missing key: %s", out) + } + if !strings.Contains(out, "/tmp/test") { + t.Errorf("KeyValue output missing value: %s", out) + } +} + +func TestHint(t *testing.T) { + var buf bytes.Buffer + u := NewUI(&buf) + u.Hint("try this") + out := buf.String() + if !strings.Contains(out, IconHint) { + t.Errorf("Hint output missing icon %q: %s", IconHint, out) + } + if !strings.Contains(out, "try this") { + t.Errorf("Hint output missing message: %s", out) + } +} From ed9f8ce53d1a4ea847f54217c531b0d34ed74683 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:09:41 +0100 Subject: [PATCH 2/3] refactor: address review findings from 5 agents Pre-reviewed by 5 agents, 8 findings addressed: - Cache lipgloss styles in UI struct fields instead of per-call allocation - Convert 6 repetitive test functions to table-driven TestOutputMethods - Migrate init.go overwrite warning from raw fmt.Fprintf to ui.Warn - Remove duplicate comment in init.go - Replace fmt.Sprintf with string concatenation in start.go - Conservative Load fallback: Sandbox=false when no config file exists - Update CLAUDE.md: add ui/ package, lipgloss dep, sandbox CI build --- CLAUDE.md | 5 +- cli/cmd/init.go | 7 +-- cli/cmd/start.go | 2 +- cli/internal/config/state.go | 3 + cli/internal/ui/ui.go | 59 ++++++++------------ cli/internal/ui/ui_test.go | 103 ++++++++++------------------------- 6 files changed, 61 insertions(+), 118 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7c8b662108..2c7176cbc3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -152,6 +152,7 @@ cli/ # Go CLI binary (cross-platform, manages Docker lifecycle) health/ # Health check polling with retry + timeout diagnostics/ # System info collection for bug reports selfupdate/ # GitHub Releases self-update + binary replacement + ui/ # Styled CLI output (lipgloss-based: logo, status icons, key-value display) scripts/ # Install scripts (install.sh, install.ps1) testdata/ # Golden files for compose generation tests .goreleaser.yml # GoReleaser config (cross-compile, checksums) @@ -257,7 +258,7 @@ site/ # Astro landing page (synthorg.io) - Build job runs regardless (catches build failures); deploy job skips on fork PRs (same-repo check via job output) - Cleanup job deletes preview comment and Cloudflare deployments on PR close (pull_request events only) - Concurrency group cancels stale builds on rapid pushes -- **Docker**: `.github/workflows/docker.yml` — builds backend + web images, pushes to GHCR, signs with cosign. SLSA L3 provenance attestations via `actions/attest-build-provenance` (SHA-pinned, Sigstore-signed, pushed to registry). Scans: Trivy (CRITICAL = hard fail, HIGH = warn-only) + Grype (critical cutoff) + CIS Docker Benchmark v1.6.0 compliance (informational). CVE triage via `.github/.trivyignore.yaml` and `.github/.grype.yaml`. Images only pushed after scans pass. Triggers on push to main and version tags (`v*`). +- **Docker**: `.github/workflows/docker.yml` — builds backend + web + sandbox images, pushes to GHCR, signs with cosign. SLSA L3 provenance attestations via `actions/attest-build-provenance` (SHA-pinned, Sigstore-signed, pushed to registry). Scans: Trivy (CRITICAL = hard fail, HIGH = warn-only) + Grype (critical cutoff) + CIS Docker Benchmark v1.6.0 compliance (informational). CVE triage via `.github/.trivyignore.yaml` and `.github/.grype.yaml`. Images only pushed after scans pass. Triggers on push to main and version tags (`v*`). - **Matrix**: Python 3.14 - **CLI**: `.github/workflows/cli.yml` — Go lint (`golangci-lint` + `go vet`) + test (`-race -coverprofile`) + build (cross-compile matrix: linux/darwin/windows × amd64/arm64) + vulnerability check (`govulncheck`) + fuzz testing (main-only, 30s/target, `continue-on-error`, matrix over 4 packages) on `cli/**` changes. `cli-pass` gate includes fuzz result as informational warning. GoReleaser release on `v*` tags (attaches assets to the draft Release Please release). Cosign keyless signing of `checksums.txt` (`.sig` + `.pem` attached to release). SLSA L3 provenance attestations via `actions/attest-build-provenance` (SHA-pinned, Sigstore-signed) for individual binaries and checksums file. Sigstore provenance bundle (`.sigstore.json`) attached to release for OpenSSF Scorecard signed-releases scoring. Post-release step appends install instructions + checksum table + cosign verification + provenance verification instructions to the draft release notes (while still in draft — before finalize-release publishes). - **Dependabot**: daily uv + github-actions + npm + pre-commit + docker + gomod updates, grouped minor/patch, no auto-merge. Use `/review-dep-pr` to review Dependabot PRs before merging @@ -282,4 +283,4 @@ site/ # Astro landing page (synthorg.io) - **Required**: `mem0ai` (Mem0 memory backend — the default and currently only backend) - **Install**: `uv sync` installs everything (dev group is default) - **Web dashboard**: Node.js 20+, dependencies in `web/package.json` (Vue 3, PrimeVue, Tailwind CSS, Pinia, VueFlow, ECharts, Axios, vue-draggable-plus, Vitest, fast-check, ESLint, vue-tsc) -- **CLI**: Go 1.26+, dependencies in `cli/go.mod` (Cobra, charmbracelet/huh) +- **CLI**: Go 1.26+, dependencies in `cli/go.mod` (Cobra, charmbracelet/huh, charmbracelet/lipgloss) diff --git a/cli/cmd/init.go b/cli/cmd/init.go index 63443572e5..1012b1ebfc 100644 --- a/cli/cmd/init.go +++ b/cli/cmd/init.go @@ -44,13 +44,12 @@ func runInit(cmd *cobra.Command, _ []string) error { return err } - // Warn if re-initializing over existing config (JWT secret will change). // Warn if re-initializing over existing config (JWT secret will change). // isInteractive() is already checked at function entry, so prompt is safe. + errOut := ui.NewUI(cmd.ErrOrStderr()) if existing := config.StatePath(state.DataDir); fileExists(existing) { - _, _ = fmt.Fprintf(cmd.ErrOrStderr(), - "Warning: existing config at %s will be overwritten.\n"+ - "A new JWT secret will be generated — running containers will need a restart.\n", existing) + errOut.Warn(fmt.Sprintf("Existing config at %s will be overwritten.", existing)) + errOut.Warn("A new JWT secret will be generated — running containers will need a restart.") var proceed bool form := huh.NewForm(huh.NewGroup( huh.NewConfirm().Title("Overwrite existing configuration?").Value(&proceed), diff --git a/cli/cmd/start.go b/cli/cmd/start.go index 3ab83eda7d..1987fbc934 100644 --- a/cli/cmd/start.go +++ b/cli/cmd/start.go @@ -51,7 +51,7 @@ func runStart(cmd *cobra.Command, args []string) error { if err != nil { return err } - out.Success(fmt.Sprintf("Docker %s, Compose %s", info.DockerVersion, info.ComposeVersion)) + out.Success("Docker " + info.DockerVersion + ", Compose " + info.ComposeVersion) // Check minimum versions. for _, w := range docker.CheckMinVersions(info) { diff --git a/cli/internal/config/state.go b/cli/internal/config/state.go index f29df1817f..7893feaa3d 100644 --- a/cli/internal/config/state.go +++ b/cli/internal/config/state.go @@ -52,6 +52,9 @@ func Load(dataDir string) (State, error) { if errors.Is(err, os.ErrNotExist) { defaults := DefaultState() defaults.DataDir = safeDir + // Conservative fallback: sandbox requires explicit user confirmation + // via `synthorg init`, so disable it when no config file exists. + defaults.Sandbox = false return defaults, nil } return State{}, err diff --git a/cli/internal/ui/ui.go b/cli/internal/ui/ui.go index 4d8ec417fe..07f492c469 100644 --- a/cli/internal/ui/ui.go +++ b/cli/internal/ui/ui.go @@ -19,76 +19,63 @@ const ( // UI provides styled CLI output bound to a specific writer. type UI struct { - w io.Writer - r *lipgloss.Renderer + w io.Writer + brand lipgloss.Style + success lipgloss.Style + warn lipgloss.Style + err lipgloss.Style + muted lipgloss.Style + label lipgloss.Style } // NewUI creates a UI bound to the given writer. // The renderer auto-detects terminal capabilities from the writer. func NewUI(w io.Writer) *UI { + r := lipgloss.NewRenderer(w) return &UI{ - w: w, - r: lipgloss.NewRenderer(w), + w: w, + brand: r.NewStyle().Foreground(lipgloss.Color("99")), + success: r.NewStyle().Foreground(lipgloss.Color("42")), + warn: r.NewStyle().Foreground(lipgloss.Color("214")), + err: r.NewStyle().Foreground(lipgloss.Color("196")), + muted: r.NewStyle().Foreground(lipgloss.Color("245")), + label: r.NewStyle().Foreground(lipgloss.Color("43")), } } -func (u *UI) brandStyle() lipgloss.Style { - return u.r.NewStyle().Foreground(lipgloss.Color("99")) -} - -func (u *UI) successStyle() lipgloss.Style { - return u.r.NewStyle().Foreground(lipgloss.Color("42")) -} - -func (u *UI) warnStyle() lipgloss.Style { - return u.r.NewStyle().Foreground(lipgloss.Color("214")) -} - -func (u *UI) errorStyle() lipgloss.Style { - return u.r.NewStyle().Foreground(lipgloss.Color("196")) -} - -func (u *UI) mutedStyle() lipgloss.Style { - return u.r.NewStyle().Foreground(lipgloss.Color("245")) -} - -func (u *UI) labelStyle() lipgloss.Style { - return u.r.NewStyle().Foreground(lipgloss.Color("43")) -} - // Logo renders the SynthOrg ASCII art logo in brand color with a version tag. func (u *UI) Logo(version string) { - art := u.brandStyle().Bold(true).Render(logo) - ver := u.mutedStyle().Render(version) + art := u.brand.Bold(true).Render(logo) + ver := u.muted.Render(version) _, _ = fmt.Fprintf(u.w, "%s %s\n", art, ver) } // Step prints an in-progress status line. func (u *UI) Step(msg string) { - _, _ = fmt.Fprintf(u.w, "%s %s\n", u.brandStyle().Render(IconInProgress), msg) + _, _ = fmt.Fprintf(u.w, "%s %s\n", u.brand.Render(IconInProgress), msg) } // Success prints a success status line. func (u *UI) Success(msg string) { - _, _ = fmt.Fprintf(u.w, "%s %s\n", u.successStyle().Render(IconSuccess), msg) + _, _ = fmt.Fprintf(u.w, "%s %s\n", u.success.Render(IconSuccess), msg) } // Warn prints a warning status line. func (u *UI) Warn(msg string) { - _, _ = fmt.Fprintf(u.w, "%s %s\n", u.warnStyle().Render(IconWarning), msg) + _, _ = fmt.Fprintf(u.w, "%s %s\n", u.warn.Render(IconWarning), msg) } // Error prints an error status line. func (u *UI) Error(msg string) { - _, _ = fmt.Fprintf(u.w, "%s %s\n", u.errorStyle().Render(IconError), msg) + _, _ = fmt.Fprintf(u.w, "%s %s\n", u.err.Render(IconError), msg) } // KeyValue prints a labeled key-value pair. func (u *UI) KeyValue(key, value string) { - _, _ = fmt.Fprintf(u.w, " %s %s\n", u.labelStyle().Render(key+":"), value) + _, _ = fmt.Fprintf(u.w, " %s %s\n", u.label.Render(key+":"), value) } // Hint prints a hint/suggestion line in muted color. func (u *UI) Hint(msg string) { - _, _ = fmt.Fprintf(u.w, "%s %s\n", u.mutedStyle().Render(IconHint), u.mutedStyle().Render(msg)) + _, _ = fmt.Fprintf(u.w, "%s %s\n", u.muted.Render(IconHint), u.muted.Render(msg)) } diff --git a/cli/internal/ui/ui_test.go b/cli/internal/ui/ui_test.go index fb061a1b52..4bae9abfeb 100644 --- a/cli/internal/ui/ui_test.go +++ b/cli/internal/ui/ui_test.go @@ -20,80 +20,33 @@ func TestLogo(t *testing.T) { } } -func TestSuccess(t *testing.T) { - var buf bytes.Buffer - u := NewUI(&buf) - u.Success("all good") - out := buf.String() - if !strings.Contains(out, IconSuccess) { - t.Errorf("Success output missing icon %q: %s", IconSuccess, out) - } - if !strings.Contains(out, "all good") { - t.Errorf("Success output missing message: %s", out) - } -} - -func TestStep(t *testing.T) { - var buf bytes.Buffer - u := NewUI(&buf) - u.Step("doing work") - out := buf.String() - if !strings.Contains(out, IconInProgress) { - t.Errorf("Step output missing icon %q: %s", IconInProgress, out) - } - if !strings.Contains(out, "doing work") { - t.Errorf("Step output missing message: %s", out) - } -} - -func TestWarn(t *testing.T) { - var buf bytes.Buffer - u := NewUI(&buf) - u.Warn("careful") - out := buf.String() - if !strings.Contains(out, IconWarning) { - t.Errorf("Warn output missing icon %q: %s", IconWarning, out) - } - if !strings.Contains(out, "careful") { - t.Errorf("Warn output missing message: %s", out) - } -} - -func TestError(t *testing.T) { - var buf bytes.Buffer - u := NewUI(&buf) - u.Error("bad thing") - out := buf.String() - if !strings.Contains(out, IconError) { - t.Errorf("Error output missing icon %q: %s", IconError, out) - } - if !strings.Contains(out, "bad thing") { - t.Errorf("Error output missing message: %s", out) - } -} - -func TestKeyValue(t *testing.T) { - var buf bytes.Buffer - u := NewUI(&buf) - u.KeyValue("Data dir", "/tmp/test") - out := buf.String() - if !strings.Contains(out, "Data dir:") { - t.Errorf("KeyValue output missing key: %s", out) - } - if !strings.Contains(out, "/tmp/test") { - t.Errorf("KeyValue output missing value: %s", out) - } -} - -func TestHint(t *testing.T) { - var buf bytes.Buffer - u := NewUI(&buf) - u.Hint("try this") - out := buf.String() - if !strings.Contains(out, IconHint) { - t.Errorf("Hint output missing icon %q: %s", IconHint, out) - } - if !strings.Contains(out, "try this") { - t.Errorf("Hint output missing message: %s", out) +func TestOutputMethods(t *testing.T) { + cases := []struct { + name string + call func(*UI) + want []string + }{ + {"Success", func(u *UI) { u.Success("all good") }, []string{IconSuccess, "all good"}}, + {"Step", func(u *UI) { u.Step("doing work") }, []string{IconInProgress, "doing work"}}, + {"Warn", func(u *UI) { u.Warn("careful") }, []string{IconWarning, "careful"}}, + {"Error", func(u *UI) { u.Error("bad thing") }, []string{IconError, "bad thing"}}, + {"KeyValue", func(u *UI) { u.KeyValue("Data dir", "/tmp/test") }, []string{"Data dir:", "/tmp/test"}}, + {"Hint", func(u *UI) { u.Hint("try this") }, []string{IconHint, "try this"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var buf bytes.Buffer + u := NewUI(&buf) + tc.call(u) + out := buf.String() + for _, s := range tc.want { + if !strings.Contains(out, s) { + t.Errorf("output missing %q: %s", s, out) + } + } + if !strings.HasSuffix(out, "\n") { + t.Errorf("output not newline-terminated: %q", out) + } + }) } } From 0a380758b02f532d0347c421ba31df70b74905e4 Mon Sep 17 00:00:00 2001 From: Aurelio <19254254+Aureliolo@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:31:57 +0100 Subject: [PATCH 3/3] refactor: address 22 review items from 6 agents, CodeRabbit, and Gemini MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLAUDE.md: add sandbox image description, fix "both" → "all" images - ui.go: extract color constants, DRY printLine helper, pre-compute brandBold, add stripControl sanitization, improve godoc comments - logo.go: fix "ASCII art" → "Unicode" terminology - state.go: wrap Load errors with context, add validate method for port ranges, improve DefaultState doc to note sandbox divergence - state_test.go: assert Sandbox=false in TestLoadMissing - init.go: scope errOut UI inside conditional, use string concat - start.go: use fmt.Sprintf for version display consistency - ui_test.go: assert version string placement in TestLogo - generate.go: handle null bytes in yamlStr YAML escaping --- CLAUDE.md | 3 +- cli/cmd/init.go | 4 +- cli/cmd/start.go | 2 +- cli/internal/compose/generate.go | 5 +- cli/internal/config/state.go | 22 ++++++- cli/internal/config/state_test.go | 4 ++ cli/internal/ui/logo.go | 2 +- cli/internal/ui/ui.go | 105 +++++++++++++++++++++--------- cli/internal/ui/ui_test.go | 4 ++ 9 files changed, 109 insertions(+), 42 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2c7176cbc3..0cbb6be6a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,9 +102,10 @@ curl http://localhost:3000/api/v1/health # backend (via web proxy) - **Backend**: 3-stage build (builder → setup → distroless runtime), Chainguard Python, non-root (UID 65532), CIS-hardened - **Web**: `nginxinc/nginx-unprivileged`, Vue 3 SPA (PrimeVue + Tailwind CSS), SPA routing, API/WebSocket proxy to backend +- **Sandbox**: `synthorg-sandbox` — Python 3.14 + Node.js + git, non-root (UID 10001), agent code execution sandbox - **Config**: all Docker files in `docker/` — Dockerfiles, compose, `.env.example` - **CI**: `.github/workflows/docker.yml` — build → scan → push to GHCR + cosign sign + SLSA L3 provenance via `attest-build-provenance` (images only pushed after Trivy/Grype scans pass) -- **Build context**: single root `.dockerignore` (both images build with `context: .`) +- **Build context**: single root `.dockerignore` (all images build with `context: .`) - **Tags**: CI tags images with version from `pyproject.toml` (`[tool.commitizen].version`), semver, and SHA - **Dependabot**: auto-updates Docker image digests and versions daily diff --git a/cli/cmd/init.go b/cli/cmd/init.go index 1012b1ebfc..0738f4a706 100644 --- a/cli/cmd/init.go +++ b/cli/cmd/init.go @@ -46,9 +46,9 @@ func runInit(cmd *cobra.Command, _ []string) error { // Warn if re-initializing over existing config (JWT secret will change). // isInteractive() is already checked at function entry, so prompt is safe. - errOut := ui.NewUI(cmd.ErrOrStderr()) if existing := config.StatePath(state.DataDir); fileExists(existing) { - errOut.Warn(fmt.Sprintf("Existing config at %s will be overwritten.", existing)) + errOut := ui.NewUI(cmd.ErrOrStderr()) + errOut.Warn("Existing config at " + existing + " will be overwritten.") errOut.Warn("A new JWT secret will be generated — running containers will need a restart.") var proceed bool form := huh.NewForm(huh.NewGroup( diff --git a/cli/cmd/start.go b/cli/cmd/start.go index 1987fbc934..3ab83eda7d 100644 --- a/cli/cmd/start.go +++ b/cli/cmd/start.go @@ -51,7 +51,7 @@ func runStart(cmd *cobra.Command, args []string) error { if err != nil { return err } - out.Success("Docker " + info.DockerVersion + ", Compose " + info.ComposeVersion) + out.Success(fmt.Sprintf("Docker %s, Compose %s", info.DockerVersion, info.ComposeVersion)) // Check minimum versions. for _, w := range docker.CheckMinVersions(info) { diff --git a/cli/internal/compose/generate.go b/cli/internal/compose/generate.go index 375051f8f4..914587fef2 100644 --- a/cli/internal/compose/generate.go +++ b/cli/internal/compose/generate.go @@ -108,8 +108,9 @@ func validateParams(p Params) error { func yamlStr(s string) string { // If the string contains YAML-special or Compose-interpolation characters, // double-quote and escape. - if strings.ContainsAny(s, "$:#{}[]|>&*!%@`\"'\\\n\r\t") { - escaped := strings.ReplaceAll(s, `\`, `\\`) + if strings.ContainsAny(s, "\x00$:#{}[]|>&*!%@`\"'\\\n\r\t") { + escaped := strings.ReplaceAll(s, "\x00", "") // YAML cannot represent null bytes + escaped = strings.ReplaceAll(escaped, `\`, `\\`) escaped = strings.ReplaceAll(escaped, `"`, `\"`) escaped = strings.ReplaceAll(escaped, "\n", `\n`) escaped = strings.ReplaceAll(escaped, "\r", `\r`) diff --git a/cli/internal/config/state.go b/cli/internal/config/state.go index 7893feaa3d..5622603b3a 100644 --- a/cli/internal/config/state.go +++ b/cli/internal/config/state.go @@ -22,7 +22,9 @@ type State struct { JWTSecret string `json:"jwt_secret,omitempty"` } -// DefaultState returns a State with sensible defaults. +// DefaultState returns a State with sensible defaults for the interactive init +// wizard. Note: Load applies a more conservative fallback (sandbox disabled) +// when no config file exists. func DefaultState() State { return State{ DataDir: DataDir(), @@ -57,12 +59,15 @@ func Load(dataDir string) (State, error) { defaults.Sandbox = false return defaults, nil } - return State{}, err + return State{}, fmt.Errorf("reading config %s: %w", path, err) } // Unmarshal onto defaults so missing fields retain default values. s := DefaultState() if err := json.Unmarshal(data, &s); err != nil { - return State{}, err + return State{}, fmt.Errorf("parsing config %s: %w", path, err) + } + if err := s.validate(); err != nil { + return State{}, fmt.Errorf("config %s: %w", path, err) } // Canonicalize and validate DataDir. if s.DataDir != "" { @@ -78,6 +83,17 @@ func Load(dataDir string) (State, error) { return s, nil } +// validate checks that loaded config values are within safe ranges. +func (s State) validate() error { + if s.BackendPort < 1 || s.BackendPort > 65535 { + return fmt.Errorf("invalid backend_port %d: must be 1-65535", s.BackendPort) + } + if s.WebPort < 1 || s.WebPort > 65535 { + return fmt.Errorf("invalid web_port %d: must be 1-65535", s.WebPort) + } + return nil +} + // Save writes State to disk as indented JSON. // DataDir is normalized to the SecurePath-cleaned form before persisting. func Save(s State) error { diff --git a/cli/internal/config/state_test.go b/cli/internal/config/state_test.go index 1b310d33a0..7091aea96c 100644 --- a/cli/internal/config/state_test.go +++ b/cli/internal/config/state_test.go @@ -131,6 +131,10 @@ func TestLoadMissing(t *testing.T) { if s.BackendPort != 8000 { t.Errorf("expected default BackendPort 8000, got %d", s.BackendPort) } + // Conservative fallback: sandbox disabled when no config exists. + if s.Sandbox { + t.Error("Sandbox should be false when config file is missing") + } } func TestLoadInvalid(t *testing.T) { diff --git a/cli/internal/ui/logo.go b/cli/internal/ui/logo.go index 023237e041..e87055b034 100644 --- a/cli/internal/ui/logo.go +++ b/cli/internal/ui/logo.go @@ -1,6 +1,6 @@ package ui -// logo is a compact ASCII art banner for SynthOrg using double-line box-drawing. +// logo is a compact Unicode banner for SynthOrg using double-line box-drawing characters. const logo = `╔═╗ ╦ ╦ ╔╗╔ ╔╦╗ ╦ ╦ ╔═╗ ╦═╗ ╔═╗ ╚═╗ ╚╦╝ ║║║ ║ ╠═╣ ║ ║ ╠╦╝ ║ ╦ ╚═╝ ╩ ╝╚╝ ╩ ╩ ╩ ╚═╝ ╩╚═ ╚═╝` diff --git a/cli/internal/ui/ui.go b/cli/internal/ui/ui.go index 07f492c469..fefda8dd59 100644 --- a/cli/internal/ui/ui.go +++ b/cli/internal/ui/ui.go @@ -1,81 +1,122 @@ -// Package ui provides styled CLI output using lipgloss. +// Package ui provides styled CLI output using lipgloss. It defines a +// writer-bound UI type with methods for rendering status lines (success, error, +// warning, step, hint), key-value pairs, and the SynthOrg Unicode logo with +// consistent colors and icons. package ui import ( "fmt" "io" + "strings" "github.com/charmbracelet/lipgloss" ) -// Status icons for CLI output. -const ( - IconSuccess = "✓" - IconInProgress = "●" - IconWarning = "!" - IconError = "✗" - IconHint = "→" +// Color palette for CLI styling. +var ( + colorBrand = lipgloss.Color("99") // purple + colorSuccess = lipgloss.Color("42") // green + colorWarn = lipgloss.Color("214") // orange + colorError = lipgloss.Color("196") // red + colorMuted = lipgloss.Color("245") // gray + colorLabel = lipgloss.Color("43") // cyan ) +// IconSuccess indicates a completed operation. +const IconSuccess = "✓" + +// IconInProgress indicates an ongoing operation. +const IconInProgress = "●" + +// IconWarning indicates a potential issue. +const IconWarning = "!" + +// IconError indicates a failed operation. +const IconError = "✗" + +// IconHint indicates a suggestion or next step. +const IconHint = "→" + // UI provides styled CLI output bound to a specific writer. +// Binding to a writer (rather than defaulting to os.Stdout) enables +// testability and correct stderr/stdout separation in Cobra commands. type UI struct { - w io.Writer - brand lipgloss.Style - success lipgloss.Style - warn lipgloss.Style - err lipgloss.Style - muted lipgloss.Style - label lipgloss.Style + w io.Writer + brand lipgloss.Style + brandBold lipgloss.Style + success lipgloss.Style + warn lipgloss.Style + err lipgloss.Style + muted lipgloss.Style + label lipgloss.Style } // NewUI creates a UI bound to the given writer. -// The renderer auto-detects terminal capabilities from the writer. +// The renderer auto-detects whether the writer is a terminal and adjusts +// color output accordingly (no ANSI codes when piped or redirected). func NewUI(w io.Writer) *UI { r := lipgloss.NewRenderer(w) return &UI{ - w: w, - brand: r.NewStyle().Foreground(lipgloss.Color("99")), - success: r.NewStyle().Foreground(lipgloss.Color("42")), - warn: r.NewStyle().Foreground(lipgloss.Color("214")), - err: r.NewStyle().Foreground(lipgloss.Color("196")), - muted: r.NewStyle().Foreground(lipgloss.Color("245")), - label: r.NewStyle().Foreground(lipgloss.Color("43")), + w: w, + brand: r.NewStyle().Foreground(colorBrand), + brandBold: r.NewStyle().Foreground(colorBrand).Bold(true), + success: r.NewStyle().Foreground(colorSuccess), + warn: r.NewStyle().Foreground(colorWarn), + err: r.NewStyle().Foreground(colorError), + muted: r.NewStyle().Foreground(colorMuted), + label: r.NewStyle().Foreground(colorLabel), } } -// Logo renders the SynthOrg ASCII art logo in brand color with a version tag. +// Logo renders the SynthOrg Unicode logo in brand color with a version tag. func (u *UI) Logo(version string) { - art := u.brand.Bold(true).Render(logo) - ver := u.muted.Render(version) + art := u.brandBold.Render(logo) + ver := u.muted.Render(stripControl(version)) _, _ = fmt.Fprintf(u.w, "%s %s\n", art, ver) } +// printLine prints a styled icon followed by a sanitized message. +func (u *UI) printLine(style lipgloss.Style, icon, msg string) { + _, _ = fmt.Fprintf(u.w, "%s %s\n", style.Render(icon), stripControl(msg)) +} + // Step prints an in-progress status line. func (u *UI) Step(msg string) { - _, _ = fmt.Fprintf(u.w, "%s %s\n", u.brand.Render(IconInProgress), msg) + u.printLine(u.brand, IconInProgress, msg) } // Success prints a success status line. func (u *UI) Success(msg string) { - _, _ = fmt.Fprintf(u.w, "%s %s\n", u.success.Render(IconSuccess), msg) + u.printLine(u.success, IconSuccess, msg) } // Warn prints a warning status line. func (u *UI) Warn(msg string) { - _, _ = fmt.Fprintf(u.w, "%s %s\n", u.warn.Render(IconWarning), msg) + u.printLine(u.warn, IconWarning, msg) } // Error prints an error status line. func (u *UI) Error(msg string) { - _, _ = fmt.Fprintf(u.w, "%s %s\n", u.err.Render(IconError), msg) + u.printLine(u.err, IconError, msg) } // KeyValue prints a labeled key-value pair. func (u *UI) KeyValue(key, value string) { - _, _ = fmt.Fprintf(u.w, " %s %s\n", u.label.Render(key+":"), value) + _, _ = fmt.Fprintf(u.w, " %s %s\n", u.label.Render(stripControl(key)+":"), stripControl(value)) } // Hint prints a hint/suggestion line in muted color. func (u *UI) Hint(msg string) { - _, _ = fmt.Fprintf(u.w, "%s %s\n", u.muted.Render(IconHint), u.muted.Render(msg)) + _, _ = fmt.Fprintf(u.w, "%s %s\n", u.muted.Render(IconHint), u.muted.Render(stripControl(msg))) +} + +// stripControl removes ASCII control characters (except tab and newline) +// to prevent terminal escape sequence injection in displayed values. +func stripControl(s string) string { + return strings.Map(func(r rune) rune { + if r < 0x20 && r != '\t' && r != '\n' { + return -1 + } + return r + }, s) } diff --git a/cli/internal/ui/ui_test.go b/cli/internal/ui/ui_test.go index 4bae9abfeb..25c09b4c7f 100644 --- a/cli/internal/ui/ui_test.go +++ b/cli/internal/ui/ui_test.go @@ -18,6 +18,10 @@ func TestLogo(t *testing.T) { if !strings.Contains(out, "v1.2.3") { t.Error("Logo output missing version string") } + // Verify version string is positioned after the logo art. + if trimmed := strings.TrimRight(out, "\n"); !strings.HasSuffix(trimmed, "v1.2.3") { + t.Errorf("version string should appear at the end of logo output, got %q", trimmed) + } } func TestOutputMethods(t *testing.T) {