Skip to content

proposal: redesign CLI output with phase-oriented display and --output flag #244

@viniciusdc

Description

@viniciusdc

Summary

NIC's current CLI output is a mix of raw JSON structured logs (slog to stderr), interleaved third-party tool output (hetzner-k3s, OpenTofu), and ASCII box-art banners. The result is hard to follow during a deploy — operators see a wall of {"level":"INFO","msg":"Status","message":"..."} lines with no visual hierarchy, progress indication, or color.

Proposal: Replace the default output with a phase-oriented, tree-structured display while preserving full backward compatibility via --output flag and automatic TTY detection. Full design doc: docs/design-doc/nic-logging-redesign.md.

Current output

{"level":"INFO","msg":"Starting deployment","config_file":"config.yaml"}
{"level":"INFO","msg":"Configuration parsed successfully","provider":"hetzner","project_name":"nic-nebari"}
{"level":"INFO","msg":"Status","message":"Creating Hetzner k3s cluster","resource":"provider","action":"deploy"}
[Instance nic-nebari-master1] Instance status: running
[Instance nic-nebari-master1] Installing k3s...
{"level":"INFO","msg":"Infrastructure deployment completed","provider":"hetzner"}
{"level":"INFO","msg":"Installing Argo CD on cluster"}
{"level":"INFO","msg":"Success","message":"Argo CD is ready","resource":"argocd","action":"ready"}

Proposed output (TTY / pretty mode)

nebari v0.2.0

  Deploying nic-nebari (hetzner)

  ✓ Configuration validated                                           0.2s

  ● Infrastructure                                                   3m 42s
    ├─ ✓ SSH keys verified                                            0.8s
    ├─ ✓ k3s version resolved (v1.32.13+k3s1)                        1.2s
    ├─ ✓ Cluster created                                             3m 38s
    │     master1 (10.0.0.2) ✓  worker1 (10.0.0.3) ✓
    └─ ✓ Kubeconfig saved                                             0.4s

  ● GitOps                                                            4.1s
    ├─ ✓ Repository cloned                                            2.8s
    ├─ ✓ Manifests generated                                          0.9s
    └─ ✓ Changes pushed                                               0.4s

  ● ArgoCD                                                           28.3s
    ├─ ✓ Helm chart installed (v9.4.1)                               12.1s
    ├─ ✓ Project created (nebari)                                     0.3s
    └─ ✓ Root app-of-apps applied                                    15.9s

  ● Foundational Services                                            1m 12s
    ├─ ✓ cert-manager                                  Healthy        18.4s
    ├─ ✓ envoy-gateway                                 Healthy        22.1s
    ├─ ✓ keycloak                                      Healthy        31.2s
    └─ ✓ nebari-operator                               Healthy         0.5s

  ● DNS                                                               2.4s
    ├─ ✓ nebari.example.com → 1.2.3.4                                 1.2s
    └─ ✓ *.nebari.example.com → 1.2.3.4                               1.2s

  ✓ Deployment complete                                              5m 29s

  ┌─────────────────────────────────────────────────────────────┐
  │  ArgoCD    https://argocd.nebari.example.com                │
  │  Keycloak  https://keycloak.nebari.example.com              │
  │                                                             │
  │  Kubeconfig: ~/.cache/nic/hetzner-k3s/nic-nebari/kubeconfig │
  └─────────────────────────────────────────────────────────────┘

Proposed output (error case)

  ● ArgoCD                                                           12.1s
    ├─ ✓ Helm chart installed (v9.4.1)                               12.1s
    └─ ✗ Project creation failed                                      0.3s

      Error: failed to create ArgoCD project "nebari":
      admission webhook "validate.argocd" denied the request:
      project "nebari" already exists

      Hint: Run `nic destroy` first, or use `--force` to overwrite.

  ✗ Deployment failed                                                3m 54s

Output mode flag (--output)

To ensure backward compatibility and support terminals that don't render Unicode/ANSI well, a global --output flag controls the rendering mode:

nic deploy -f config.yaml --output=pretty   # tree-structured, colored (default on TTY)
nic deploy -f config.yaml --output=json     # current JSON structured logs (default on pipe/CI)
nic deploy -f config.yaml --output=plain    # human-readable but no color/unicode (for dumb terminals)

Auto-detection (default):

  • stdout is a TTY → pretty
  • stdout is a pipe or NO_COLOR env is set → json

Override: --output flag always wins over auto-detection. This ensures:

  • Users on terminals that don't handle Unicode can force --output=plain
  • CI pipelines that want pretty logs (e.g., for GitHub Actions summaries) can force --output=pretty
  • Scripts that parse JSON can force --output=json regardless of TTY state

The plain mode is identical to pretty but uses ASCII-only characters (+/x/* instead of //, - instead of , | instead of ) and no ANSI color codes.

Implementation approach

Architecture

cmd/nic/
├── main.go              # --output flag + isatty check → select renderer
├── status_handler.go    # current: slog bridge → new: renderer dispatch
└── renderer/
    ├── renderer.go      # Renderer interface
    ├── pretty.go        # TTY renderer (pterm-based, color + unicode)
    ├── plain.go         # ASCII-only, no color (extends pretty with different symbols)
    └── json.go          # Pipe/CI renderer (current slog JSON behavior)

Renderer interface

type Renderer interface {
    StartPhase(name string)
    EndPhase(status PhaseStatus, duration time.Duration)
    StartStep(name string)
    EndStep(status StepStatus, duration time.Duration, detail string)
    Detail(line string)         // verbose sub-output (hetzner-k3s, OpenTofu lines)
    Summary(items []SummaryItem)
    Error(err error, hint string)
    Confirm(message string, expected string) bool
}

Dependencies

Migration path

Each step is independently shippable — the json renderer preserves current behavior throughout:

  1. Add Renderer interface + json.go (wraps current slog) + pretty.go + plain.go
  2. Wire --output flag + isatty check in main.go
  3. Migrate status_handler.go to dispatch to renderer
  4. Migrate print*Instructionsrenderer.Summary()
  5. Redirect third-party stdout to renderer.Detail()

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions