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/CLAUDE.md b/CLAUDE.md index 7c8b662108..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 @@ -152,6 +153,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 +259,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 +284,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 fde0013cbc..0738f4a706 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" @@ -43,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. 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 := 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( huh.NewConfirm().Title("Overwrite existing configuration?").Value(&proceed), @@ -67,12 +67,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 +96,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/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 bfd61c73a2..5622603b3a 100644 --- a/cli/internal/config/state.go +++ b/cli/internal/config/state.go @@ -22,13 +22,16 @@ 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(), ImageTag: "latest", BackendPort: 8000, WebPort: 3000, + Sandbox: true, LogLevel: "info", } } @@ -51,14 +54,20 @@ 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 + 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 != "" { @@ -74,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 cd142d25e2..7091aea96c 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") } @@ -128,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 new file mode 100644 index 0000000000..e87055b034 --- /dev/null +++ b/cli/internal/ui/logo.go @@ -0,0 +1,6 @@ +package ui + +// 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 new file mode 100644 index 0000000000..fefda8dd59 --- /dev/null +++ b/cli/internal/ui/ui.go @@ -0,0 +1,122 @@ +// 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" +) + +// 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 + 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 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(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 Unicode logo in brand color with a version tag. +func (u *UI) Logo(version string) { + 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) { + u.printLine(u.brand, IconInProgress, msg) +} + +// Success prints a success status line. +func (u *UI) Success(msg string) { + u.printLine(u.success, IconSuccess, msg) +} + +// Warn prints a warning status line. +func (u *UI) Warn(msg string) { + u.printLine(u.warn, IconWarning, msg) +} + +// Error prints an error status line. +func (u *UI) Error(msg string) { + 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(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(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 new file mode 100644 index 0000000000..25c09b4c7f --- /dev/null +++ b/cli/internal/ui/ui_test.go @@ -0,0 +1,56 @@ +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") + } + // 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) { + 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) + } + }) + } +}