diff --git a/.github/workflows/dist.yml b/.github/workflows/dist.yml new file mode 100644 index 00000000..39ff6a95 --- /dev/null +++ b/.github/workflows/dist.yml @@ -0,0 +1,45 @@ +name: Dist + +on: + push: + branches: [main, master, develop] + workflow_dispatch: + +jobs: + # Rebuild the embedded task-ui bundle from source and commit it back when it + # changed. dist/taskui.js is gitignored build output that task/ui/assets.go + # embeds via `//go:embed`, so it must be kept in sync with task/ui/src. + # Only runs on push (a real branch to push to); the commit is marked + # [skip ci] to avoid re-triggering this workflow. + task-ui: + name: Rebuild task-ui bundle + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Build and commit task-ui bundle + env: + GH_TOKEN: ${{ secrets.FLANKBOT }} + run: | + make task-ui + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add -f task/ui/dist/taskui.js + if ! git diff --cached --quiet; then + git commit -m "chore(embed): rebuild task-ui bundle [skip ci]" + git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" + git push origin HEAD:${{ github.ref_name }} + else + echo "task-ui bundle unchanged" + fi diff --git a/.github/workflows/gavel.yml b/.github/workflows/gavel.yml new file mode 100644 index 00000000..1c6ada90 --- /dev/null +++ b/.github/workflows/gavel.yml @@ -0,0 +1,98 @@ +name: CI + +on: + push: + branches: [main, master, develop] + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + +env: + GO_VERSION: "1.26" + +jobs: + test: + name: Test + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Gavel test + uses: flanksource/gavel@main + with: + args: test + comment-header: gavel-test + artifact-name: gavel-test-results + + lint: + name: Lint + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + # The gavel action always injects `--show-passed`, which `gavel lint` + # rejects (only `gavel test` accepts it), so run the binary directly. + - name: Install gavel + run: | + mkdir -p "$HOME/.local/bin" + curl -fsSL "https://github.com/flanksource/gavel/releases/download/${GAVEL_VERSION}/gavel_linux_amd64.tar.gz" -o /tmp/gavel.tar.gz + tar -xzf /tmp/gavel.tar.gz -C /tmp + install -m 0755 /tmp/gavel "$HOME/.local/bin/gavel" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + env: + GAVEL_VERSION: v0.0.39 + + - name: Gavel lint + run: gavel lint --no-progress --no-color + + openapi: + name: OpenAPI Integration + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + - name: Run OpenAPI integration tests + run: make test:openapi + env: + CGO_ENABLED: 1 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index db3c37c6..00000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: Lint - -on: - push: - branches: [main, master, develop] - pull_request: - types: [opened, synchronize, reopened] - workflow_dispatch: - -env: - GO_VERSION: "1.26" - -jobs: - golangci-lint: - name: golangci-lint - runs-on: ubuntu-latest - permissions: - # Required: allow read access to the content for analysis. - contents: read - # Optional: allow read access to pull requests. Use with `only-new-issues` option. - pull-requests: read - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: ${{ env.GO_VERSION }} - cache: true - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - - - name: Build task-ui frontend - run: cd task/ui && npm ci && npm run build - - - name: golangci-lint - uses: golangci/golangci-lint-action@v9 - with: - version: v2.11.0 - args: --timeout=10m --tests=false - skip-cache: true - verify: false - - go-fmt: - name: Go Format Check - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: ${{ env.GO_VERSION }} - cache: true - - - name: Check formatting - run: | - if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then - echo "The following files are not formatted correctly:" - gofmt -s -l . - echo "Please run 'go fmt ./...' or 'make fmt' to fix formatting issues" - exit 1 - fi - - go-mod-tidy: - name: Go Mod Tidy Check - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: ${{ env.GO_VERSION }} - cache: true - - - name: Check go mod tidy - run: | - go mod tidy - if [ -n "$(git status --porcelain)" ]; then - echo "go mod tidy resulted in changes:" - git diff - echo "Please run 'go mod tidy' and commit the changes" - exit 1 - fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 5210ca8a..00000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,119 +0,0 @@ -name: Test - -on: - push: - branches: [main, master, develop] - pull_request: - types: [opened, synchronize, reopened] - workflow_dispatch: - -env: - GO_VERSION: "1.26" - -jobs: - test: - name: Test (${{ matrix.os }}, Go ${{ matrix.go-version }}) - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest] - go-version: ["1.26"] - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: ${{ matrix.go-version }} - cache: true - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - - - name: Build task-ui frontend - run: cd task/ui && npm ci && npm run build - - - name: Install dependencies - run: | - go mod download - go mod tidy - - - name: Verify dependencies - run: go mod verify - - - name: Run tests - run: make test - env: - CGO_ENABLED: 1 - - openapi: - name: OpenAPI Integration - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: ${{ env.GO_VERSION }} - cache: true - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - - - name: Build task-ui frontend - run: cd task/ui && npm ci && npm run build - - - name: Run OpenAPI integration tests - run: make test:openapi - env: - CGO_ENABLED: 1 - - build: - name: Build - runs-on: ubuntu-latest - needs: test - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: ${{ env.GO_VERSION }} - cache: true - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '22' - - - name: Build task-ui frontend - run: cd task/ui && npm ci && npm run build - - - name: Build binary - run: make build - - - name: Test binary - run: | - ./clicky --help - ./clicky --version || echo "Version command may not be implemented yet" - - - name: Upload binary artifact - uses: actions/upload-artifact@v4 - with: - name: clicky-linux-amd64 - path: clicky diff --git a/.gitignore b/.gitignore index 0069ab3f..1ae9577d 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,8 @@ test-* .cache *.db clicky +!lint/testdata/src/github.com/flanksource/clicky/ +!lint/testdata/src/github.com/flanksource/clicky/stub.go .task *.pdf *.html @@ -96,4 +98,8 @@ ginkgo.report examples/uber_demo/uber_demo .gavel/ examples/enitity/webapp/dist/ +# Keep a committed placeholder so the //go:embed webapp/dist in main.go always +# resolves in CI; `pnpm build` overwrites dist/ with the real hashed bundles. +!examples/enitity/webapp/dist/ +!examples/enitity/webapp/dist/index.html examples/enitity/enitity diff --git a/ai/cache/cache.go b/ai/cache/cache.go index 748c51f4..22135bda 100644 --- a/ai/cache/cache.go +++ b/ai/cache/cache.go @@ -13,7 +13,12 @@ import ( "github.com/flanksource/clicky/task" "github.com/flanksource/commons/logger" - _ "github.com/mattn/go-sqlite3" + // Pure-Go SQLite driver (registers driver name "sqlite") so clicky builds + // without CGO. Use modernc.org/sqlite — the same driver gavel's cache + // registers — so a binary linking both registers the "sqlite" driver exactly + // once. (glebarez/go-sqlite registers the same name from a different package; + // mixing the two panics at init with "Register called twice".) + _ "modernc.org/sqlite" ) var ( @@ -104,7 +109,7 @@ func New(config Config) (*Cache, error) { } // Open database - db, err := sql.Open("sqlite3", config.DBPath) + db, err := sql.Open("sqlite", config.DBPath) if err != nil { return nil, fmt.Errorf("failed to open database: %w", err) } diff --git a/clicky_lookup.go b/clicky_lookup.go index 4b51b9d5..698df958 100644 --- a/clicky_lookup.go +++ b/clicky_lookup.go @@ -12,6 +12,14 @@ type entityLookupFilter struct { Selected map[string]clickyNode `json:"selected,omitempty"` Multi bool `json:"multi,omitempty"` Type string `json:"type,omitempty"` + // Truncated is true when more options exist than are returned in Options: + // the filter is a SearchableFilter whose distinct count exceeds the option + // cap. The UI renders the returned head set plus an "… and N more" hint and + // re-queries via OptionsWithQuery as the user types. Total is the true + // distinct count behind the head. Both stay zero/false for non-searchable + // filters whose Options fully enumerate. + Truncated bool `json:"truncated,omitempty"` + Total int `json:"total,omitempty"` } type clickyNode struct { diff --git a/cmd/clicky/main.go b/cmd/clicky/main.go index 2f91c09d..5beeedb2 100644 --- a/cmd/clicky/main.go +++ b/cmd/clicky/main.go @@ -3,12 +3,15 @@ package main import ( "encoding/json" "fmt" + "io" "os" "path/filepath" "runtime" "strings" + "github.com/flanksource/clicky" "github.com/spf13/cobra" + "github.com/spf13/pflag" "golang.org/x/tools/go/analysis/singlechecker" "gopkg.in/yaml.v3" @@ -103,6 +106,7 @@ For backward compatibility, you can use the root command directly, or use the rootCmd.AddCommand(newVersionCommand()) rootCmd.AddCommand(newSchemaCommand()) rootCmd.AddCommand(newLintCommand()) + rootCmd.SetHelpCommand(newClickyHelpCommand()) // Add OpenAPI and MCP commands using extensions extensions.CobraExtensions(rootCmd). @@ -122,7 +126,10 @@ func newPrettyCommand() *cobra.Command { Long: `Format structured data files (JSON, YAML, etc.) using a YAML schema definition. The pretty command is the main functionality of clicky, allowing you to transform -raw data into beautifully formatted output using customizable schemas.`, +raw data into beautifully formatted output using customizable schemas. + +For the full pretty-printing API guide, run: + clicky help pretty`, Example: ` clicky pretty --schema order-schema.yaml order1.json order2.yaml clicky pretty --schema user-schema.yaml --format html --output reports/ users.json clicky pretty --schema product-schema.yaml --format csv products.json`, @@ -170,34 +177,618 @@ raw data into beautifully formatted output using customizable schemas.`, return cmd } -func newLintCommand() *cobra.Command { +func newClickyHelpCommand() *cobra.Command { return &cobra.Command{ + Use: "help [command]", + Short: "Help about any command", + Long: `Help provides help for any command in the application. +Use "clicky help pretty" for the full pretty-printing API guide.`, + Args: cobra.ArbitraryArgs, + Run: func(cmd *cobra.Command, args []string) { + if len(args) == 1 && args[0] == "pretty" { + renderPrettyAPIHelp(cmd) + return + } + + target, _, err := cmd.Root().Find(args) + if target == nil || err != nil { + cmd.Printf("Unknown help topic %#q\n", args) + cobra.CheckErr(cmd.Root().Usage()) + return + } + + target.InitDefaultHelpFlag() + target.InitDefaultVersionFlag() + cobra.CheckErr(target.Help()) + }, + } +} + +func renderPrettyAPIHelp(cmd *cobra.Command) { + opts := formatters.FormatOptions{} + opts.ResolveNoColor() + + prettyCmd, _, err := cmd.Root().Find([]string{"pretty"}) + if err != nil || prettyCmd == nil { + prettyCmd = nil + } + if prettyCmd != nil { + prettyCmd.InitDefaultHelpFlag() + prettyCmd.InitDefaultVersionFlag() + } + + out, err := clicky.Format(prettyHelpGuide{command: prettyCmd}, formatters.FormatOptions{ + Format: "pretty", + NoColor: opts.NoColor, + }) + if err != nil { + cobra.CheckErr(err) + return + } + if !strings.HasSuffix(out, "\n") { + out += "\n" + } + fmt.Fprint(cmd.OutOrStdout(), out) +} + +type prettyHelpGuide struct { + command *cobra.Command +} + +func (g prettyHelpGuide) Pretty() api.Text { + return prettyAPIHelp(g.command) +} + +func prettyAPIHelp(command *cobra.Command) api.Text { + doc := clicky.Text("") + doc = doc.Add(helpTitle("Clicky Pretty Printing")).NewLine() + + doc = doc.Add(helpSection("Intro")).NewLine() + doc = doc.Add(helpText("Build terminal, Markdown, HTML, Slack, PDF, Excel, and web UI output from one Clicky render tree. Values stay structured until the formatter boundary, so tables, links, code blocks, badges, diagnostics, and trees can render appropriately for each output.")).NewLine() + doc = doc.NewLine() + if command != nil { + doc = doc.Add(helpCode(command.UseLine())).NewLine() + } else { + doc = doc.Add(helpCode("clicky pretty [flags] [file...]")).NewLine() + } + doc = doc.Add(helpBullet("Input files may be JSON or YAML. Multiple files are rendered in order.")).NewLine() + doc = doc.Add(helpBullet("The schema controls labels, field order, styles, nested fields, table columns, tree options, and value formatting.")).NewLine() + doc = doc.Add(helpBullet("The root command keeps the old form: ")).Add(helpCodeInline("clicky --schema schema.yaml data.json")).Append(".").NewLine() + doc = doc.Add(helpBullet("Pretty-print schema-backed data:")).NewLine() + doc = doc.Add(helpCode("clicky pretty --schema examples/order-schema.yaml examples/example-data.json")).NewLine() + doc = doc.Add(helpBullet("Render stdout and write additional sinks in one pass:")).NewLine() + doc = doc.Add(helpCode("clicky pretty --schema schema.yaml --format 'pretty,json=out.json,markdown=summary.md' data.yaml")).NewLine() + doc = doc.Add(helpBullet("Filter table or tree rows with CEL:")).NewLine() + doc = doc.Add(helpCode(`clicky pretty --schema schema.yaml --filter "status == 'active' && total > 100" data.json`)).NewLine() + doc = doc.Add(helpBullet("Force a structure when automatic detection is not enough:")).NewLine() + doc = doc.Add(helpCode("clicky pretty --schema schema.yaml --table data.json")).NewLine() + doc = doc.Add(helpCode("clicky pretty --schema schema.yaml --tree data.json")).NewLine() + doc = doc.NewLine() + + doc = doc.Add(helpSection("Flag Reference")).NewLine() + if command != nil { + doc = doc.Add(prettyFlagsTable(command.Flags())).NewLine() + doc = doc.NewLine() + } + doc = doc.Add(formatSpecTable()).NewLine() + doc = doc.Add(helpText("A bare format renders to stdout. ")).Add(helpCodeInline("format=file")).Append(" writes a file sink. Comma-separated specs may mix one stdout format with many file sinks.").NewLine() + doc = doc.NewLine() + + doc = doc.Add(helpSection("Quickstart: Add Pretty() Method")).NewLine() + doc = doc.Add(helpText("Implement ")).Add(helpCodeInline("Pretty() api.Text")).Append(" when a type owns its normal rich display. Compose Clicky values and return them; do not render to a string inside the method.").NewLine() + doc = doc.Add(helpCodeBlock("go", ` +type Service struct { + Name string + Status string + URL string +} + +func (s Service) Pretty() api.Text { + return clicky.Text(s.Name, "font-bold"). + Space(). + Add(clicky.Badge(s.Status, "bg-green-100 text-green-800")). + Space(). + Add(clicky.Link(s.URL).Append("open", "text-blue-600 underline")) +} + +out, err := clicky.Format(Service{ + Name: "api", Status: "healthy", URL: "https://status.example.com", +}, clicky.FormatOptions{Format: "pretty"}) +`)).NewLine() + doc = doc.Add(helpText("Render with ")).Add(helpCodeInline("clicky.Format")).Append(", ").Add(helpCodeInline("clicky.MustPrint")).Append(", ").Add(helpCodeInline("clicky.FormatToFile")).Append(", or ").Add(helpCodeInline("clicky.PrintAndWriteSinks")).Append(".").NewLine() + doc = doc.NewLine() + + doc = doc.Add(helpSection("Add Table Support")).NewLine() + doc = doc.Add(helpText("For slices, implement ")).Add(helpCodeInline("PrettyRow(opts any) map[string]api.Text")).Append(" when each item should control its table cells. Use ").Add(helpCodeInline("api.TableMixin")).Append(" only when the type already owns headers and cells directly.").NewLine() + doc = doc.Add(helpCodeBlock("go", ` +type User struct { + Name string + Role string + Active bool +} + +func (u User) PrettyRow(opts any) map[string]api.Text { + status := "disabled" + style := "text-muted" + if u.Active { + status = "active" + style = "text-green-600 font-bold" + } + return map[string]api.Text{ + "Name": clicky.Text(u.Name, "font-bold"), + "Role": clicky.Text(u.Role), + "Status": clicky.Text(status, style), + } +} + +clicky.MustPrint([]User{users[0], users[1]}, clicky.FormatOptions{Format: "pretty"}) +clicky.MustPrint([]User{users[0], users[1]}, clicky.FormatOptions{Format: "markdown", Table: true}) +`)).NewLine() + doc = doc.NewLine() + + doc = doc.Add(helpSection("Tree Rendering")).NewLine() + doc = doc.Add(helpText("Use ")).Add(helpCodeInline("api.TreeNode")).Append(" or ").Add(helpCodeInline("api.TreeMixin")).Append(" for hierarchical data. The same tree can render in the terminal, Markdown, static HTML, or the React Clicky document.").NewLine() + doc = doc.Add(helpCodeBlock("go", ` +type Folder struct { + Name string + Children []Folder +} + +func (f Folder) Pretty() api.Text { + return clicky.Text(f.Name, "font-bold") +} + +func (f Folder) GetChildren() []api.TreeNode { + children := make([]api.TreeNode, 0, len(f.Children)) + for i := range f.Children { + children = append(children, f.Children[i]) + } + return children +} + +clicky.MustPrint([]Folder{root}, clicky.FormatOptions{Format: "tree"}) +`)).NewLine() + doc = doc.NewLine() + + doc = doc.Add(helpSection("Clicky Components: Reference And Examples")).NewLine() + doc = doc.Add(componentExamplesTable()).NewLine() + doc = doc.Add(helpText("Web payloads: ")).Add(helpCodeInline("html-react")).Append(" and ").Add(helpCodeInline("clicky-json")).Append(" preserve the structured Clicky document consumed by ").Add(helpCodeInline("@flanksource/clicky-ui")).Append(".").NewLine() + doc = doc.NewLine() + + doc = doc.Add(helpSection("Anti-patterns")).NewLine() + doc = doc.Add(antiPatternTable()).NewLine() + doc = doc.Add(clicky.Admonition( + api.SeverityWarning, + helpText("Render only at the boundary"), + helpText("Call .ANSI(), .HTML(), .Markdown(), or .String() only when writing to an output sink. Calling them inside Pretty(), PrettyRow(), TreeNode.Pretty(), or component builders discards structure that other formatters need."), + )).NewLine() + doc = doc.NewLine() + + doc = doc.Add(helpSection("Struct Tags")).NewLine() + doc = doc.Add(helpText("Struct tags mirror schema settings for reflection-based formatting. Use them when the Go type is the source of truth; use YAML schema files when the CLI is formatting external JSON or YAML.")).NewLine() + doc = doc.Add(structTagReferenceTable()).NewLine() + doc = doc.Add(helpText("Common field formats include ")).Add(helpCodeInline("currency")).Append(", ").Add(helpCodeInline("date")).Append(", ").Add(helpCodeInline("float")).Append(", ").Add(helpCodeInline("table")).Append(", ").Add(helpCodeInline("tree")).Append(", ").Add(helpCodeInline("struct")).Append(", ").Add(helpCodeInline("hide")).Append(", ").Add(helpCodeInline("compact")).Append(", and ").Add(helpCodeInline("short")).Append(".").NewLine() + doc = doc.Add(helpText("Color options support exact matches and numeric comparisons such as ")).Add(helpCodeInline("green=>=36")).Append(", ").Add(helpCodeInline("yellow=>=24")).Append(", ").Add(helpCodeInline("red=<24")).Append(".").NewLine() + doc = doc.Add(helpCodeBlock("go", ` +type Order struct { + ID string `+"`"+`pretty:"label=Order,style=text-blue-600 font-bold"`+"`"+` + Status string `+"`"+`pretty:"label=Status,green=completed,yellow=processing,red=failed"`+"`"+` + Total float64 `+"`"+`pretty:"label=Total,format=currency,digits=2"`+"`"+` + Lines []Line `+"`"+`pretty:"table,title=Line Items,header_style=bg-blue-50 font-bold"`+"`"+` + Private string `+"`"+`pretty:"hide"`+"`"+` +} +`)).NewLine() + + return doc +} + +func componentExamplesTable() api.TextTable { + table := helpTable("Component", "Example", "Use") + for _, row := range []struct { + name string + example string + use string + }{ + {"clicky.Text", `clicky.Text("healthy", "text-green-600 font-bold")`, "Styled text with child nodes."}, + {"clicky.List", `clicky.List(clicky.Text("one"), clicky.Text("two"))`, "Inline or vertical lists."}, + {"clicky.TextList", `clicky.TextList(clicky.Text("one"), clicky.Text("two"))`, "Join existing Textable values."}, + {"clicky.KeyValue", `clicky.KeyValue("status", "active")`, "Compact key/value pairs."}, + {"clicky.Map", `clicky.Map(map[string]string{"env": "prod"})`, "Sorted description list from a map."}, + {"clicky.Table", `clicky.Table("Name", "Status")`, "Explicit rich tables."}, + {"clicky.Tree", `clicky.Tree(clicky.Text("root"))`, "Explicit tree values."}, + {"clicky.CodeBlock", `clicky.CodeBlock("go", source)`, "Syntax-highlighted code and Markdown fences."}, + {"clicky.Link", `clicky.Link("/orders/1").Append("ORD-1")`, "Structured links."}, + {"clicky.LinkCommand", `clicky.LinkCommand("kubectl get pods").Append("run")`, "Command links/actions for UI surfaces."}, + {"clicky.Button", `clicky.Button("Open", "/orders")`, "Platform-neutral action button."}, + {"clicky.ButtonGroup", `clicky.ButtonGroup(open, cancel)`, "Grouped actions."}, + {"clicky.Badge", `clicky.Badge("active", "bg-green-100 text-green-800")`, "Single-value pill."}, + {"clicky.LabelBadge", `clicky.LabelBadge("Status", "active")`, "Label/value pill."}, + {"clicky.Collapsed", `clicky.Collapsed("Details", detail)`, "Expandable details in HTML; direct content in terminal."}, + {"clicky.Admonition", `clicky.Admonition(api.SeverityWarning, nil, msg)`, "Note, info, tip, warning, and danger callouts."}, + {"clicky.Diff", `clicky.Diff(before, after, "old", "new")`, "Unified diffs with terminal and HTML styling."}, + {"clicky.StackTrace", `clicky.StackTrace(trace, clicky.WithMaxStackFrames(20))`, "Stack traces with source context."}, + {"clicky.Comment", `clicky.Comment("generated")`, "HTML/Markdown comments."}, + {"clicky.HTMLElement", `clicky.HTMLElement("span", "raw")`, "Custom HTML with text fallback."}, + {"clicky.ClickyText", `clicky.ClickyText(detail)`, "JSON field wrapper for Clicky document payloads."}, + } { + table.Rows = append(table.Rows, helpRow( + helpCodeInline(row.name), + helpCodeInline(row.example), + helpText(row.use), + )) + } + return table +} + +func antiPatternTable() api.TextTable { + table := helpTable("Avoid", "Use Instead") + for _, row := range []struct { + avoid string + use string + }{ + {"Calling .ANSI(), .HTML(), .Markdown(), or .String() inside Pretty().", "Return clicky.Text(...) or another api.Textable and let clicky.Format render it."}, + {"Using fmt.Sprintf to align columns or draw tables.", "Return PrettyRow, TableMixin, or clicky.Table(...)."}, + {"Writing to stdout or stderr from Pretty(), PrettyRow(), or TreeNode.Pretty().", "Return structured values; write only in command/output code."}, + {"Embedding raw terminal escape sequences.", "Use Tailwind-like styles such as text-green-600, font-bold, bg-blue-50."}, + {"Flattening links, badges, diffs, or code blocks into plain strings.", "Use clicky.Link, clicky.Badge, clicky.Diff, clicky.CodeBlock, and other components."}, + {"Duplicating CLI flag docs by hand.", "Read Cobra flags live when building command help."}, + } { + table.Rows = append(table.Rows, helpRow(helpText(row.avoid), helpText(row.use))) + } + return table +} + +func structTagReferenceTable() api.TextTable { + table := helpTable("Tag", "Effect") + for _, row := range []struct { + tag string + effect string + }{ + {"label=Order", "Display label."}, + {"style=text-blue-600 font-bold", "Value style."}, + {"label_style=text-muted", "Label style."}, + {"format=currency,digits=2", "Formatter hint and formatter option."}, + {"table,title=Line Items", "Render a slice as a table and set the table title."}, + {"sort=total,dir=desc", "Sort table rows by a field."}, + {"header_style=bg-blue-50 font-bold", "Table header style."}, + {"row_style=bg-gray-50", "Table row style."}, + {"tree,max_depth=3,no_icons", "Render as tree with tree options."}, + {"short", "Use PrettyShort() for compact cell rendering."}, + {"compact", "Compact nested items."}, + {"hide", "Hide the field."}, + {"green=completed,red=failed", "Value-based color options."}, + } { + table.Rows = append(table.Rows, helpRow(helpCodeInline(row.tag), helpText(row.effect))) + } + return table +} + +func prettyFlagsTable(flags *pflag.FlagSet) api.TextTable { + table := helpTable("Flag", "Purpose") + flags.VisitAll(func(flag *pflag.Flag) { + if flag.Hidden { + return + } + + purpose := flag.Usage + if _, ok := flag.Annotations[cobra.BashCompOneRequiredFlag]; ok { + purpose = "Required. " + purpose + } + if flag.DefValue != "" && flag.DefValue != "false" && flag.DefValue != "0" { + purpose += fmt.Sprintf(" Default: %s.", flag.DefValue) + } + + table.Rows = append(table.Rows, helpRow( + helpCodeInline(flagUsage(flag)), + helpText(purpose), + )) + }) + return table +} + +func flagUsage(flag *pflag.Flag) string { + name := "--" + flag.Name + if flag.Shorthand != "" { + name = "-" + flag.Shorthand + ", " + name + } + if flag.Value != nil && flag.Value.Type() != "bool" { + name += " " + flag.Value.Type() + } + return name +} + +func formatSpecTable() api.TextTable { + table := helpTable("Format", "Use") + for _, row := range []struct { + format string + use string + }{ + {"pretty", "ANSI terminal output from Clicky Textable/PrettyData values."}, + {"json, yaml/yml", "Machine-readable data output."}, + {"csv", "Flat table output for spreadsheets and shell pipelines."}, + {"markdown/md", "Markdown tables, code fences, links, lists, and callouts."}, + {"html", "Interactive HTML output."}, + {"html-static", "Static HTML without JavaScript dependencies."}, + {"html-react", "React/Clicky document payload for web UI rendering."}, + {"clicky-json", "Structured Clicky document JSON."}, + {"pdf", "PDF output from the static HTML renderer."}, + {"slack", "Slack Block Kit JSON."}, + {"excel/xlsx", "Excel workbook output; use a file sink."}, + {"tree", "Tree renderer output."}, + } { + table.Rows = append(table.Rows, helpRow(helpCodeInline(row.format), helpText(row.use))) + } + return table +} + +func helpTable(headers ...string) api.TextTable { + table := clicky.Table() + for i, header := range headers { + table.Headers = append(table.Headers, clicky.Text(header, "font-bold text-cyan-600")) + table.FieldNames = append(table.FieldNames, fmt.Sprintf("col%d", i)) + } + return table +} + +func helpRow(cells ...api.Textable) api.TableRow { + row := api.TableRow{} + for i, cell := range cells { + row[fmt.Sprintf("col%d", i)] = api.NewTypedValue(cell) + } + return row +} + +func helpTitle(text string) api.Text { + return clicky.Text(text, "font-bold text-blue-600") +} + +func helpSection(text string) api.Text { + return clicky.Text(text, "font-bold text-cyan-600") +} + +func helpText(text string) api.Text { + return clicky.Text(text) +} + +func helpBullet(text string) api.Text { + return clicky.Text("- ", "text-muted").Append(text) +} + +func helpCode(text string) api.Text { + return clicky.Text(" "+text, "font-mono text-yellow-600") +} + +func helpCodeInline(text string) api.Text { + return clicky.Text(text, "font-mono text-yellow-600") +} + +func helpCodeBlock(language, text string) api.Code { + return clicky.CodeBlock(language, strings.TrimSpace(text)) +} + +func newLintCommand() *cobra.Command { + helpOpts := defaultLintCLIOptions() + cmd := &cobra.Command{ Use: "lint [flags] [packages...]", Short: "Run the clicky API linter on Go packages (defaults to ./...)", Long: `Run the clicky API linter (clickylint) on the given Go packages. Detects bad usage patterns of the clicky text API, such as incorrect -composite literal usage of api.Text. Accepts the same flags as go vet -(-json, -fix, -c=N, etc.); these are passed through to the analyzer -driver, so use "clicky lint -- -help" to see them. +composite literal usage of api.Text, direct stdout writes, and calling +.ANSI()/.HTML()/.Markdown()/.String() inside Pretty/render-builder code. + +By default, clicky lint renders a colored tree summary grouped by rule and +affected file. Use --format json for structured output, or --raw / -- for the +underlying go/analysis driver flags (-json, -fix, -c=N, etc.). If no packages are given, defaults to ./... in the current directory.`, Example: ` clicky lint ./... clicky lint ./pkg/foo ./pkg/bar - clicky lint -json ./...`, + clicky lint --summary-limit 2 ./... + clicky lint --format json ./... + clicky lint -- -json ./...`, Args: cobra.ArbitraryArgs, DisableFlagParsing: true, RunE: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 { - args = []string{"./..."} + if rawArgs, raw := rawLintArgs(args); raw { + return runRawLint(rawArgs) + } + if lintHelpRequested(args) { + return cmd.Help() + } + + opts, packages, err := parseLintArgs(args) + if err != nil { + return err + } + result, err := lint.Run(lint.RunOptions{ + Packages: packages, + IncludeTests: true, + }) + if err != nil { + return err + } + if err := renderLintResult(cmd, result, opts); err != nil { + return err + } + if result.HasIssues() { + return lintExitError{result: result} } - origArgs := os.Args - defer func() { os.Args = origArgs }() - os.Args = append([]string{"clickylint"}, args...) - singlechecker.Main(lint.Analyzer) return nil }, } + bindLintFlags(cmd.Flags(), &helpOpts) + return cmd +} + +type lintCLIOptions struct { + Format string + NoColor bool + Raw bool + SummaryLimit int +} + +type lintExitError struct { + result *lint.Result +} + +func (e lintExitError) Error() string { + if e.result == nil { + return "clickylint found issues" + } + parts := []string{} + if count := len(e.result.Violations); count > 0 { + parts = append(parts, fmt.Sprintf("%d violations", count)) + } + if count := len(e.result.Errors); count > 0 { + parts = append(parts, fmt.Sprintf("%d errors", count)) + } + return "clickylint found " + strings.Join(parts, ", ") +} + +func defaultLintCLIOptions() lintCLIOptions { + return lintCLIOptions{ + Format: "pretty", + SummaryLimit: 5, + } +} + +func bindLintFlags(flags *pflag.FlagSet, opts *lintCLIOptions) { + flags.StringVar(&opts.Format, "format", opts.Format, "Output format: pretty or json") + flags.BoolVar(&opts.NoColor, "no-color", opts.NoColor, "Disable ANSI color output") + flags.BoolVar(&opts.Raw, "raw", opts.Raw, "Use the raw go/analysis singlechecker driver") + flags.IntVar(&opts.SummaryLimit, "summary-limit", opts.SummaryLimit, "Maximum file locations to show per rule") +} + +func parseLintArgs(args []string) (lintCLIOptions, []string, error) { + opts := defaultLintCLIOptions() + flags := pflag.NewFlagSet("lint", pflag.ContinueOnError) + flags.SetOutput(io.Discard) + bindLintFlags(flags, &opts) + if err := flags.Parse(args); err != nil { + return opts, nil, err + } + if opts.Raw { + return opts, nil, fmt.Errorf("--raw must be handled before lint flag parsing") + } + opts.Format = strings.ToLower(strings.TrimSpace(opts.Format)) + if opts.Format == "" { + opts.Format = "pretty" + } + switch opts.Format { + case "pretty", "json": + default: + return opts, nil, fmt.Errorf("unsupported lint format %q (expected pretty or json)", opts.Format) + } + packages := flags.Args() + if len(packages) == 0 { + packages = []string{"./..."} + } + return opts, packages, nil +} + +func renderLintResult(cmd *cobra.Command, result *lint.Result, opts lintCLIOptions) error { + switch opts.Format { + case "json": + encoder := json.NewEncoder(cmd.OutOrStdout()) + encoder.SetIndent("", " ") + return encoder.Encode(result) + default: + formatOpts := formatters.FormatOptions{ + Format: "tree", + NoColor: opts.NoColor, + } + formatOpts.ResolveNoColor() + out, err := clicky.Format(lint.NewSummaryView(result, opts.SummaryLimit), formatOpts) + if err != nil { + return err + } + if !strings.HasSuffix(out, "\n") { + out += "\n" + } + _, err = fmt.Fprint(cmd.OutOrStdout(), out) + return err + } +} + +func lintHelpRequested(args []string) bool { + for _, arg := range args { + if arg == "--help" || arg == "-h" { + return true + } + } + return false +} + +func rawLintArgs(args []string) ([]string, bool) { + if len(args) > 0 && args[0] == "--" { + return defaultRawLintPackages(args[1:]), true + } + for i, arg := range args { + if arg == "--" { + return defaultRawLintPackages(args[i+1:]), true + } + if arg == "--raw" { + rawArgs := append([]string{}, args[:i]...) + rawArgs = append(rawArgs, args[i+1:]...) + if len(rawArgs) > 0 && rawArgs[0] == "--" { + rawArgs = rawArgs[1:] + } + return defaultRawLintPackages(rawArgs), true + } + if strings.HasPrefix(arg, "--raw=") { + value := strings.TrimSpace(strings.TrimPrefix(arg, "--raw=")) + if value == "true" || value == "1" { + rawArgs := append([]string{}, args[:i]...) + rawArgs = append(rawArgs, args[i+1:]...) + if len(rawArgs) > 0 && rawArgs[0] == "--" { + rawArgs = rawArgs[1:] + } + return defaultRawLintPackages(rawArgs), true + } + } + if isAnalyzerDriverFlag(arg) { + return defaultRawLintPackages(args), true + } + } + return nil, false +} + +func defaultRawLintPackages(args []string) []string { + if len(args) == 0 { + return []string{"./..."} + } + return args +} + +func isAnalyzerDriverFlag(arg string) bool { + if arg == "" || arg == "-h" || arg == "--help" { + return false + } + if strings.HasPrefix(arg, "-") && !strings.HasPrefix(arg, "--") { + return true + } + if !strings.HasPrefix(arg, "--") { + return false + } + name := strings.TrimPrefix(arg, "--") + if idx := strings.Index(name, "="); idx >= 0 { + name = name[:idx] + } + switch name { + case "c", "context", "cpuprofile", "debug", "diff", "fix", "flags", "json", "memprofile", "test", "trace", "V": + return true + default: + return false + } +} + +func runRawLint(args []string) error { + origArgs := os.Args + defer func() { os.Args = origArgs }() + os.Args = append([]string{"clickylint"}, args...) + singlechecker.Main(lint.Analyzer) + return nil } func newVersionCommand() *cobra.Command { diff --git a/cobra_command.go b/cobra_command.go index 00a52d3e..0f50d2ce 100644 --- a/cobra_command.go +++ b/cobra_command.go @@ -1,6 +1,7 @@ package clicky import ( + "context" "errors" "fmt" "os" @@ -15,10 +16,20 @@ import ( "github.com/spf13/cobra" ) +// ContextDataFunc is the context-aware data closure stored for a command. Its +// signature matches rpc.ContextDataFunc structurally; the RPC converter +// converts it when wiring RPCOperation.ContextDataFunc. Defined here (the root +// package) to avoid a clicky -> clicky/rpc import cycle. +type ContextDataFunc = func(ctx context.Context, flags map[string]string, args []string) (any, error) + // dataFuncRegistry maps cobra commands created by AddCommand to closures // that can invoke the user function directly with flag values. var dataFuncRegistry sync.Map // map[*cobra.Command]func(flags map[string]string, args []string) (any, error) +// contextDataFuncRegistry maps cobra commands to context-aware data closures. +// When present, both the CLI run path and the RPC converter prefer it. +var contextDataFuncRegistry sync.Map // map[*cobra.Command]ContextDataFunc + // lookupFuncRegistry maps cobra commands to filter metadata lookup closures. var lookupFuncRegistry sync.Map // map[*cobra.Command]func(flags map[string]string, args []string) (any, error) @@ -31,6 +42,15 @@ func GetDataFunc(cmd *cobra.Command) func(flags map[string]string, args []string return nil } +// GetContextDataFunc returns the context-aware data function registered for a +// command, if any. Used by the RPC converter to wire ContextDataFunc. +func GetContextDataFunc(cmd *cobra.Command) ContextDataFunc { + if v, ok := contextDataFuncRegistry.Load(cmd); ok { + return v.(ContextDataFunc) + } + return nil +} + // GetLookupFunc returns the direct lookup function registered for a command, if any. func GetLookupFunc(cmd *cobra.Command) func(flags map[string]string, args []string) (any, error) { if v, ok := lookupFuncRegistry.Load(cmd); ok { @@ -107,6 +127,27 @@ func AddCommand[T any, R any](parent *cobra.Command, opts T, fn func(opts T) (R, } func AddNamedCommand[T any, R any](name string, parent *cobra.Command, opts T, fn func(opts T) (R, error)) *cobra.Command { + return addNamedCommand(name, parent, opts, fn, nil) +} + +// AddNamedCommandWithContext is AddNamedCommand whose closure receives the +// request-scoped context (cmd.Context() on the CLI, r.Context() over HTTP), so +// the subcommand can resolve a per-request database/config instead of a process +// global. Same flag/arg binding and rendering as AddNamedCommand. +func AddNamedCommandWithContext[T any, R any](name string, parent *cobra.Command, opts T, fn func(ctx context.Context, opts T) (R, error)) *cobra.Command { + return addNamedCommand(name, parent, opts, nil, fn) +} + +// addNamedCommand is the shared implementation. Exactly one of fn / fnCtx is +// non-nil; fnCtx, when set, is preferred and is fed cmd.Context() (CLI) or +// r.Context() (HTTP, via the stored ContextDataFunc). +func addNamedCommand[T any, R any]( + name string, + parent *cobra.Command, + opts T, + fn func(opts T) (R, error), + fnCtx func(ctx context.Context, opts T) (R, error), +) *cobra.Command { optsType := reflect.TypeOf(opts) if optsType.Kind() != reflect.Struct { @@ -134,7 +175,7 @@ func AddNamedCommand[T any, R any](name string, parent *cobra.Command, opts T, f subFilters = carrier.Filters() } if meta := GetCommandOpenAPIMeta(parent); meta != nil { - annotateEntityOperationCommand(cmd, parent, "action", "", "collection", name, "", len(subFilters) > 0, false) + annotateEntityOperationCommand(cmd, parent, "action", "", "collection", name, "", len(subFilters) > 0, false, false) } if len(subFilters) > 0 { lookupFuncRegistry.Store(cmd, buildLookupFunc[T](subFilters)) @@ -226,13 +267,23 @@ func AddNamedCommand[T any, R any](name string, parent *cobra.Command, opts T, f // CLI path (cmd.RunE below) keeps pflag because cobra owns its // lifecycle and one process invocation == one Run. capturedFields := append([]flags.FieldInfo(nil), fieldInfos...) - dataFuncRegistry.Store(cmd, func(flagMap map[string]string, args []string) (any, error) { - optsValue := reflect.New(optsType).Elem() - if err := flags.PopulateFromRequest(optsValue, capturedFields, flagMap, args); err != nil { - return nil, err - } - return fn(optsValue.Interface().(T)) - }) + if fnCtx != nil { + contextDataFuncRegistry.Store(cmd, func(ctx context.Context, flagMap map[string]string, args []string) (any, error) { + optsValue := reflect.New(optsType).Elem() + if err := flags.PopulateFromRequest(optsValue, capturedFields, flagMap, args); err != nil { + return nil, err + } + return fnCtx(ctx, optsValue.Interface().(T)) + }) + } else { + dataFuncRegistry.Store(cmd, func(flagMap map[string]string, args []string) (any, error) { + optsValue := reflect.New(optsType).Elem() + if err := flags.PopulateFromRequest(optsValue, capturedFields, flagMap, args); err != nil { + return nil, err + } + return fn(optsValue.Interface().(T)) + }) + } // Set RunE function cmd.RunE = func(c *cobra.Command, args []string) error { @@ -261,8 +312,15 @@ func AddNamedCommand[T any, R any](name string, parent *cobra.Command, opts T, f } } - // Call the function - result, err := fn(optsValue.Interface().(T)) + // Call the function, preferring the context-aware variant (fed the + // command's context so the closure sees cmd.Context()). + var result R + var err error + if fnCtx != nil { + result, err = fnCtx(c.Context(), optsValue.Interface().(T)) + } else { + result, err = fn(optsValue.Interface().(T)) + } if err != nil { // An error that carries a clicky rendering interface // (Pretty/Textable/Tree*) is rendered through the same format diff --git a/e2e_integration_test.go b/e2e_integration_test.go index a585f339..99f2f16f 100644 --- a/e2e_integration_test.go +++ b/e2e_integration_test.go @@ -137,6 +137,212 @@ var _ = Describe("E2E Clicky Command Execution", func() { }) }) + Context("when requesting pretty help", func() { + It("keeps pretty --help concise and points to the detailed guide", func() { + cmd := exec.Command(binaryPath, "pretty", "--help") + cmd.Env = clickyTestEnv(false) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + Expect(err).ToNot(HaveOccurred(), "pretty --help should succeed") + + output := stdout.String() + Expect(output).To(ContainSubstring("clicky help pretty")) + Expect(output).ToNot(ContainSubstring("Anti-patterns")) + }) + + It("shows the colored full pretty-printing API guide", func() { + cmd := exec.Command(binaryPath, "help", "pretty") + cmd.Env = clickyTestEnv(false) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + Expect(err).ToNot(HaveOccurred(), "help pretty should succeed") + + output := stdout.String() + Expect(output).To(ContainSubstring("Clicky Pretty Printing")) + Expect(output).To(ContainSubstring("Intro")) + Expect(output).To(ContainSubstring("Flag Reference")) + Expect(output).To(ContainSubstring("--schema string")) + Expect(output).To(ContainSubstring("Quickstart: Add Pretty() Method")) + Expect(output).To(ContainSubstring("Add Table Support")) + Expect(output).To(ContainSubstring("Tree Rendering")) + Expect(output).To(ContainSubstring("Clicky Components: Reference And Examples")) + Expect(output).To(ContainSubstring("Anti-patterns")) + Expect(output).To(ContainSubstring("Struct Tags")) + Expect(output).To(ContainSubstring("clicky.Format")) + Expect(output).To(ContainSubstring("clicky.Table")) + Expect(output).To(ContainSubstring("clicky-json")) + Expect(output).To(ContainSubstring(".ANSI()")) + Expect(output).To(ContainSubstring("\x1b[")) + }) + + It("honors NO_COLOR for the detailed API guide", func() { + cmd := exec.Command(binaryPath, "help", "pretty") + cmd.Env = clickyTestEnv(true) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + Expect(err).ToNot(HaveOccurred(), "NO_COLOR help pretty should succeed") + + output := stdout.String() + Expect(output).To(ContainSubstring("Clicky Pretty Printing")) + Expect(output).ToNot(ContainSubstring("\x1b[")) + }) + }) + + Context("when running clicky lint", func() { + It("shows the Gavel-style lint summary for violations", func() { + dir := writeLintFixtureModule(map[string]string{ + "bad.go": ` +package fixture + +import "github.com/flanksource/clicky/api" + +type Server struct{ Name string } + +var direct = api.Text{Content: "bad"} + +func (s Server) Pretty() api.Text { + text := api.Text{}.Append(s.Name) + return api.Text{}.Append(text.ANSI()) +} +`, + "other.go": ` +package fixture + +import "github.com/flanksource/clicky/api" + +var other = api.Text{Content: "other"} +`, + }) + cmd := exec.Command(binaryPath, "lint", "--no-color", "--summary-limit", "1", ".") + cmd.Dir = dir + cmd.Env = append(clickyTestEnv(true), "GOWORK=off") + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + Expect(err).To(HaveOccurred(), "lint should fail when violations are found") + + output := stdout.String() + Expect(output).To(ContainSubstring("Lint summary:")) + Expect(output).To(ContainSubstring("clickylint")) + Expect(output).To(ContainSubstring("avoid direct api.Text struct literal")) + Expect(output).To(ContainSubstring("avoid .ANSI() inside clicky render builders")) + Expect(output).To(ContainSubstring("bad.go")) + Expect(output).To(ContainSubstring("... 1 more")) + Expect(stderr.String()).To(ContainSubstring("clickylint found")) + }) + + It("exits successfully for clean packages", func() { + dir := writeLintFixtureModule(map[string]string{ + "good.go": ` +package fixture + +import "github.com/flanksource/clicky/api" + +type Server struct{ Name string } + +func (s Server) Pretty() api.Text { + return api.Text{}.Append(s.Name) +} +`, + }) + cmd := exec.Command(binaryPath, "lint", "--no-color", ".") + cmd.Dir = dir + cmd.Env = append(clickyTestEnv(true), "GOWORK=off") + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + Expect(err).ToNot(HaveOccurred(), "lint should pass for clean packages: %s", stderr.String()) + Expect(stdout.String()).To(ContainSubstring("Lint summary: 0 violations")) + Expect(stdout.String()).To(ContainSubstring("clickylint")) + }) + + It("keeps raw analyzer JSON passthrough for old flags", func() { + dir := writeLintFixtureModule(map[string]string{ + "bad.go": ` +package fixture + +import "github.com/flanksource/clicky/api" + +var direct = api.Text{Content: "bad"} +`, + }) + cmd := exec.Command(binaryPath, "lint", "-json", ".") + cmd.Dir = dir + cmd.Env = append(clickyTestEnv(true), "GOWORK=off") + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + Expect(err).ToNot(HaveOccurred(), "singlechecker -json exits 0 for diagnostics: %s", stderr.String()) + Expect(stdout.String()).To(ContainSubstring(`"clickylint"`)) + Expect(stdout.String()).To(ContainSubstring("avoid direct api.Text struct literal")) + }) + + It("prints structured JSON from the summary runner", func() { + dir := writeLintFixtureModule(map[string]string{ + "bad.go": ` +package fixture + +import "github.com/flanksource/clicky/api" + +var direct = api.Text{Content: "bad"} +`, + }) + cmd := exec.Command(binaryPath, "lint", "--format", "json", ".") + cmd.Dir = dir + cmd.Env = append(clickyTestEnv(true), "GOWORK=off") + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + Expect(err).To(HaveOccurred(), "structured lint JSON should still exit nonzero for violations") + + var result map[string]interface{} + Expect(json.Unmarshal(stdout.Bytes(), &result)).To(Succeed(), "stdout should be JSON: %s", stdout.String()) + Expect(result).To(HaveKeyWithValue("linter", "clickylint")) + Expect(result).To(HaveKey("violations")) + Expect(stderr.String()).To(ContainSubstring("clickylint found")) + }) + + It("documents the lint display flags in command help", func() { + cmd := exec.Command(binaryPath, "lint", "--help") + cmd.Env = clickyTestEnv(true) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + Expect(err).ToNot(HaveOccurred(), "lint --help should succeed: %s", stderr.String()) + Expect(stdout.String()).To(ContainSubstring("--summary-limit")) + Expect(stdout.String()).To(ContainSubstring("--format")) + Expect(stdout.String()).To(ContainSubstring("--raw")) + Expect(stdout.String()).To(ContainSubstring("tree summary")) + }) + }) + Context("when generating OpenAPI spec", func() { It("should produce valid OpenAPI JSON with required components", func() { cmd := exec.Command(binaryPath, "openapi", "generate") @@ -403,3 +609,45 @@ Total: $15,750.00 USD` w.WriteHeader(http.StatusOK) _ = json.NewEncoder(w).Encode(response) } + +func clickyTestEnv(noColor bool) []string { + env := make([]string, 0, len(os.Environ())+3) + for _, item := range os.Environ() { + if strings.HasPrefix(item, "NO_COLOR=") || + strings.HasPrefix(item, "COLOR=") || + strings.HasPrefix(item, "TERM=") { + continue + } + env = append(env, item) + } + env = append(env, "COLOR=", "TERM=xterm-256color") + if noColor { + env = append(env, "NO_COLOR=1") + } else { + env = append(env, "NO_COLOR=") + } + return env +} + +func writeLintFixtureModule(files map[string]string) string { + dir := GinkgoT().TempDir() + repoRoot, err := os.Getwd() + Expect(err).ToNot(HaveOccurred(), "Should resolve repository root for lint fixture") + + mod := fmt.Sprintf(`module example.com/clickylintfixture + +go 1.26.1 + +require github.com/flanksource/clicky v0.0.0 + +replace github.com/flanksource/clicky => %s +`, filepath.ToSlash(repoRoot)) + Expect(os.WriteFile(filepath.Join(dir, "go.mod"), []byte(mod), 0o644)).To(Succeed()) + + for name, content := range files { + path := filepath.Join(dir, name) + Expect(os.MkdirAll(filepath.Dir(path), 0o755)).To(Succeed()) + Expect(os.WriteFile(path, []byte(strings.TrimSpace(content)+"\n"), 0o644)).To(Succeed()) + } + return dir +} diff --git a/entity.go b/entity.go index e3561042..44a17539 100644 --- a/entity.go +++ b/entity.go @@ -1,6 +1,7 @@ package clicky import ( + "context" "encoding/csv" "encoding/json" "fmt" @@ -70,6 +71,10 @@ type EntityOperation struct { Verb string // "list", "get", "create", "update", "delete" Method string // Optional explicit HTTP method for generated RPC/OpenAPI routes. DataFunc func(flags map[string]string, args []string) (any, error) + // ContextDataFunc, when set, is preferred over DataFunc by both the CLI run + // path (fed cmd.Context()) and the RPC executor (fed r.Context()). Lets the + // operation resolve request-scoped state instead of process globals. + ContextDataFunc ContextDataFunc // FlagsType, when non-nil, binds typed flags from the given struct type // onto the generated cobra command and collects their values into the // flag map passed to DataFunc. Used by actions that implement @@ -88,6 +93,11 @@ type ActionInfo struct { Short string Method string DataFunc func(flags map[string]string, args []string) (any, error) + // ContextDataFunc, when set, is preferred over DataFunc by both the CLI run + // path (fed cmd.Context()) and the RPC executor (fed r.Context()), mirroring + // the entity CRUD seam. Lets an action resolve request-scoped state instead + // of process globals. + ContextDataFunc ContextDataFunc // FlagsType, if non-nil, is the struct type whose `flag:"..."` tagged // fields are registered as cobra flags on the generated action command // and populated into the flag map passed to DataFunc. @@ -133,6 +143,20 @@ type Filter[ListOpts any] interface { Options(opts ListOpts) map[string]api.Textable } +// SearchableFilter is an OPTIONAL extension a Filter may implement when its +// option set is too large to enumerate in one shot (e.g. a SQL DISTINCT column +// with thousands of values). The lookup handler type-asserts to it; filters +// that don't implement it keep the plain Options() behaviour unchanged. +// +// OptionsWithQuery returns at most limit options. An empty query means "the +// head set": the first limit options plus total = the true distinct count, so +// the UI can show "… and N more". A non-empty query returns up to limit options +// matching it (server-side search), letting the UI surface values that sort +// beyond the head. total is only meaningful for the head (query == "") call. +type SearchableFilter[ListOpts any] interface { + OptionsWithQuery(opts ListOpts, query string, limit int) (options map[string]api.Textable, total int) +} + // Filterable is implemented by AddNamedCommand options structs that want to // expose typed filter lookups (dropdowns/typeaheads on the web UI, shell // completions on the CLI) on the subcommand itself rather than inheriting @@ -182,6 +206,19 @@ func (l liftedFilter[Outer, Inner]) Options(opts Outer) map[string]api.Textable return l.inner.Options(*l.project(&opts)) } +// OptionsWithQuery forwards to the inner filter when it is searchable, so a +// lifted DistinctColumnFilter keeps its head/search behaviour. When the inner +// filter isn't searchable it degrades to Options() with len() as the total, +// matching the non-truncated path. +func (l liftedFilter[Outer, Inner]) OptionsWithQuery(opts Outer, query string, limit int) (map[string]api.Textable, int) { + inner := *l.project(&opts) + if s, ok := l.inner.(SearchableFilter[Inner]); ok { + return s.OptionsWithQuery(inner, query, limit) + } + options := l.inner.Options(inner) + return options, len(options) +} + // EntityAction is the type-erased registration surface for custom entity // actions. Use Action or ActionWithFlags to construct values. type EntityAction interface { @@ -193,6 +230,7 @@ type actionSpec[R any] struct { short string method string run func(id string, flags map[string]string) (R, error) + runCtx func(ctx context.Context, id string, flags map[string]string) (R, error) flags ActionFlags optionalID bool } @@ -207,6 +245,18 @@ func ActionWithFlags[R any](name string, flags ActionFlags, fn func(id string, f return &actionSpec[R]{name: name, flags: flags, run: fn} } +// ActionWithContext creates a typed action whose run closure receives the +// request-scoped context (cmd.Context() on the CLI, r.Context() over HTTP), so +// it can resolve a per-request database/config instead of a process global. +func ActionWithContext[R any](name string, fn func(ctx context.Context, id string, flags map[string]string) (R, error)) *actionSpec[R] { + return &actionSpec[R]{name: name, runCtx: fn} +} + +// ActionWithFlagsAndContext is ActionWithContext with typed action flags. +func ActionWithFlagsAndContext[R any](name string, flags ActionFlags, fn func(ctx context.Context, id string, flags map[string]string) (R, error)) *actionSpec[R] { + return &actionSpec[R]{name: name, flags: flags, runCtx: fn} +} + func (a *actionSpec[R]) WithShort(short string) *actionSpec[R] { a.short = short return a @@ -232,25 +282,44 @@ func (a *actionSpec[R]) WithOptionalID() *actionSpec[R] { return a } +func (a *actionSpec[R]) actionID(flagMap map[string]string, args []string) (string, error) { + id := flagMap["id"] + if id == "" && len(args) > 0 { + id = args[0] + } + if id == "" && !a.optionalID { + return "", fmt.Errorf("id is required") + } + return id, nil +} + func (a *actionSpec[R]) actionInfo() ActionInfo { - return ActionInfo{ + info := ActionInfo{ Name: a.name, Short: a.short, Method: a.method, FlagsType: actionFlagsType(a.flags), ResponseType: responseTypeOf[R](), OptionalID: a.optionalID, - DataFunc: func(flagMap map[string]string, args []string) (any, error) { - id := flagMap["id"] - if id == "" && len(args) > 0 { - id = args[0] + } + if a.runCtx != nil { + info.ContextDataFunc = func(ctx context.Context, flagMap map[string]string, args []string) (any, error) { + id, err := a.actionID(flagMap, args) + if err != nil { + return nil, err } - if id == "" && !a.optionalID { - return nil, fmt.Errorf("id is required") + return a.runCtx(ctx, id, flagMap) + } + } else { + info.DataFunc = func(flagMap map[string]string, args []string) (any, error) { + id, err := a.actionID(flagMap, args) + if err != nil { + return nil, err } return a.run(id, flagMap) - }, + } } + return info } // EntityBulkAction is the type-erased registration surface for custom bulk @@ -348,6 +417,15 @@ type Entity[T EntityItem, ListOpts any, R any] struct { Aliases []string List func(opts ListOpts) ([]T, error) Get func(id string) (R, error) + // ListWithContext / GetWithContext / GetWithFlagsAndContext are context-aware + // variants of List / Get / GetWithFlags. When set they take precedence over + // their non-context counterparts and receive the request-scoped + // context.Context (r.Context() over HTTP, cmd.Context() on the CLI). Use them + // to resolve per-request state (e.g. a database/config bundle) instead of + // reaching for process globals. + ListWithContext func(ctx context.Context, opts ListOpts) ([]T, error) + GetWithContext func(ctx context.Context, id string) (R, error) + GetWithFlagsAndContext func(ctx context.Context, id string, flags map[string]string) (R, error) // GetFlags, when non-nil, is a zero-value struct value implementing // ActionFlags whose `flag:"..."` tagged fields are registered as // CLI flags on the generated `get` subcommand. The parsed values are @@ -361,7 +439,14 @@ type Entity[T EntityItem, ListOpts any, R any] struct { Create func(body map[string]any) (R, error) Update func(id string, body map[string]any) (R, error) Delete func(id string) error - Filters []Filter[ListOpts] + // CreateWithContext / UpdateWithContext / DeleteWithContext are context-aware + // variants of Create / Update / Delete, mirroring the read-side seam above. + // When set they take precedence and receive the request-scoped context, so + // writes target the request's environment rather than a process global. + CreateWithContext func(ctx context.Context, body map[string]any) (R, error) + UpdateWithContext func(ctx context.Context, id string, body map[string]any) (R, error) + DeleteWithContext func(ctx context.Context, id string) error + Filters []Filter[ListOpts] Actions []EntityAction BulkActions []EntityBulkAction @@ -375,6 +460,40 @@ type Entity[T EntityItem, ListOpts any, R any] struct { ValidArgs func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) } +// entityIDFrom resolves the entity id from the `id` flag or the first +// positional arg, erroring when neither is present. +func entityIDFrom(flagMap map[string]string, args []string) (string, error) { + id := flagMap["id"] + if id == "" && len(args) > 0 { + id = args[0] + } + if id == "" { + return "", fmt.Errorf("id is required") + } + return id, nil +} + +// entityBody copies the flag map into the body map passed to Create. +func entityBody(flagMap map[string]string) map[string]any { + body := make(map[string]any, len(flagMap)) + for k, v := range flagMap { + body[k] = v + } + return body +} + +// entityBodyExcludingID copies the flag map minus the `id` key into the body +// map passed to Update (the id is supplied separately). +func entityBodyExcludingID(flagMap map[string]string) map[string]any { + body := make(map[string]any, len(flagMap)) + for k, v := range flagMap { + if k != "id" { + body[k] = v + } + } + return body +} + // RegisterEntity registers a CRUD entity. Call during init(). // CLI commands and HTTP routes are generated later via GenerateCLI/GenerateRoutes. func RegisterEntity[T EntityItem, ListOpts any, R any](e Entity[T, ListOpts, R]) { @@ -392,10 +511,29 @@ func RegisterEntity[T EntityItem, ListOpts any, R any](e Entity[T, ListOpts, R]) ValidArgs: e.ValidArgs, } - if e.List != nil { - info.Operations = append(info.Operations, EntityOperation{ - Verb: "list", - DataFunc: func(flagMap map[string]string, args []string) (any, error) { + if e.ListWithContext != nil || e.List != nil { + op := EntityOperation{ + Verb: "list", + LookupFunc: buildLookupFunc[ListOpts](e.Filters), + BindCompletions: buildFilterCompletionBinder[ListOpts](e.Filters), + ResponseType: reflect.TypeOf((*T)(nil)).Elem(), + ResponseArray: true, + ResponseEntityID: true, + } + if e.ListWithContext != nil { + op.ContextDataFunc = func(ctx context.Context, flagMap map[string]string, args []string) (any, error) { + opts, err := resolveEntityOpts[ListOpts](flagMap, e.Filters) + if err != nil { + return nil, err + } + items, err := e.ListWithContext(ctx, opts) + if err != nil { + return nil, err + } + return withEntityIDs(items), nil + } + } else { + op.DataFunc = func(flagMap map[string]string, args []string) (any, error) { opts, err := resolveEntityOpts[ListOpts](flagMap, e.Filters) if err != nil { return nil, err @@ -405,99 +543,120 @@ func RegisterEntity[T EntityItem, ListOpts any, R any](e Entity[T, ListOpts, R]) return nil, err } return withEntityIDs(items), nil - }, - LookupFunc: buildLookupFunc[ListOpts](e.Filters), - BindCompletions: buildFilterCompletionBinder[ListOpts](e.Filters), - ResponseType: reflect.TypeOf((*T)(nil)).Elem(), - ResponseArray: true, - ResponseEntityID: true, - }) + } + } + info.Operations = append(info.Operations, op) } - if e.GetWithFlags != nil { + switch { + case e.GetWithFlagsAndContext != nil: info.Operations = append(info.Operations, EntityOperation{ Verb: "get", FlagsType: actionFlagsType(e.GetFlags), ResponseType: responseTypeOf[R](), - DataFunc: func(flagMap map[string]string, args []string) (any, error) { - id := flagMap["id"] - if id == "" && len(args) > 0 { - id = args[0] - } - if id == "" { - return nil, fmt.Errorf("id is required") + ContextDataFunc: func(ctx context.Context, flagMap map[string]string, args []string) (any, error) { + id, err := entityIDFrom(flagMap, args) + if err != nil { + return nil, err } - return e.GetWithFlags(id, flagMap) + return e.GetWithFlagsAndContext(ctx, id, flagMap) }, }) - } else if e.Get != nil { + case e.GetWithFlags != nil: info.Operations = append(info.Operations, EntityOperation{ Verb: "get", + FlagsType: actionFlagsType(e.GetFlags), ResponseType: responseTypeOf[R](), DataFunc: func(flagMap map[string]string, args []string) (any, error) { - id := flagMap["id"] - if id == "" && len(args) > 0 { - id = args[0] - } - if id == "" { - return nil, fmt.Errorf("id is required") + id, err := entityIDFrom(flagMap, args) + if err != nil { + return nil, err } - return e.Get(id) + return e.GetWithFlags(id, flagMap) }, }) - } - - if e.Create != nil { + case e.GetWithContext != nil: info.Operations = append(info.Operations, EntityOperation{ - Verb: "create", + Verb: "get", ResponseType: responseTypeOf[R](), - DataFunc: func(flagMap map[string]string, args []string) (any, error) { - body := make(map[string]any) - for k, v := range flagMap { - body[k] = v + ContextDataFunc: func(ctx context.Context, flagMap map[string]string, args []string) (any, error) { + id, err := entityIDFrom(flagMap, args) + if err != nil { + return nil, err } - return e.Create(body) + return e.GetWithContext(ctx, id) }, }) - } - - if e.Update != nil { + case e.Get != nil: info.Operations = append(info.Operations, EntityOperation{ - Verb: "update", + Verb: "get", ResponseType: responseTypeOf[R](), DataFunc: func(flagMap map[string]string, args []string) (any, error) { - id := flagMap["id"] - if id == "" && len(args) > 0 { - id = args[0] - } - if id == "" { - return nil, fmt.Errorf("id is required") - } - body := make(map[string]any) - for k, v := range flagMap { - if k != "id" { - body[k] = v - } + id, err := entityIDFrom(flagMap, args) + if err != nil { + return nil, err } - return e.Update(id, body) + return e.Get(id) }, }) } - if e.Delete != nil { - info.Operations = append(info.Operations, EntityOperation{ - Verb: "delete", - DataFunc: func(flagMap map[string]string, args []string) (any, error) { - id := flagMap["id"] - if id == "" && len(args) > 0 { - id = args[0] + if e.CreateWithContext != nil || e.Create != nil { + op := EntityOperation{Verb: "create", ResponseType: responseTypeOf[R]()} + if e.CreateWithContext != nil { + op.ContextDataFunc = func(ctx context.Context, flagMap map[string]string, args []string) (any, error) { + return e.CreateWithContext(ctx, entityBody(flagMap)) + } + } else { + op.DataFunc = func(flagMap map[string]string, args []string) (any, error) { + return e.Create(entityBody(flagMap)) + } + } + info.Operations = append(info.Operations, op) + } + + if e.UpdateWithContext != nil || e.Update != nil { + op := EntityOperation{Verb: "update", ResponseType: responseTypeOf[R]()} + if e.UpdateWithContext != nil { + op.ContextDataFunc = func(ctx context.Context, flagMap map[string]string, args []string) (any, error) { + id, err := entityIDFrom(flagMap, args) + if err != nil { + return nil, err } - if id == "" { - return nil, fmt.Errorf("id is required") + return e.UpdateWithContext(ctx, id, entityBodyExcludingID(flagMap)) + } + } else { + op.DataFunc = func(flagMap map[string]string, args []string) (any, error) { + id, err := entityIDFrom(flagMap, args) + if err != nil { + return nil, err + } + return e.Update(id, entityBodyExcludingID(flagMap)) + } + } + info.Operations = append(info.Operations, op) + } + + if e.DeleteWithContext != nil || e.Delete != nil { + op := EntityOperation{Verb: "delete"} + if e.DeleteWithContext != nil { + op.ContextDataFunc = func(ctx context.Context, flagMap map[string]string, args []string) (any, error) { + id, err := entityIDFrom(flagMap, args) + if err != nil { + return nil, err + } + return nil, e.DeleteWithContext(ctx, id) + } + } else { + op.DataFunc = func(flagMap map[string]string, args []string) (any, error) { + id, err := entityIDFrom(flagMap, args) + if err != nil { + return nil, err } return nil, e.Delete(id) - }, - }) + } + } + info.Operations = append(info.Operations, op) } for _, action := range e.Actions { @@ -680,11 +839,12 @@ func generateEntityCLI(parent *cobra.Command, entity EntityInfo) { for _, action := range entity.Actions { generateIDCommand(entityCmd, action.Name, action.Short, EntityOperation{ - Verb: action.Name, - Method: action.Method, - DataFunc: action.DataFunc, - FlagsType: action.FlagsType, - ResponseType: action.ResponseType, + Verb: action.Name, + Method: action.Method, + DataFunc: action.DataFunc, + ContextDataFunc: action.ContextDataFunc, + FlagsType: action.FlagsType, + ResponseType: action.ResponseType, }, entity.ValidArgs, "action", "", "entity", action.Name, "id", false, false, action.OptionalID) } @@ -736,6 +896,27 @@ func generateEntitySubcommand(parent *cobra.Command, entity EntityInfo, op Entit } } +// runEntityOp invokes the operation's data function on the CLI path, preferring +// the context-aware variant (fed cmd.Context()) when present. +func runEntityOp(c *cobra.Command, op EntityOperation, flagMap map[string]string, args []string) (any, error) { + if op.ContextDataFunc != nil { + return op.ContextDataFunc(c.Context(), flagMap, args) + } + return op.DataFunc(flagMap, args) +} + +// storeEntityDataFuncs records the operation's data closures against the command +// so the RPC converter can wire them onto the generated RPCOperation. The +// context-aware variant is stored separately and preferred downstream. +func storeEntityDataFuncs(cmd *cobra.Command, op EntityOperation) { + if op.ContextDataFunc != nil { + contextDataFuncRegistry.Store(cmd, ContextDataFunc(op.ContextDataFunc)) + } + if op.DataFunc != nil { + dataFuncRegistry.Store(cmd, op.DataFunc) + } +} + func generateListCommand(parent *cobra.Command, entity EntityInfo, op EntityOperation) { cmd := &cobra.Command{ Use: "list", @@ -745,7 +926,7 @@ func generateListCommand(parent *cobra.Command, entity EntityInfo, op EntityOper c.Flags().Visit(func(f *pflag.Flag) { flagMap[f.Name] = flagMapValue(f) }) - result, err := op.DataFunc(flagMap, args) + result, err := runEntityOp(c, op, flagMap, args) if err != nil { return err } @@ -760,9 +941,9 @@ func generateListCommand(parent *cobra.Command, entity EntityInfo, op EntityOper op.BindCompletions(cmd) } - annotateEntityOperationCommand(cmd, parent, "list", "", "collection", "", "", op.LookupFunc != nil, false) + annotateEntityOperationCommand(cmd, parent, "list", "", "collection", "", "", op.LookupFunc != nil, false, false) parent.AddCommand(cmd) - dataFuncRegistry.Store(cmd, op.DataFunc) + storeEntityDataFuncs(cmd, op) SetCommandResponseMeta(cmd, ResponseOpenAPIMeta{ Type: op.ResponseType, Array: op.ResponseArray, @@ -811,7 +992,7 @@ func generateIDCommand( flagMap[f.Name] = flagMapValue(f) }) } - result, err := op.DataFunc(flagMap, args) + result, err := runEntityOp(c, op, flagMap, args) if err != nil { return err } @@ -833,9 +1014,9 @@ func generateIDCommand( if method == "" { method = op.Method } - annotateEntityOperationCommand(cmd, parent, metaVerb, method, scope, actionName, idParam, supportsLookup, supportsFilterMode) + annotateEntityOperationCommand(cmd, parent, metaVerb, method, scope, actionName, idParam, supportsLookup, supportsFilterMode, optionalID) parent.AddCommand(cmd) - dataFuncRegistry.Store(cmd, op.DataFunc) + storeEntityDataFuncs(cmd, op) SetCommandResponseMeta(cmd, ResponseOpenAPIMeta{ Type: op.ResponseType, Array: op.ResponseArray, @@ -897,7 +1078,7 @@ func generateBodyCommand(parent *cobra.Command, verb, short string, op EntityOpe } } - result, err := op.DataFunc(flagMap, args) + result, err := runEntityOp(c, op, flagMap, args) if err != nil { return err } @@ -913,9 +1094,9 @@ func generateBodyCommand(parent *cobra.Command, verb, short string, op EntityOpe scope = "entity" idParam = "id" } - annotateEntityOperationCommand(cmd, parent, verb, "", scope, "", idParam, false, false) + annotateEntityOperationCommand(cmd, parent, verb, "", scope, "", idParam, false, false, false) parent.AddCommand(cmd) - dataFuncRegistry.Store(cmd, op.DataFunc) + storeEntityDataFuncs(cmd, op) SetCommandResponseMeta(cmd, ResponseOpenAPIMeta{ Type: op.ResponseType, Array: op.ResponseArray, @@ -960,7 +1141,7 @@ func generateBulkActionCommand(parent *cobra.Command, ba BulkActionInfo) { } } - annotateEntityOperationCommand(cmd, parent, "action", "", "collection", ba.Name, "id", ba.LookupFunc != nil, ba.FilterFunc != nil) + annotateEntityOperationCommand(cmd, parent, "action", "", "collection", ba.Name, "id", ba.LookupFunc != nil, ba.FilterFunc != nil, false) parent.AddCommand(cmd) dataFuncRegistry.Store(cmd, execute) SetCommandResponseMeta(cmd, ResponseOpenAPIMeta{Type: ba.ResponseType}) @@ -1092,6 +1273,20 @@ func resolveEntityOpts[T any](flagMap map[string]string, filters []Filter[T]) (T return opts, err } +// lookupOptionsLimit caps the number of options a single lookup filter returns, +// both for the head set and per-search. SearchableFilter implementations enumerate +// only this many; the UI shows "… and N more" against the reported total and +// re-queries as the user types. +const lookupOptionsLimit = 200 + +// Reserved flag keys the lookup handler injects from the request query so a +// single filter can be searched server-side. They never collide with entity +// flags because of the __lookup_ prefix. +const ( + lookupFilterKeyParam = "__lookup_filter" + lookupQueryParam = "__lookup_q" +) + func buildLookupFunc[T any](filters []Filter[T]) func(flags map[string]string, args []string) (any, error) { if len(filters) == 0 { return nil @@ -1100,6 +1295,13 @@ func buildLookupFunc[T any](filters []Filter[T]) func(flags map[string]string, a lookupMetadata := buildLookupMetadata[T]() return func(flagMap map[string]string, args []string) (any, error) { + searchKey := flagMap[lookupFilterKeyParam] + searchQuery := flagMap[lookupQueryParam] + // The reserved params aren't entity flags; strip them before buildOpts + // so they don't leak into the filter ListOpts. + delete(flagMap, lookupFilterKeyParam) + delete(flagMap, lookupQueryParam) + opts, err := buildOpts[T](flagMap) if err != nil { return nil, err @@ -1115,13 +1317,31 @@ func buildLookupFunc[T any](filters []Filter[T]) func(flags map[string]string, a } for _, filter := range filters { meta := lookupMetadata[filter.Key()] - response.Filters[filter.Key()] = entityLookupFilter{ + entry := entityLookupFilter{ Label: filter.Label(), - Options: toClickyNodeMap(filter.Options(opts)), Selected: toClickyNodeMap(selected[filter.Key()]), Multi: meta.Multi, Type: meta.Type, } + + searchable, isSearchable := filter.(SearchableFilter[T]) + switch { + case isSearchable && searchKey == filter.Key() && searchQuery != "": + // Targeted server-side search: return matches for this filter only. + options, _ := searchable.OptionsWithQuery(opts, searchQuery, lookupOptionsLimit) + entry.Options = toClickyNodeMap(options) + case isSearchable: + // Head request: first N options plus the true distinct total so + // the UI can show "… and N more" and decide whether to search. + options, total := searchable.OptionsWithQuery(opts, "", lookupOptionsLimit) + entry.Options = toClickyNodeMap(options) + entry.Total = total + entry.Truncated = total > len(options) + default: + entry.Options = toClickyNodeMap(filter.Options(opts)) + } + + response.Filters[filter.Key()] = entry } return response, nil } diff --git a/entity_annotations.go b/entity_annotations.go index cd61fc37..e385ef75 100644 --- a/entity_annotations.go +++ b/entity_annotations.go @@ -19,6 +19,7 @@ const ( annotationClickyOperationIDParam = "clicky/operation-id-param" annotationClickySupportsLookup = "clicky/supports-lookup" annotationClickySupportsFilterMode = "clicky/supports-filter-mode" + annotationClickyOptionalID = "clicky/operation-optional-id" ) // CommandOpenAPIMeta is the clicky-specific metadata attached to generated @@ -36,6 +37,11 @@ type CommandOpenAPIMeta struct { IDParam string SupportsLookup bool SupportsFilterMode bool + // OptionalID is true when the operation's positional id is optional + // (WithOptionalID). Such operations are invocable without an id, so the + // generated REST path must NOT carry an {id} segment — otherwise the no-id + // call collides with the entity's get-by-id route. + OptionalID bool } func GetCommandOpenAPIMeta(cmd *cobra.Command) *CommandOpenAPIMeta { @@ -55,6 +61,7 @@ func GetCommandOpenAPIMeta(cmd *cobra.Command) *CommandOpenAPIMeta { IDParam: cmd.Annotations[annotationClickyOperationIDParam], SupportsLookup: parseAnnotationBool(cmd.Annotations[annotationClickySupportsLookup]), SupportsFilterMode: parseAnnotationBool(cmd.Annotations[annotationClickySupportsFilterMode]), + OptionalID: parseAnnotationBool(cmd.Annotations[annotationClickyOptionalID]), } if meta.Entity == "" { @@ -85,6 +92,7 @@ func annotateEntityOperationCommand( idParam string, supportsLookup bool, supportsFilterMode bool, + optionalID bool, ) { if cmd == nil { return @@ -98,6 +106,7 @@ func annotateEntityOperationCommand( setCommandAnnotation(cmd, annotationClickyOperationIDParam, idParam) setCommandAnnotation(cmd, annotationClickySupportsLookup, strconv.FormatBool(supportsLookup)) setCommandAnnotation(cmd, annotationClickySupportsFilterMode, strconv.FormatBool(supportsFilterMode)) + setCommandAnnotation(cmd, annotationClickyOptionalID, strconv.FormatBool(optionalID)) } func inheritEntityAnnotations(cmd *cobra.Command, parent *cobra.Command) { diff --git a/entity_searchable_filter_test.go b/entity_searchable_filter_test.go new file mode 100644 index 00000000..44592261 --- /dev/null +++ b/entity_searchable_filter_test.go @@ -0,0 +1,152 @@ +package clicky + +import ( + "fmt" + "testing" + + "github.com/flanksource/clicky/api" +) + +type searchableTestOpts struct { + Plan MultiFilter `flag:"plan"` +} + +// searchableTestFilter is a SearchableFilter stub over a fixed pool of "plan" +// values. The head returns the first `limit` sorted values plus the true total; +// a query returns the matching subset (substring), including values that sort +// past the head — the behaviour the real DistinctColumnFilter provides via SQL. +type searchableTestFilter struct { + pool []string + lastQuery string + lastLimit int + headRequests int +} + +func (f *searchableTestFilter) Key() string { return "plan" } +func (f *searchableTestFilter) Label() string { return "Plan" } + +func (f *searchableTestFilter) Lookup(opts *searchableTestOpts) (map[string]api.Textable, error) { + return nil, nil +} + +func (f *searchableTestFilter) Options(opts searchableTestOpts) map[string]api.Textable { + head, _ := f.OptionsWithQuery(opts, "", lookupOptionsLimit) + return head +} + +func (f *searchableTestFilter) OptionsWithQuery(_ searchableTestOpts, query string, limit int) (map[string]api.Textable, int) { + f.lastQuery = query + f.lastLimit = limit + out := make(map[string]api.Textable) + count := 0 + for _, v := range f.pool { + if query != "" && !contains(v, query) { + continue + } + if count >= limit { + if query == "" { + continue // keep counting the head total below + } + break + } + out[v] = api.Text{Content: v} + count++ + } + if query == "" { + f.headRequests++ + return out, len(f.pool) + } + // match total isn't meaningful for a query call + return out, 0 +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +func makePool(n int) []string { + pool := make([]string, n) + for i := 0; i < n; i++ { + // zero-padded so lexical sort == numeric order + pool[i] = fmt.Sprintf("plan-%04d", i) + } + return pool +} + +func TestBuildLookupFunc_SearchableHeadReportsTruncatedAndTotal(t *testing.T) { + filter := &searchableTestFilter{pool: makePool(250)} + lookup := buildLookupFunc[searchableTestOpts]([]Filter[searchableTestOpts]{filter}) + + data, err := lookup(map[string]string{}, nil) + if err != nil { + t.Fatalf("lookup returned error: %v", err) + } + resp := data.(entityLookupResponse) + plan := resp.Filters["plan"] + + if plan.Total != 250 { + t.Fatalf("expected total 250, got %d", plan.Total) + } + if !plan.Truncated { + t.Fatalf("expected truncated=true when total (250) > head (%d)", len(plan.Options)) + } + if len(plan.Options) != lookupOptionsLimit { + t.Fatalf("expected head of %d options, got %d", lookupOptionsLimit, len(plan.Options)) + } + if filter.lastQuery != "" { + t.Fatalf("head request must pass an empty query, got %q", filter.lastQuery) + } +} + +func TestBuildLookupFunc_SearchableQueryFindsBeyondHead(t *testing.T) { + filter := &searchableTestFilter{pool: makePool(250)} + lookup := buildLookupFunc[searchableTestOpts]([]Filter[searchableTestOpts]{filter}) + + // plan-0225 sorts past the 200-item head, so only a server-side search finds it. + data, err := lookup(map[string]string{ + "__lookup_filter": "plan", + "__lookup_q": "plan-0225", + }, nil) + if err != nil { + t.Fatalf("lookup returned error: %v", err) + } + resp := data.(entityLookupResponse) + plan := resp.Filters["plan"] + + if _, ok := plan.Options["plan-0225"]; !ok { + t.Fatalf("expected beyond-head value plan-0225 in search results, got %v", keysOf(plan.Options)) + } + if filter.lastQuery != "plan-0225" { + t.Fatalf("expected the filter to receive query plan-0225, got %q", filter.lastQuery) + } +} + +func TestBuildLookupFunc_StripsReservedParamsFromOpts(t *testing.T) { + filter := &searchableTestFilter{pool: makePool(3)} + lookup := buildLookupFunc[searchableTestOpts]([]Filter[searchableTestOpts]{filter}) + + flags := map[string]string{"__lookup_filter": "plan", "__lookup_q": "plan-0001"} + if _, err := lookup(flags, nil); err != nil { + t.Fatalf("lookup returned error: %v", err) + } + // The reserved params must be removed so they never leak into buildOpts. + if _, ok := flags["__lookup_filter"]; ok { + t.Fatalf("__lookup_filter must be stripped from the flag map") + } + if _, ok := flags["__lookup_q"]; ok { + t.Fatalf("__lookup_q must be stripped from the flag map") + } +} + +func keysOf(m map[string]clickyNode) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/examples/cobra-command-demo.go b/examples/cobra-command-demo.go index 089a708e..beab7042 100644 --- a/examples/cobra-command-demo.go +++ b/examples/cobra-command-demo.go @@ -1,3 +1,5 @@ +//go:build ignore + package main import ( diff --git a/examples/dynamic-executor.go b/examples/dynamic-executor.go index 2626a700..ba8417d5 100644 --- a/examples/dynamic-executor.go +++ b/examples/dynamic-executor.go @@ -1,3 +1,5 @@ +//go:build ignore + package main import ( diff --git a/examples/echo-comprehensive-demo.go b/examples/echo-comprehensive-demo.go index e251f484..e22a6c5c 100644 --- a/examples/echo-comprehensive-demo.go +++ b/examples/echo-comprehensive-demo.go @@ -1,3 +1,5 @@ +//go:build ignore + package main import ( diff --git a/examples/echo-middleware-demo.go b/examples/echo-middleware-demo.go index db908cd7..9872feae 100644 --- a/examples/echo-middleware-demo.go +++ b/examples/echo-middleware-demo.go @@ -1,3 +1,5 @@ +//go:build ignore + package main import ( diff --git a/examples/enitity/webapp/dist/index.html b/examples/enitity/webapp/dist/index.html new file mode 100644 index 00000000..c16628a7 --- /dev/null +++ b/examples/enitity/webapp/dist/index.html @@ -0,0 +1,17 @@ + + + + + + Clicky Entity Example + + +
+ + + diff --git a/examples/file-tree-demo.go b/examples/file-tree-demo.go index 1f858d12..59d3edea 100644 --- a/examples/file-tree-demo.go +++ b/examples/file-tree-demo.go @@ -1,3 +1,5 @@ +//go:build ignore + package main import ( diff --git a/examples/fluent-integration.go b/examples/fluent-integration.go index b3aacc54..6eaf7fa3 100644 --- a/examples/fluent-integration.go +++ b/examples/fluent-integration.go @@ -1,3 +1,5 @@ +//go:build ignore + package main import ( diff --git a/examples/go.mod b/examples/go.mod index 95abffa7..5098f04d 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -4,10 +4,7 @@ go 1.26.1 require ( github.com/flanksource/clicky v1.21.1 - github.com/flanksource/commons v1.51.3 - github.com/labstack/echo/v4 v4.13.4 github.com/spf13/cobra v1.10.2 - github.com/spf13/pflag v1.0.10 ) require ( @@ -43,8 +40,9 @@ require ( github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fatih/color v1.18.0 // indirect - github.com/flanksource/gomplate/v3 v3.24.78 // indirect - github.com/flanksource/is-healthy v1.0.87 // indirect + github.com/flanksource/commons v1.51.3 // indirect + github.com/flanksource/gomplate/v3 v3.24.82 // indirect + github.com/flanksource/is-healthy v1.0.88 // indirect github.com/flanksource/kubectl-neat v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect @@ -57,7 +55,6 @@ require ( github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/cel-go v0.27.0 // indirect github.com/google/uuid v1.6.0 // indirect @@ -73,7 +70,6 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect - github.com/labstack/gommon v0.4.2 // indirect github.com/lmittmann/tint v1.1.3 // indirect github.com/lrita/cmap v0.0.0-20231108122212-cb084a67f554 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect @@ -117,6 +113,7 @@ require ( github.com/shirou/gopsutil/v3 v3.24.5 // indirect github.com/shoenig/go-m1cpu v0.1.7 // indirect github.com/sirupsen/logrus v1.9.4 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect @@ -127,8 +124,6 @@ require ( github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/ugorji/go/codec v1.3.1 // indirect - github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasttemplate v1.2.2 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xuri/efp v0.0.1 // indirect @@ -146,20 +141,19 @@ require ( golang.org/x/sys v0.42.0 // indirect golang.org/x/term v0.41.0 // indirect golang.org/x/text v0.35.0 // indirect - golang.org/x/time v0.14.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/sourcemap.v1 v1.0.5 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/api v0.35.4 // indirect - k8s.io/apiextensions-apiserver v0.35.4 // indirect - k8s.io/apimachinery v0.35.4 // indirect - k8s.io/client-go v0.35.4 // indirect + k8s.io/api v0.36.1 // indirect + k8s.io/apiextensions-apiserver v0.36.1 // indirect + k8s.io/apimachinery v0.36.1 // indirect + k8s.io/client-go v0.36.1 // indirect k8s.io/klog/v2 v2.140.0 // indirect - k8s.io/kube-openapi v0.0.0-20260304202019-5b3e3fdb0acf // indirect + k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect layeh.com/gopher-json v0.0.0-20201124131017-552bb3c4c3bf // indirect sigs.k8s.io/gateway-api v1.5.1 // indirect diff --git a/examples/go.sum b/examples/go.sum index e36c30ea..a9170ffe 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -1,95 +1,70 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= -cloud.google.com/go/auth v0.16.5/go.mod h1:utzRfHMP+Vv0mpOkTRQoWD2q3BatTOoWbA7gCc2dUhQ= -cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.12.0/go.mod h1:J7MUC/wtRpfGVbQ5sIItY5/FuVWmvzlY21WAOfQnq/I= -github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0/go.mod h1:fSvRkb8d26z9dbL40Uf/OO6Vo9iExtZK3D0ulRV+8M0= -github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= -github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/Khan/genqlient v0.8.1/go.mod h1:R2G6DzjBvCbhjsEajfRjbWdVglSH/73kSivC9TLWVjU= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= -github.com/Venafi/vcert/v5 v5.12.2/go.mod h1:x3l0pB0q0E6wuhPe7nzfkUEwwraK7amnBWQ4LtT1bbw= github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY= github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk= github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw= github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM= -github.com/akamai/AkamaiOPEN-edgegrid-golang/v12 v12.0.0/go.mod h1:Bf6hnZkloZnfL4I/gFGnMMMdMHiu/ERnSOWtFgnodDk= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= -github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= github.com/antchfx/xmlquery v1.5.1 h1:T9I4Ns1EXiWHy0IqKupGhnfTQtJwlGrpXtauYOoNv78= github.com/antchfx/xmlquery v1.5.1/go.mod h1:bVqnl7TaDXSReKINrhZz+2E/PbCu2tUahb+wZ7WZNT8= github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI= github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= -github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= -github.com/aws/aws-sdk-go-v2/config v1.32.9 h1:ktda/mtAydeObvJXlHzyGpK1xcsLaP16zfUPDGoW90A= -github.com/aws/aws-sdk-go-v2/config v1.32.9/go.mod h1:U+fCQ+9QKsLW786BCfEjYRj34VVTbPdsLP3CHSYXMOI= -github.com/aws/aws-sdk-go-v2/credentials v1.19.9 h1:sWvTKsyrMlJGEuj/WgrwilpoJ6Xa1+KhIpGdzw7mMU8= -github.com/aws/aws-sdk-go-v2/credentials v1.19.9/go.mod h1:+J44MBhmfVY/lETFiKI+klz0Vym2aCmIjqgClMmW82w= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU= -github.com/aws/aws-sdk-go-v2/service/route53 v1.58.4/go.mod h1:xNLZLn4SusktBQ5moqUOgiDKGz3a7vHwF4W0KD+WBPc= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 h1:+VTRawC4iVY58pS/lzpo0lnoa/SYNGF4/B/3/U5ro8Y= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.10/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= -github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= -github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg= +github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ= +github.com/aws/aws-sdk-go-v2/config v1.32.16 h1:Q0iQ7quUgJP0F/SCRTieScnaMdXr9h/2+wze1u3cNeM= +github.com/aws/aws-sdk-go-v2/config v1.32.16/go.mod h1:duCCnJEFqpt2RC6no1iK6q+8HpwOAkiUua0pY507dQc= +github.com/aws/aws-sdk-go-v2/credentials v1.19.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTtEVmAVyo+NFLHBhH4= +github.com/aws/aws-sdk-go-v2/credentials v1.19.15/go.mod h1:gJiYyMOjNg8OEdRWOf3CrFQxM2a98qmrtjx1zuiQfB8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 h1:IOGsJ1xVWhsi+ZO7/NW8OuZZBtMJLZbk4P5HDjJO0jQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22/go.mod h1:b+hYdbU+jGKfXE8kKM6g1+h+L/Go3vMvzlxBsiuGsxg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.10/go.mod h1:p6+MXNxW7IA6dMgHfTAzljuwSKD0NCm/4lbS4t6+7vI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 h1:x6bKbmDhsgSZwv6q19wY/u3rLk/3FGjJWyqKcIRufpE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.16/go.mod h1:CudnEVKRtLn0+3uMV0yEXZ+YZOKnAtUJ5DmDhilVnIw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 h1:oK/njaL8GtyEihkWMD4k3VgHCT64RQKkZwh0DG5j8ak= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20/go.mod h1:JHs8/y1f3zY7U5WcuzoJ/yAYGYtNIVPKLIbp61euvmg= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcuxk8l+PG4+A0exds= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo= +github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U= +github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= 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.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= -github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= -github.com/cert-manager/cert-manager v1.19.4 h1:7lOkSYj+nJNjgGFfAznQzPpOfWX+1Kgz6xUXwTa/K5k= -github.com/cert-manager/cert-manager v1.19.4/go.mod h1:9uBnn3IK9NxjjuXmQDYhwOwFUU5BtGVB1g/voPvvcVw= github.com/cert-manager/cert-manager v1.20.0 h1:czZamsFJ1YdKPhpv+SopJZN9t3W68vQBnFYUevSHGqY= github.com/cert-manager/cert-manager v1.20.0/go.mod h1:3oparI7R5JyJMNXntYod9p6DucIZ84st7y/9kL6cbBE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -98,11 +73,8 @@ github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/x github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= 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.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= -github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= @@ -115,27 +87,20 @@ github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSTh github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= -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/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= 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/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= -github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= -github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= +github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= -github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= @@ -146,62 +111,40 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ= github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= -github.com/digitalocean/godo v1.165.1/go.mod h1:xQsWpVCCbkDrWisHA72hPzPlnC+4W5w/McZY5ij9uvU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU= github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A= 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/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= -github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/flanksource/commons v1.51.0 h1:Q7v8WTl1e/TsSPT00+QP9wdVwaJZhMsUOLBYFNPcZh4= -github.com/flanksource/commons v1.51.0/go.mod h1:IAZ94jQnUMMRShmbkg4+sPHEdyQfVc7a1tl2BnL1VF8= github.com/flanksource/commons v1.51.3 h1:sgQZ2s0XJTub4qmIlzRyH+eYXJP6UXmreCataP9mE7E= github.com/flanksource/commons v1.51.3/go.mod h1:BxXJzAsRxsw0la7Y/ShEABa8ZbtGIdRi7PCRjiHDCJE= -github.com/flanksource/gomplate/v3 v3.24.77 h1:StGjXRxeADULhxA9rXbPzAPk+C/952gNeU+LUO7qRXk= -github.com/flanksource/gomplate/v3 v3.24.77/go.mod h1:IioRhY9IdwdlPI/xdZOiLHGsFJqW4Sp6yXmrqCFuh+k= -github.com/flanksource/gomplate/v3 v3.24.78 h1:zAxVx4VOUT6VDgBp5nDY4zOVbm4Y1O7+eTs0OqMrb1k= -github.com/flanksource/gomplate/v3 v3.24.78/go.mod h1:RzIg+YwNQI0eUV61LtqmhNN2Qw8ebm1cGa6IhNQmkWE= -github.com/flanksource/is-healthy v1.0.86 h1:N4oxqdW8/YN7+EmEHk4rStpYXzuGADiMAXP2T75bynw= -github.com/flanksource/is-healthy v1.0.86/go.mod h1:xoEeeCamUiW8fGWGyRaGL9NU4xQzou+sgDC5raguDew= -github.com/flanksource/is-healthy v1.0.87 h1:wSK9wI9tu//gdKO9JxyZe8ZQ5H7MCpwG17KdbWaiMeM= -github.com/flanksource/is-healthy v1.0.87/go.mod h1:xoEeeCamUiW8fGWGyRaGL9NU4xQzou+sgDC5raguDew= +github.com/flanksource/gomplate/v3 v3.24.82 h1:22HOZYeNRMX40G8OF3qRAqRoR5Lkf8Vm4x3WDqugZkE= +github.com/flanksource/gomplate/v3 v3.24.82/go.mod h1:NMMZkFsjbLy/8iY8Fip5N86Y0PP6lZeq+kmPwpVVIL0= +github.com/flanksource/is-healthy v1.0.88 h1:ATQuKoNdp8Qfzf41/eMFajmT0qzOmZlZNG5eLK41RFo= +github.com/flanksource/is-healthy v1.0.88/go.mod h1:faa7yhEtdftTiMxg9Cp2bZ/fGaaB04KSjEm+bkghyY0= github.com/flanksource/kubectl-neat v1.0.4 h1:t5/9CqgE84oEtB0KitgJ2+WIeLfD+RhXSxYrqb4X8yI= github.com/flanksource/kubectl-neat v1.0.4/go.mod h1:Un/Voyh3cmiZNKQrW/TkAl28nAA7vwnwDGVjRErKjOw= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= -github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a/go.mod h1:I79BieaU4fxrw4LMXby6q5OS9XnoR9UIKLOzDFjUmuw= github.com/go-jose/go-jose/v3 v3.0.5 h1:BLLJWbC4nMZOfuPVxoZIxeYsn6Nl2r1fITaJ78UQlVQ= github.com/go-jose/go-jose/v3 v3.0.5/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= -github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA= -github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= -github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= -github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= -github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= -github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= -github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew= github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= @@ -210,81 +153,38 @@ github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUW github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/golangci/plugin-module-register v0.1.2/go.mod h1:1+QGTsKBvAIvPvoY/os+G5eoqxWn70HYDm2uvUyGuVw= -github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= -github.com/google/certificate-transparency-go v1.3.1/go.mod h1:gg+UQlx6caKEDQ9EElFOujyxEQEfOiQzAt6782Bvi8k= -github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= -github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= 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.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= -github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= -github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= 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= github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= -github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= -github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1/go.mod h1:lXGCsh6c22WGtjr+qGHj1otzZpV/1kwTMAqkwZsnWRU= -github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.0/go.mod h1:qOchhhIlmRcqk/O9uCo/puJlyo07YINaIqdZfZG3Jkc= -github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= -github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/hairyhenderson/toml v0.4.2-0.20210923231440-40456b8e66cf h1:I1sbT4ZbIt9i+hB1zfKw2mE8C12TuGxPiW7YmtLbPa4= github.com/hairyhenderson/toml v0.4.2-0.20210923231440-40456b8e66cf/go.mod h1:jDHmWDKZY6MIIYltYYfW4Rs7hQ50oS4qf/6spSiZAxY= github.com/hairyhenderson/yaml v0.0.0-20220618171115-2d35fca545ce h1:cVkYhlWAxwuS2/Yp6qPtcl0fGpcWxuZNonywHZ6/I+s= github.com/hairyhenderson/yaml v0.0.0-20220618171115-2d35fca545ce/go.mod h1:7TyiGlHI+IO+iJbqRZ82QbFtvgj/AIcFm5qc9DLn7Kc= -github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= -github.com/hashicorp/go-hmac-drbg v0.0.0-20210916214228-a6e5a68489f6/go.mod h1:y+HSOcOGB48PkUxNyLAiCiY6rEENu+E+Ss4LG8QHwf4= -github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= -github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= -github.com/hashicorp/go-secure-stdlib/cryptoutil v0.1.1/go.mod h1:hH8rgXHh9fPSDPerG6WzABHsHF+9ZpLhRI1LPk4JZ8c= -github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= -github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= -github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= -github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= -github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= -github.com/hashicorp/vault/sdk v0.20.0/go.mod h1:xEjAt/n/2lHBAkYiRPRmvf1d5B6HlisPh2pELlRCosk= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/itchyny/go-yaml v0.0.0-20251001235044-fca9a0999f15/go.mod h1:Tmbz8uw5I/I6NvVpEGuhzlElCGS5hPoXJkt7l+ul6LE= -github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc= -github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg= github.com/itchyny/gojq v0.12.19 h1:ttXA0XCLEMoaLOz5lSeFOZ6u6Q3QxmG46vfgI4O0DEs= github.com/itchyny/gojq v0.12.19/go.mod h1:5galtVPDywX8SPSOrqjGxkBeDhSxEW1gSxoy7tn1iZY= -github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA= -github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= github.com/itchyny/timefmt-go v0.1.8 h1:1YEo1JvfXeAHKdjelbYr/uCuhkybaHCeTkH8Bo791OI= github.com/itchyny/timefmt-go v0.1.8/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= github.com/jeremywohl/flatten v1.0.1 h1:LrsxmB3hfwJuE+ptGOijix1PIfOoKLJ3Uee/mzbgtrs= @@ -293,52 +193,33 @@ github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 h1:liMMTbpW github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= -github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= -github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= -github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= -github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I= github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/lrita/cmap v0.0.0-20231108122212-cb084a67f554 h1:a0+bIffIh/HdvvgtPQLRhOef1VDSxZ+8bQiyjQlJzqc= github.com/lrita/cmap v0.0.0-20231108122212-cb084a67f554/go.mod h1:Cn9TaoncDT8Tt/aJ7CIZy+t48MaZWDEwhu1bBXwrzLI= 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/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k= github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= -github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 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.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= -github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/mattn/go-sqlite3 v1.14.30/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -353,11 +234,6 @@ 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/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= -github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= -github.com/nrdcg/goacmedns v0.2.0/go.mod h1:T5o6+xvSLrQpugmwHvrSNkzWht0UGAwj2ACBMhh73Cg= -github.com/ohler55/ojg v1.28.0 h1:8xClBgMIRRJGDUC9xNe7NprP4kD2C3mQMeon3wY4KXA= -github.com/ohler55/ojg v1.28.0/go.mod h1:/Y5dGWkekv9ocnUixuETqiL58f+5pAsUfg5P8e7Pa2o= github.com/ohler55/ojg v1.28.1 h1:Xy93DelhLSZNeWv8GPKtP6qMqkUlZlAxBP/AQcC5RfY= github.com/ohler55/ojg v1.28.1/go.mod h1:/Y5dGWkekv9ocnUixuETqiL58f+5pAsUfg5P8e7Pa2o= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= @@ -368,25 +244,17 @@ github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmC github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= github.com/olekukonko/ll v0.1.7 h1:WyK1YZwOTUKHEXZz3VydBDT5t3zDqa9yI8iJg5PHon4= github.com/olekukonko/ll v0.1.7/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw= -github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA= -github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM= github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= -github.com/olekukonko/ts v0.0.0-20171002115256-78ecb04241c0/go.mod h1:F/7q8/HZz+TXjlsoZQQKVYvXTZaFH4QRa3y+j1p7MS0= github.com/onsi/ginkgo/v2 v2.28.0 h1:Rrf+lVLmtlBIKv6KrIGJCjyY8N36vDVcutbGJkyqjJc= github.com/onsi/ginkgo/v2 v2.28.0/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/orisano/pixelmatch v0.0.0-20230914042517-fa304d1dc785/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= -github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0/go.mod h1:lAVhWwbNaveeJmxrxuSTxMgKpF6DjnuVpn6T8WiBwYQ= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= -github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= -github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -395,8 +263,7 @@ github.com/playwright-community/playwright-go v0.5700.1/go.mod h1:MlSn1dZrx8rszb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= @@ -420,32 +287,24 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= -github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/samber/oops v1.21.0 h1:18atcO4oEigNFuGXqr3NZWZ6P0XOSEXyBSAMXdQRxTc= github.com/samber/oops v1.21.0/go.mod h1:Hsm/sKPxtCfPh0w/cE3xVoRfSiE1joDRiStPAsmG9bo= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= -github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= -github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/shoenig/go-m1cpu v0.1.7 h1:C76Yd0ObKR82W4vhfjZiCp0HxcSZ8Nqd84v+HZ0qyI0= github.com/shoenig/go-m1cpu v0.1.7/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= -github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= -github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= +github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= -github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= -github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -470,32 +329,20 @@ github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160 h1:NSWpaDaurcAJY7PkL8Xt0 github.com/tj/assert v0.0.0-20190920132354-ee03d75cd160/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0= github.com/tj/go-naturaldate v1.3.0 h1:OgJIPkR/Jk4bFMBLbxZ8w+QUxwjqSvzd9x+yXocY4RI= github.com/tj/go-naturaldate v1.3.0/go.mod h1:rpUbjivDKiS1BlfMGc2qUKNZ/yxgthOfmytQs8d8hKk= -github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= -github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= -github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= -github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= -github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= -github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/vadimi/go-http-ntlm v1.0.3 h1:o6n2vAtP1MlLT73jIXuQYryIcWzXyMN0SCQWZ2QVLLc= github.com/vadimi/go-http-ntlm v1.0.3/go.mod h1:SwhhmybQ4Yn1mC53UPmQ6MCrBX6UvJHlS1Xt89OmM9M= github.com/vadimi/go-http-ntlm/v2 v2.5.0 h1:sddEWZumD7GoeNkfFZyZq01pq6CB4U6L73EBw3X7vTU= github.com/vadimi/go-http-ntlm/v2 v2.5.0/go.mod h1:KduY1xBqaL8Q2Rh/erMvRQHKoj3VAT9GNYxe9EH+rOo= github.com/vadimi/go-ntlm v1.2.1 h1:y2xZf/a5+BJlYNJIIulP1q8F438H9bU7aGcYE53vghQ= github.com/vadimi/go-ntlm v1.2.1/go.mod h1:hPTY60eLSKGj9oUJAB+kZiLs2Cg5eKdH60aLczM9rMg= -github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= -github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= -github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= -github.com/vektah/gqlparser/v2 v2.5.30/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= -github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= 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/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= @@ -504,48 +351,26 @@ github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzx github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= -github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= -go.etcd.io/etcd/api/v3 v3.6.5/go.mod h1:ob0/oWA/UQQlT1BmaEkWQzI0sJ1M0Et0mMpaABxguOQ= -go.etcd.io/etcd/client/pkg/v3 v3.6.5/go.mod h1:8Wx3eGRPiy0qOFMZT/hfvdos+DjEaPxdIDiCDUv/FQk= -go.etcd.io/etcd/client/v3 v3.6.5/go.mod h1:ZqwG/7TAFZ0BJ0jXRPoJjKQJtbFo/9NIY8uoFFKcCyo= -go.etcd.io/etcd/pkg/v3 v3.6.5/go.mod h1:uqrXrzmMIJDEy5j00bCqhVLzR5jEJIwDp5wTlLwPGOU= -go.etcd.io/etcd/server/v3 v3.6.5/go.mod h1:PLuhyVXz8WWRhzXDsl3A3zv/+aK9e4A9lpQkqawIaH0= -go.etcd.io/raft/v3 v3.6.0/go.mod h1:nLvLevg6+xrVtHUmVaTcTz603gQPHfh7kUAwV6YpfGo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= -go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 h1:T0Ec2E+3YZf5bgTNQVet8iTDW7oIk03tXHq+wkwIDnE= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0/go.mod h1:30v2gqH+vYGJsesLWFov8u47EpYTcIQcBjKpI6pJThg= -go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= -go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= -go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= -go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= @@ -558,12 +383,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= -golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= @@ -574,8 +395,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= @@ -587,12 +408,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= -golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -601,8 +420,6 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -619,13 +436,10 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 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.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= @@ -637,8 +451,6 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -650,12 +462,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= @@ -663,36 +471,22 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= -golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= -golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -google.golang.org/api v0.251.0/go.mod h1:Rwy0lPf/TD7+T2VhYcffCHhyyInyuxGjICxdfLqT7KI= -google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= -google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= -gopkg.in/readline.v1 v1.0.0-20160726135117-62c6fe619375/go.mod h1:lNEQeAhU009zbRxng+XOj5ITVgY24WcbNnQopyfKoYQ= gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI= gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -705,51 +499,29 @@ gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= -k8s.io/api v0.35.2 h1:tW7mWc2RpxW7HS4CoRXhtYHSzme1PN1UjGHJ1bdrtdw= -k8s.io/api v0.35.2/go.mod h1:7AJfqGoAZcwSFhOjcGM7WV05QxMMgUaChNfLTXDRE60= -k8s.io/api v0.35.4 h1:P7nFYKl5vo9AGUp1Z+Pmd3p2tA7bX2wbFWCvDeRv988= -k8s.io/api v0.35.4/go.mod h1:yl4lqySWOgYJJf9RERXKUwE9g2y+CkuwG+xmcOK8wXU= -k8s.io/apiextensions-apiserver v0.35.2 h1:iyStXHoJZsUXPh/nFAsjC29rjJWdSgUmG1XpApE29c0= -k8s.io/apiextensions-apiserver v0.35.2/go.mod h1:OdyGvcO1FtMDWQ+rRh/Ei3b6X3g2+ZDHd0MSRGeS8rU= -k8s.io/apiextensions-apiserver v0.35.4 h1:HeP+Upp7ItdvnyGmub0yoix+2z5+ev4M5cE5TCgtOUU= -k8s.io/apiextensions-apiserver v0.35.4/go.mod h1:ogQlk+stIE8mnoRthSYCwlOS12fVqgWFiErMwPaXA7c= -k8s.io/apimachinery v0.35.2 h1:NqsM/mmZA7sHW02JZ9RTtk3wInRgbVxL8MPfzSANAK8= -k8s.io/apimachinery v0.35.2/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= -k8s.io/apimachinery v0.35.4 h1:xtdom9RG7e+yDp71uoXoJDWEE2eOiHgeO4GdBzwWpds= -k8s.io/apimachinery v0.35.4/go.mod h1:NNi1taPOpep0jOj+oRha3mBJPqvi0hGdaV8TCqGQ+cc= -k8s.io/apiserver v0.35.2/go.mod h1:CROJUAu0tfjZLyYgSeBsBan2T7LUJGh0ucWwTCSSk7g= -k8s.io/client-go v0.35.2 h1:YUfPefdGJA4aljDdayAXkc98DnPkIetMl4PrKX97W9o= -k8s.io/client-go v0.35.2/go.mod h1:4QqEwh4oQpeK8AaefZ0jwTFJw/9kIjdQi0jpKeYvz7g= -k8s.io/client-go v0.35.4 h1:DN6fyaGuzK64UvnKO5fOA6ymSjvfGAnCAHAR0C66kD8= -k8s.io/client-go v0.35.4/go.mod h1:2Pg9WpsS4NeOpoYTfHHfMxBG8zFMSAUi4O/qoiJC3nY= -k8s.io/code-generator v0.35.2/go.mod h1:id4XLCm0yAQq5nlvyfAKibMOKnMjzlesAwGw6kM3Adc= -k8s.io/component-base v0.35.2/go.mod h1:B1iBJjooe6xIJYUucAxb26RwhAjzx0gHnqO9htWIX+0= -k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b/go.mod h1:CgujABENc3KuTrcsdpGmrrASjtQsWCT7R99mEV4U/fM= -k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= -k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/api v0.36.1 h1:XbL/EMj8K2aJpJtePmqUyQMsM0D4QI2pvl7YKJ20FTY= +k8s.io/api v0.36.1/go.mod h1:KOWo4ey3TINlXjeHVuwB3i+tXXnu+UcwFBHlI/9dvEo= +k8s.io/apiextensions-apiserver v0.36.1 h1:6JfYmPUsuUIHuN+3QxutXYWj492RqF5fBSx67GYK5Ks= +k8s.io/apiextensions-apiserver v0.36.1/go.mod h1:pLzZin90riwisdzKwv/GoTwENooytoIx5zWJb4Hkby8= +k8s.io/apimachinery v0.36.1 h1:G63Gjx2W+q0YD+72Vo8oY0nDnePVwnuzTmmy5ENrVSA= +k8s.io/apimachinery v0.36.1/go.mod h1:ibYOR00vW/I1kzvi5SF0dRuJ52BvKtfvRdOn35GPQ+8= +k8s.io/client-go v0.36.1 h1:FN/K8QIT2CEDt+2WB2HnWrUANZ50AP5GII43/SP2JR0= +k8s.io/client-go v0.36.1/go.mod h1:s6rAnCtTGYDQnpNjEhSaISV+2O8jwruZ6m3QOYBFbtU= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= -k8s.io/kms v0.35.2/go.mod h1:VT+4ekZAdrZDMgShK37vvlyHUVhwI9t/9tvh0AyCWmQ= -k8s.io/kube-aggregator v0.34.1/go.mod h1:RU8j+5ERfp0h+gIvWtxRPfsa5nK7rboDm8RST8BJfYQ= -k8s.io/kube-openapi v0.0.0-20260304202019-5b3e3fdb0acf h1:btPscg4cMql0XdYK2jLsJcNEKmACJz8l+U7geC06FiM= -k8s.io/kube-openapi v0.0.0-20260304202019-5b3e3fdb0acf/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= layeh.com/gopher-json v0.0.0-20201124131017-552bb3c4c3bf h1:rRz0YsF7VXj9fXRF6yQgFI7DzST+hsI3TeFSGupntu0= layeh.com/gopher-json v0.0.0-20201124131017-552bb3c4c3bf/go.mod h1:ivKkcY8Zxw5ba0jldhZCYYQfGdb2K6u9tbYK1AwMIBc= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= -sigs.k8s.io/controller-runtime v0.22.3/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= -sigs.k8s.io/gateway-api v1.5.0 h1:duoo14Ky/fJXpjpmyMISE2RTBGnfCg8zICfTYLTnBJA= -sigs.k8s.io/gateway-api v1.5.0/go.mod h1:GvCETiaMAlLym5CovLxGjS0NysqFk3+Yuq3/rh6QL2o= sigs.k8s.io/gateway-api v1.5.1 h1:RqVRIlkhLhUO8wOHKTLnTJA6o/1un4po4/6M1nRzdd0= sigs.k8s.io/gateway-api v1.5.1/go.mod h1:GvCETiaMAlLym5CovLxGjS0NysqFk3+Yuq3/rh6QL2o= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E= sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= -software.sslmate.com/src/go-pkcs12 v0.6.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/examples/hello-world-server.go b/examples/hello-world-server.go index 53426c26..4da51316 100644 --- a/examples/hello-world-server.go +++ b/examples/hello-world-server.go @@ -1,3 +1,5 @@ +//go:build ignore + package main import ( diff --git a/examples/mcp-integration.go b/examples/mcp-integration.go index b22ff2ec..51aa3a80 100644 --- a/examples/mcp-integration.go +++ b/examples/mcp-integration.go @@ -1,3 +1,5 @@ +//go:build ignore + package main import ( diff --git a/examples/pdf_widgets_example.go b/examples/pdf_widgets_example.go index a4fdc09d..b2f47ea1 100644 --- a/examples/pdf_widgets_example.go +++ b/examples/pdf_widgets_example.go @@ -1,3 +1,5 @@ +//go:build ignore + package main import ( diff --git a/examples/swagger-serve.go b/examples/swagger-serve.go index 8a035f71..662549dd 100644 --- a/examples/swagger-serve.go +++ b/examples/swagger-serve.go @@ -1,3 +1,5 @@ +//go:build ignore + package main import ( diff --git a/examples/task-basic.go b/examples/task-basic.go index 0217cd0f..5ef718b3 100644 --- a/examples/task-basic.go +++ b/examples/task-basic.go @@ -1,3 +1,5 @@ +//go:build ignore + package main import ( diff --git a/examples/task-concurrent.go b/examples/task-concurrent.go index 2bb18598..da493015 100644 --- a/examples/task-concurrent.go +++ b/examples/task-concurrent.go @@ -1,3 +1,5 @@ +//go:build ignore + package main import ( diff --git a/examples/task-dependencies.go b/examples/task-dependencies.go index 70742f5a..6aedb0ad 100644 --- a/examples/task-dependencies.go +++ b/examples/task-dependencies.go @@ -1,3 +1,5 @@ +//go:build ignore + package main import ( diff --git a/examples/task-manager-demo.go b/examples/task-manager-demo.go index f273bd81..f24bf9d7 100644 --- a/examples/task-manager-demo.go +++ b/examples/task-manager-demo.go @@ -1,3 +1,5 @@ +//go:build ignore + /* Task Manager Demo - Comprehensive demonstration of clicky task manager capabilities diff --git a/examples/task-retry.go b/examples/task-retry.go index e1f7f8bb..ec248327 100644 --- a/examples/task-retry.go +++ b/examples/task-retry.go @@ -1,3 +1,5 @@ +//go:build ignore + package main import ( diff --git a/format.go b/format.go index c9953563..e3274273 100644 --- a/format.go +++ b/format.go @@ -147,6 +147,14 @@ func CompactList[T any](items []T) api.Textable { return list } +func List(items ...api.Textable) api.List { + return api.List{Items: items} +} + +func TextList(items ...api.Textable) api.TextList { + return api.TextList(items) +} + func Text(content string, tailwindClasses ...string) api.Text { return api.Text{ Content: content, @@ -167,6 +175,22 @@ func WithKey(key string, value api.Textable) api.Keyed { return api.Keyed{Key: key, Value: value} } +func Table(headers ...string) api.TextTable { + table := api.TextTable{} + for _, header := range headers { + table.Headers = append(table.Headers, Text(header, "font-bold")) + table.FieldNames = append(table.FieldNames, header) + } + return table +} + +func Tree(node api.Textable, children ...api.TextTree) api.TextTree { + return api.TextTree{ + Node: node, + Children: children, + } +} + func Collapsed(label string, content api.Textable, styles ...string) api.Collapsed { return api.Collapsed{ Label: label, @@ -175,6 +199,105 @@ func Collapsed(label string, content api.Textable, styles ...string) api.Collaps } } +func Button(label, href string, options ...func(*api.Button)) api.Button { + button := api.Button{Label: label, Href: href} + for _, option := range options { + if option != nil { + option(&button) + } + } + return button +} + +func ButtonID(id string) func(*api.Button) { + return func(button *api.Button) { + button.ID = id + } +} + +func ButtonPayload(payload string) func(*api.Button) { + return func(button *api.Button) { + button.Payload = payload + } +} + +func ButtonVariant(variant string) func(*api.Button) { + return func(button *api.Button) { + button.Variant = variant + } +} + +func ButtonGroup(buttons ...api.Button) api.ButtonGroup { + return api.ButtonGroup{Buttons: buttons} +} + +func LabelBadge(label, value string, options ...func(*api.LabelBadge)) api.LabelBadge { + badge := api.LabelBadge{Label: label, Value: value} + for _, option := range options { + if option != nil { + option(&badge) + } + } + return badge +} + +func LabelBadgeColor(color string) func(*api.LabelBadge) { + return func(badge *api.LabelBadge) { + badge.Color = color + } +} + +func LabelBadgeTextColor(color string) func(*api.LabelBadge) { + return func(badge *api.LabelBadge) { + badge.TextColor = color + } +} + +func LabelBadgeShape(shape string) func(*api.LabelBadge) { + return func(badge *api.LabelBadge) { + badge.Shape = shape + } +} + +func LabelBadgeIcon(icon string) func(*api.LabelBadge) { + return func(badge *api.LabelBadge) { + badge.Icon = icon + } +} + +func Admonition(severity api.Severity, title, body api.Textable) api.Admonition { + return api.Admonition{ + Severity: severity, + Title: title, + Body: body, + } +} + +func Diff(before, after, fromLabel, toLabel string) api.Diff { + return api.NewDiff(before, after, fromLabel, toLabel) +} + +func Comment(text string) api.Comment { + return api.Comment(text) +} + +func HTMLElement(tag, content string, attrs ...map[string]string) api.HtmlElement { + var attributes map[string]string + if len(attrs) > 0 { + attributes = attrs[0] + } + return api.HtmlElement{ + Tag: tag, + Attributes: attributes, + Content: content, + Fallback: Text(content), + } +} + +func ClickyText(text api.Textable) formatters.ClickyText { + return formatters.ClickyText{Textable: text} +} + var KeyValue = api.KeyValue var CodeBlock = api.CodeBlock var Badge = api.Badge diff --git a/formatters/options.go b/formatters/options.go index 4d02af76..f2937f6c 100644 --- a/formatters/options.go +++ b/formatters/options.go @@ -32,6 +32,9 @@ var knownFormats = map[string]bool{ "tree": true, } +const FormatSpecHelp = "Output format. Use one stdout format (pretty|json|yaml|yml|csv|markdown|md|html|html-static|html-react|clicky-json|pdf|slack|excel|xlsx|tree), " + + "or comma-separated format=file sinks such as 'pretty,json=out.json,markdown=summary.md'." + // canonicalFormat normalises common aliases (e.g. "md" -> "markdown"). func canonicalFormat(f string) string { switch f { @@ -262,7 +265,7 @@ func MergeOptions(opts ...FormatOptions) FormatOptions { // BindFlags adds formatting flags to the provided flag set func BindFlags(flags *flag.FlagSet, options *FormatOptions) { - flags.StringVar(&options.Format, "format", "", "Output format: pretty, json, yaml, csv, html, pdf, markdown, slack") + flags.StringVar(&options.Format, "format", "", FormatSpecHelp) flags.StringVar(&options.Output, "output", "", "Output file pattern (optional, uses stdout if not specified)") flags.BoolVar(&options.NoColor, "no-color", false, "Disable colored output") flags.BoolVar(&options.DumpSchema, "dump-schema", false, "Dump the schema to stderr for debugging") @@ -285,7 +288,7 @@ func BindFlags(flags *flag.FlagSet, options *FormatOptions) { // BindPFlags adds formatting flags to the provided pflag set (for cobra) func BindPFlags(flags *pflag.FlagSet, options *FormatOptions) { - flags.StringVar(&options.Format, "format", "", "Output format: pretty, json, yaml, csv, html, pdf, markdown, slack") + flags.StringVar(&options.Format, "format", "", FormatSpecHelp) flags.StringVar(&options.Output, "output", "", "Output file pattern (optional, uses stdout if not specified)") flags.BoolVar(&options.NoColor, "no-color", false, "Disable colored output") flags.BoolVar(&options.DumpSchema, "dump-schema", false, "Dump the schema to stderr for debugging") diff --git a/formatters/options_test.go b/formatters/options_test.go index 5d6f5ec5..b0d1d1da 100644 --- a/formatters/options_test.go +++ b/formatters/options_test.go @@ -26,6 +26,9 @@ func TestResolveNoColor(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Setenv("NO_COLOR", "") + t.Setenv("COLOR", "") + t.Setenv("TERM", "xterm-256color") for k, v := range tt.env { t.Setenv(k, v) } diff --git a/go.mod b/go.mod index b6074c39..929bf085 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,6 @@ require ( github.com/google/uuid v1.6.0 github.com/itchyny/gojq v0.12.19 github.com/labstack/echo/v4 v4.13.4 - github.com/mattn/go-sqlite3 v1.14.38 github.com/muesli/termenv v0.16.0 github.com/olekukonko/tablewriter v1.1.4 github.com/onsi/ginkgo/v2 v2.28.0 @@ -45,6 +44,7 @@ require ( golang.org/x/time v0.14.0 golang.org/x/tools v0.43.0 gopkg.in/yaml.v3 v3.0.1 + modernc.org/sqlite v1.51.0 ) require ( @@ -133,6 +133,7 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ohler55/ojg v1.28.1 // indirect github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect @@ -146,6 +147,7 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/richardlehane/mscfb v1.0.6 // indirect github.com/richardlehane/msoleps v1.0.6 // indirect github.com/rivo/uniseg v0.4.7 // indirect @@ -200,6 +202,9 @@ require ( k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect layeh.com/gopher-json v0.0.0-20201124131017-552bb3c4c3bf // indirect + modernc.org/libc v1.72.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect sigs.k8s.io/gateway-api v1.5.1 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect diff --git a/go.sum b/go.sum index 19d7fdbc..a916d042 100644 --- a/go.sum +++ b/go.sum @@ -190,6 +190,8 @@ github.com/hairyhenderson/toml v0.4.2-0.20210923231440-40456b8e66cf h1:I1sbT4ZbI github.com/hairyhenderson/toml v0.4.2-0.20210923231440-40456b8e66cf/go.mod h1:jDHmWDKZY6MIIYltYYfW4Rs7hQ50oS4qf/6spSiZAxY= github.com/hairyhenderson/yaml v0.0.0-20220618171115-2d35fca545ce h1:cVkYhlWAxwuS2/Yp6qPtcl0fGpcWxuZNonywHZ6/I+s= github.com/hairyhenderson/yaml v0.0.0-20220618171115-2d35fca545ce/go.mod h1:7TyiGlHI+IO+iJbqRZ82QbFtvgj/AIcFm5qc9DLn7Kc= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -235,8 +237,6 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4= -github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= @@ -257,6 +257,8 @@ 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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ohler55/ojg v1.28.1 h1:Xy93DelhLSZNeWv8GPKtP6qMqkUlZlAxBP/AQcC5RfY= github.com/ohler55/ojg v1.28.1/go.mod h1:/Y5dGWkekv9ocnUixuETqiL58f+5pAsUfg5P8e7Pa2o= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= @@ -296,6 +298,8 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= @@ -545,6 +549,34 @@ k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0x k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= layeh.com/gopher-json v0.0.0-20201124131017-552bb3c4c3bf h1:rRz0YsF7VXj9fXRF6yQgFI7DzST+hsI3TeFSGupntu0= layeh.com/gopher-json v0.0.0-20201124131017-552bb3c4c3bf/go.mod h1:ivKkcY8Zxw5ba0jldhZCYYQfGdb2K6u9tbYK1AwMIBc= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU= +modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U= +modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= sigs.k8s.io/gateway-api v1.5.1 h1:RqVRIlkhLhUO8wOHKTLnTJA6o/1un4po4/6M1nRzdd0= sigs.k8s.io/gateway-api v1.5.1/go.mod h1:GvCETiaMAlLym5CovLxGjS0NysqFk3+Yuq3/rh6QL2o= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= diff --git a/lint/analyzer.go b/lint/analyzer.go index cd25d54f..a1308886 100644 --- a/lint/analyzer.go +++ b/lint/analyzer.go @@ -14,9 +14,28 @@ import ( const clickyAPIPath = "github.com/flanksource/clicky/api" const clickyModulePrefix = "github.com/flanksource/clicky" +var helperBackedTypes = map[string]string{ + "Text": "clicky.Text(...) or api.Text{}.Append(...)", + "List": "clicky.List(...)", + "TextList": "clicky.TextList(...)", + "TextTable": "clicky.Table(...) or PrettyRow/TableMixin", + "TextTree": "clicky.Tree(...) or TreeNode/TreeMixin", + "Code": "clicky.CodeBlock(...)", + "Button": "clicky.Button(...)", + "ButtonGroup": "clicky.ButtonGroup(...)", + "KeyValuePair": "clicky.KeyValue(...)", + "DescriptionList": "clicky.Map(...)", + "LabelBadge": "clicky.LabelBadge(...)", + "Admonition": "clicky.Admonition(...)", + "Collapsed": "clicky.Collapsed(...)", + "Diff": "clicky.Diff(...)", + "StackTrace": "clicky.StackTrace(...)", + "HtmlElement": "clicky.HTMLElement(...)", +} + var Analyzer = &analysis.Analyzer{ Name: "clickylint", - Doc: "detects bad usage patterns of the clicky text API", + Doc: "detects bad usage patterns of the clicky text API and render builders", Run: run, Requires: []*analysis.Analyzer{inspect.Analyzer}, } @@ -40,6 +59,7 @@ func run(pass *analysis.Pass) (any, error) { checkCompositeLiteral(pass, node) case *ast.FuncDecl: checkFuncReturnType(pass, node) + checkRenderBuilderRenderCalls(pass, node) case *ast.CallExpr: checkDirectStdout(pass, node) } @@ -72,16 +92,53 @@ func isClickyTextTypesType(t types.Type) bool { return obj.Name() == "Text" && obj.Pkg() != nil && obj.Pkg().Path() == clickyAPIPath } +func isClickyNamedType(t types.Type, names ...string) bool { + typeName, ok := clickyAPITypeName(t) + if !ok { + return false + } + for _, candidate := range names { + if typeName == candidate { + return true + } + } + return false +} + +func clickyAPITypeName(t types.Type) (string, bool) { + named, ok := t.(*types.Named) + if !ok { + if ptr, ok := t.(*types.Pointer); ok { + return clickyAPITypeName(ptr.Elem()) + } + return "", false + } + obj := named.Obj() + if obj.Pkg() == nil || obj.Pkg().Path() != clickyAPIPath { + return "", false + } + return obj.Name(), true +} + // checkCompositeLiteral checks rules 1 (struct literal), 3 (concat content), 4 (children literal) func checkCompositeLiteral(pass *analysis.Pass, lit *ast.CompositeLit) { - if !isClickyTextType(pass, lit) { + typeName, isClickyAPIType := clickyAPITypeName(pass.TypesInfo.TypeOf(lit)) + if !isClickyAPIType { return } - // Rule 1: non-empty struct literal - if len(lit.Elts) > 0 { + if typeName == "Text" { + if len(lit.Elts) > 0 { + pass.Reportf(lit.Pos(), + "avoid direct api.Text struct literal; use clicky.Text(...) or api.Text{}.Append(...)") + } + } else if helper, ok := helperBackedTypes[typeName]; ok { pass.Reportf(lit.Pos(), - "avoid direct api.Text struct literal; use NewText().Build() or api.Text{}.Append(...)") + "avoid direct api.%s struct literal; use %s", typeName, helper) + } + + if typeName != "Text" { + return } for _, elt := range lit.Elts { @@ -158,7 +215,7 @@ func checkFuncReturnType(pass *analysis.Pass, fn *ast.FuncDecl) { } name := fn.Name.Name - if name == "Pretty" || name == "PrettyFull" || name == "PrettyRow" { + if name == "Pretty" || name == "PrettyFull" || name == "PrettyShort" || name == "PrettyRow" { return } diff --git a/lint/render_builder.go b/lint/render_builder.go new file mode 100644 index 00000000..96405835 --- /dev/null +++ b/lint/render_builder.go @@ -0,0 +1,152 @@ +package lint + +import ( + "go/ast" + "go/types" + + "golang.org/x/tools/go/analysis" +) + +var prettyBuilderNames = map[string]bool{ + "Pretty": true, + "PrettyFull": true, + "PrettyShort": true, + "PrettyRow": true, +} + +var renderExtractionMethods = map[string]bool{ + "ANSI": true, + "HTML": true, + "Markdown": true, + "MarkdownSlack": true, + "StaticHTML": true, + "CompactHTML": true, + "AsANSI": true, + "AsHTML": true, + "AsMarkdown": true, + "String": true, +} + +func checkRenderBuilderRenderCalls(pass *analysis.Pass, fn *ast.FuncDecl) { + if fn.Body == nil || !isRenderBuilderFunc(pass, fn) { + return + } + + ast.Inspect(fn.Body, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || !renderExtractionMethods[sel.Sel.Name] { + return true + } + if !isClickyRenderType(pass, pass.TypesInfo.TypeOf(sel.X)) { + return true + } + pass.Reportf(call.Pos(), + "avoid .%s() inside clicky render builders; return api.Text/api.Textable and let the formatter render it", + sel.Sel.Name) + return true + }) +} + +func isRenderBuilderFunc(pass *analysis.Pass, fn *ast.FuncDecl) bool { + if prettyBuilderNames[fn.Name.Name] { + return true + } + if fn.Type.Results == nil { + return false + } + for _, result := range fn.Type.Results.List { + if isClickyRenderType(pass, pass.TypesInfo.TypeOf(result.Type)) { + return true + } + } + return false +} + +func isClickyRenderType(pass *analysis.Pass, t types.Type) bool { + if t == nil { + return false + } + + switch tt := t.(type) { + case *types.Pointer: + return isClickyRenderType(pass, tt.Elem()) || implementsClickyTextable(pass, tt) + case *types.Slice: + return isClickyRenderType(pass, tt.Elem()) + case *types.Array: + return isClickyRenderType(pass, tt.Elem()) + case *types.Map: + return isClickyRenderType(pass, tt.Elem()) + case *types.Named: + if isClickyNamedType(tt, + "Text", + "Textable", + "TextList", + "TextTable", + "TextTree", + "Code", + "Link", + "LinkCommand", + "Button", + "ButtonGroup", + "KeyValuePair", + "DescriptionList", + "LabelBadge", + "Admonition", + "Collapsed", + "Diff", + "StackTrace", + "HtmlElement", + "TypedValue", + "TypedList", + "TypedMap", + "TextMap", + ) { + return true + } + if implementsClickyTextable(pass, tt) || implementsClickyTextable(pass, types.NewPointer(tt)) { + return true + } + return isClickyRenderType(pass, tt.Underlying()) + case *types.Interface: + return isClickyNamedType(t, "Textable") || implementsClickyTextable(pass, tt) + default: + return implementsClickyTextable(pass, t) + } +} + +func implementsClickyTextable(pass *analysis.Pass, t types.Type) bool { + if t == nil { + return false + } + iface := clickyTextableInterface(pass) + if iface == nil { + return false + } + return types.Implements(t, iface) +} + +func clickyTextableInterface(pass *analysis.Pass) *types.Interface { + for _, pkg := range pass.Pkg.Imports() { + if pkg.Path() != clickyAPIPath { + continue + } + obj := pkg.Scope().Lookup("Textable") + if obj == nil { + return nil + } + named, ok := obj.Type().(*types.Named) + if !ok { + return nil + } + iface, ok := named.Underlying().(*types.Interface) + if !ok { + return nil + } + return iface + } + return nil +} diff --git a/lint/runner.go b/lint/runner.go new file mode 100644 index 00000000..e323a01e --- /dev/null +++ b/lint/runner.go @@ -0,0 +1,181 @@ +package lint + +import ( + "fmt" + "os" + "sort" + "strings" + "time" + + "golang.org/x/tools/go/analysis" + "golang.org/x/tools/go/analysis/checker" + "golang.org/x/tools/go/packages" +) + +// RunOptions controls the standalone clickylint runner used by the clicky CLI. +type RunOptions struct { + Packages []string `json:"packages,omitempty"` + WorkDir string `json:"work_dir,omitempty"` + IncludeTests bool `json:"include_tests"` +} + +// Result is the normalized lint result rendered by the CLI and JSON output. +type Result struct { + Linter string `json:"linter"` + WorkDir string `json:"work_dir,omitempty"` + PackageCount int `json:"package_count"` + Success bool `json:"success"` + Duration time.Duration `json:"duration"` + Violations []Violation `json:"violations,omitempty"` + Errors []string `json:"errors,omitempty"` +} + +// Violation is a display-oriented analysis diagnostic. +type Violation struct { + Package string `json:"package,omitempty"` + File string `json:"file,omitempty"` + Line int `json:"line,omitempty"` + Column int `json:"column,omitempty"` + Rule string `json:"rule,omitempty"` + Message string `json:"message,omitempty"` +} + +// HasIssues reports whether the lint run found diagnostics or execution errors. +func (r *Result) HasIssues() bool { + if r == nil { + return false + } + return len(r.Violations) > 0 || len(r.Errors) > 0 +} + +// Run loads the requested Go packages, runs clickylint, and returns normalized +// diagnostics without printing through the go/analysis default text driver. +func Run(opts RunOptions) (*Result, error) { + if len(opts.Packages) == 0 { + opts.Packages = []string{"./..."} + } + if opts.WorkDir == "" { + wd, err := os.Getwd() + if err != nil { + return nil, err + } + opts.WorkDir = wd + } + + start := time.Now() + result := &Result{ + Linter: Analyzer.Name, + WorkDir: opts.WorkDir, + } + + cfg := &packages.Config{ + Mode: packages.LoadSyntax | packages.NeedModule, + Dir: opts.WorkDir, + Tests: opts.IncludeTests, + } + pkgs, err := packages.Load(cfg, opts.Packages...) + if err != nil { + result.Errors = append(result.Errors, err.Error()) + result.Duration = time.Since(start) + return result, nil + } + if len(pkgs) == 0 { + result.Errors = append(result.Errors, fmt.Sprintf("%s matched no packages", strings.Join(opts.Packages, " "))) + result.Duration = time.Since(start) + return result, nil + } + + result.PackageCount = len(pkgs) + result.Errors = append(result.Errors, packageErrors(pkgs)...) + + graph, err := checker.Analyze([]*analysis.Analyzer{Analyzer}, pkgs, nil) + if err != nil { + result.Errors = append(result.Errors, err.Error()) + result.Duration = time.Since(start) + return result, nil + } + + for _, act := range graph.Roots { + if act == nil || act.Analyzer != Analyzer { + continue + } + if act.Err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", act.Package.PkgPath, act.Err)) + continue + } + for _, diag := range act.Diagnostics { + pos := act.Package.Fset.PositionFor(diag.Pos, false) + result.Violations = append(result.Violations, Violation{ + Package: act.Package.PkgPath, + File: pos.Filename, + Line: pos.Line, + Column: pos.Column, + Rule: RuleForMessage(diag.Message), + Message: diag.Message, + }) + } + } + + sortViolations(result.Violations) + result.Errors = uniqueStrings(result.Errors) + result.Success = !result.HasIssues() + result.Duration = time.Since(start) + return result, nil +} + +// RuleForMessage derives a stable rule bucket from a go/analysis diagnostic. +func RuleForMessage(message string) string { + message = strings.TrimSpace(message) + if message == "" { + return "(no rule)" + } + if idx := strings.Index(message, ";"); idx >= 0 { + return strings.TrimSpace(message[:idx]) + } + return message +} + +func packageErrors(pkgs []*packages.Package) []string { + var out []string + packages.Visit(pkgs, nil, func(pkg *packages.Package) { + for _, err := range pkg.Errors { + out = append(out, err.Error()) + } + }) + return uniqueStrings(out) +} + +func sortViolations(violations []Violation) { + sort.SliceStable(violations, func(i, j int) bool { + a, b := violations[i], violations[j] + switch { + case a.File != b.File: + return a.File < b.File + case a.Line != b.Line: + return a.Line < b.Line + case a.Column != b.Column: + return a.Column < b.Column + case a.Rule != b.Rule: + return a.Rule < b.Rule + default: + return a.Message < b.Message + } + }) +} + +func uniqueStrings(in []string) []string { + if len(in) == 0 { + return nil + } + seen := map[string]bool{} + out := make([]string, 0, len(in)) + for _, item := range in { + item = strings.TrimSpace(item) + if item == "" || seen[item] { + continue + } + seen[item] = true + out = append(out, item) + } + return out +} diff --git a/lint/summary.go b/lint/summary.go new file mode 100644 index 00000000..bef06e94 --- /dev/null +++ b/lint/summary.go @@ -0,0 +1,263 @@ +package lint + +import ( + "fmt" + "path/filepath" + "sort" + "strings" + + "github.com/flanksource/clicky/api" +) + +// SummaryView renders clickylint results as a compact tree, matching the +// Gavel lint display shape: root -> linter -> rule -> affected files. +type SummaryView struct { + Result *Result `json:"result"` + Limit int `json:"-"` +} + +// NewSummaryView returns a tree view for a lint result. +func NewSummaryView(result *Result, limit int) *SummaryView { + if limit < 1 { + limit = 5 + } + return &SummaryView{Result: result, Limit: limit} +} + +func (s *SummaryView) Pretty() api.Text { + violations := 0 + errors := 0 + if s.Result != nil { + violations = len(s.Result.Violations) + errors = len(s.Result.Errors) + } + style := "text-blue-500" + if violations == 0 && errors == 0 { + style = "text-green-600" + } + text := fmt.Sprintf("Lint summary: %d violations", violations) + if errors > 0 { + text += fmt.Sprintf(" (%d errors)", errors) + style = "text-red-500" + } + return api.Text{Content: text, Style: style} +} + +func (s *SummaryView) GetChildren() []api.TreeNode { + if s.Result == nil { + return nil + } + return []api.TreeNode{&linterSummaryNode{ + linter: s.Result.Linter, + workDir: s.Result.WorkDir, + violations: s.Result.Violations, + errors: s.Result.Errors, + limit: s.Limit, + }} +} + +type linterSummaryNode struct { + linter string + workDir string + violations []Violation + errors []string + limit int +} + +func (n *linterSummaryNode) Pretty() api.Text { + count := len(n.violations) + if len(n.errors) > 0 { + text := "❌ " + n.linter + if count > 0 { + text += fmt.Sprintf(" (%d violations, error)", count) + } else { + text += " (error)" + } + if summary := firstErrorLine(strings.Join(n.errors, "\n")); summary != "" { + text += " - " + summary + } + return api.Text{Content: text, Style: "text-red-600"} + } + if count == 0 { + return api.Text{Content: "✅ " + n.linter, Style: "text-green-600"} + } + return api.Text{ + Content: fmt.Sprintf("⚠ %s (%d violations)", n.linter, count), + Style: "text-yellow-600", + } +} + +func (n *linterSummaryNode) GetChildren() []api.TreeNode { + var children []api.TreeNode + for _, err := range n.errors { + children = append(children, &linterErrorNode{message: err}) + } + if len(n.violations) == 0 { + return children + } + + type ruleGroup struct { + rule string + violations []Violation + } + byRule := map[string]*ruleGroup{} + var order []string + for _, v := range n.violations { + key := v.Rule + if key == "" { + key = "(no rule)" + } + g, ok := byRule[key] + if !ok { + g = &ruleGroup{rule: key} + byRule[key] = g + order = append(order, key) + } + g.violations = append(g.violations, v) + } + sort.Slice(order, func(i, j int) bool { + left, right := byRule[order[i]], byRule[order[j]] + if len(left.violations) != len(right.violations) { + return len(left.violations) > len(right.violations) + } + return left.rule < right.rule + }) + + for _, key := range order { + g := byRule[key] + children = append(children, &ruleSummaryNode{ + rule: g.rule, + workDir: n.workDir, + violations: g.violations, + limit: n.limit, + }) + } + return children +} + +type linterErrorNode struct { + message string +} + +func (n *linterErrorNode) Pretty() api.Text { + return api.Text{Content: strings.TrimRight(n.message, "\n"), Style: "text-red-500"} +} + +func (n *linterErrorNode) GetChildren() []api.TreeNode { return nil } + +type ruleSummaryNode struct { + rule string + workDir string + violations []Violation + limit int +} + +func (n *ruleSummaryNode) Pretty() api.Text { + text := fmt.Sprintf("%s (%d)", n.rule, len(n.violations)) + if msg := firstMessage(n.violations); msg != "" { + text += " - " + msg + } + return api.Text{Content: text, Style: "text-blue-500"} +} + +func (n *ruleSummaryNode) GetChildren() []api.TreeNode { + type fileBucket struct { + file string + first Violation + count int + } + byFile := map[string]*fileBucket{} + var order []string + for _, v := range n.violations { + file := v.File + if file == "" { + file = "(unknown file)" + } + b, ok := byFile[file] + if !ok { + b = &fileBucket{file: file, first: v} + byFile[file] = b + order = append(order, file) + } + b.count++ + } + sort.Strings(order) + + limit := min(n.limit, len(order)) + children := make([]api.TreeNode, 0, limit+1) + for i := range limit { + b := byFile[order[i]] + children = append(children, &locationSummaryNode{ + workDir: n.workDir, + violation: b.first, + count: b.count, + }) + } + if remaining := len(order) - limit; remaining > 0 { + children = append(children, &moreLocationsNode{remaining: remaining}) + } + return children +} + +type locationSummaryNode struct { + workDir string + violation Violation + count int +} + +func (n *locationSummaryNode) Pretty() api.Text { + file := n.violation.File + if file == "" { + file = "(unknown file)" + } + if n.workDir != "" && filepath.IsAbs(file) { + if rel, err := filepath.Rel(n.workDir, file); err == nil { + file = rel + } + } + if n.count > 1 { + return api.Text{ + Content: fmt.Sprintf("📄 %s (%d)", file, n.count), + Style: "text-muted", + } + } + loc := file + if n.violation.Line > 0 { + loc = fmt.Sprintf("%s:%d", file, n.violation.Line) + if n.violation.Column > 0 { + loc = fmt.Sprintf("%s:%d", loc, n.violation.Column) + } + } + return api.Text{Content: "📄 " + loc, Style: "text-muted"} +} + +func (n *locationSummaryNode) GetChildren() []api.TreeNode { return nil } + +type moreLocationsNode struct { + remaining int +} + +func (n *moreLocationsNode) Pretty() api.Text { + return api.Text{Content: fmt.Sprintf("... %d more", n.remaining), Style: "text-muted"} +} + +func (n *moreLocationsNode) GetChildren() []api.TreeNode { return nil } + +func firstMessage(vs []Violation) string { + for _, v := range vs { + if strings.TrimSpace(v.Message) != "" { + return v.Message + } + } + return "" +} + +func firstErrorLine(msg string) string { + for _, line := range strings.Split(msg, "\n") { + line = strings.TrimSpace(line) + if line != "" { + return line + } + } + return "" +} diff --git a/lint/summary_test.go b/lint/summary_test.go new file mode 100644 index 00000000..404561c7 --- /dev/null +++ b/lint/summary_test.go @@ -0,0 +1,100 @@ +package lint + +import ( + "strings" + "testing" +) + +func testViolation(file string, line int, rule, msg string) Violation { + return Violation{ + File: file, + Line: line, + Column: 7, + Rule: rule, + Message: msg, + } +} + +func TestSummaryViewGroupsByRuleAndLocation(t *testing.T) { + result := &Result{ + Linter: "clickylint", + WorkDir: "/repo", + Violations: []Violation{ + testViolation("/repo/a.go", 10, "avoid direct api.Text struct literal", "avoid direct api.Text struct literal; use api.Text{}.Append(...)"), + testViolation("/repo/b.go", 12, "avoid direct api.Text struct literal", "avoid direct api.Text struct literal; use api.Text{}.Append(...)"), + testViolation("/repo/c.go", 14, "avoid .ANSI() inside clicky render builders", "avoid .ANSI() inside clicky render builders; return api.Text"), + }, + } + + view := NewSummaryView(result, 5) + children := view.GetChildren() + if len(children) != 1 { + t.Fatalf("expected one linter node, got %d", len(children)) + } + linter := children[0].(*linterSummaryNode) + rules := linter.GetChildren() + if len(rules) != 2 { + t.Fatalf("expected two rule nodes, got %d", len(rules)) + } + first := rules[0].(*ruleSummaryNode) + if first.rule != "avoid direct api.Text struct literal" { + t.Fatalf("expected most frequent rule first, got %q", first.rule) + } + if len(first.violations) != 2 { + t.Fatalf("expected two violations in first group, got %d", len(first.violations)) + } + + locations := first.GetChildren() + if len(locations) != 2 { + t.Fatalf("expected two file locations, got %d", len(locations)) + } + if got := locations[0].Pretty().String(); !strings.Contains(got, "a.go:10:7") { + t.Fatalf("expected relative file location in first child, got %q", got) + } +} + +func TestSummaryViewTruncatesLocationsAtLimit(t *testing.T) { + result := &Result{Linter: "clickylint"} + for i, file := range []string{"a.go", "b.go", "c.go"} { + result.Violations = append(result.Violations, testViolation(file, i+1, "same-rule", "same-rule; details")) + } + + rule := NewSummaryView(result, 2).GetChildren()[0].GetChildren()[0] + children := rule.GetChildren() + if len(children) != 3 { + t.Fatalf("expected 2 locations and a trailer, got %d", len(children)) + } + trailer, ok := children[2].(*moreLocationsNode) + if !ok { + t.Fatalf("expected trailer node, got %T", children[2]) + } + if trailer.remaining != 1 { + t.Fatalf("expected 1 remaining location, got %d", trailer.remaining) + } +} + +func TestSummaryViewSurfacesErrors(t *testing.T) { + result := &Result{ + Linter: "clickylint", + Errors: []string{"package load failed:\nmissing module"}, + } + + view := NewSummaryView(result, 5) + if got := view.Pretty().String(); !strings.Contains(got, "1 errors") { + t.Fatalf("expected root error count, got %q", got) + } + linter := view.GetChildren()[0].(*linterSummaryNode) + if got := linter.Pretty().String(); !strings.Contains(got, "package load failed") { + t.Fatalf("expected linter error preview, got %q", got) + } + if children := linter.GetChildren(); len(children) != 1 { + t.Fatalf("expected one error child, got %d", len(children)) + } +} + +func TestRuleForMessageUsesFirstClause(t *testing.T) { + got := RuleForMessage("avoid .ANSI() inside clicky render builders; return api.Text") + if got != "avoid .ANSI() inside clicky render builders" { + t.Fatalf("unexpected rule: %q", got) + } +} diff --git a/lint/testdata/src/bad/bad.go b/lint/testdata/src/bad/bad.go index ae172f8f..c850b027 100644 --- a/lint/testdata/src/bad/bad.go +++ b/lint/testdata/src/bad/bad.go @@ -34,3 +34,60 @@ var sprintfContent = api.Text{Content: fmt.Sprintf("hello %s", "world")} // want var childrenLiteral = api.Text{ // want `avoid direct api\.Text struct literal` Children: []api.Textable{api.Text{Content: "a"}, api.Text{Content: "b"}}, // want `avoid Children slice literal` `avoid direct api\.Text struct literal` `avoid direct api\.Text struct literal` } + +var directList = api.List{Items: []api.Textable{api.Text{}.Append("one")}} // want `avoid direct api\.List struct literal` +var directTextList = api.TextList{api.Text{}.Append("one")} // want `avoid direct api\.TextList struct literal` +var directTable = api.TextTable{Headers: api.TextList{}} // want `avoid direct api\.TextTable struct literal` `avoid direct api\.TextList struct literal` +var directTree = api.TextTree{Node: api.Text{}.Append("root")} // want `avoid direct api\.TextTree struct literal` +var directCode = api.Code{Content: "package main"} // want `avoid direct api\.Code struct literal` +var directButton = api.Button{Label: "Open", Href: "/orders"} // want `avoid direct api\.Button struct literal` +var directButtonGroup = api.ButtonGroup{Buttons: nil} // want `avoid direct api\.ButtonGroup struct literal` +var directKeyValue = api.KeyValuePair{Key: "status", Value: "active"} // want `avoid direct api\.KeyValuePair struct literal` +var directDescriptionList = api.DescriptionList{Items: nil} // want `avoid direct api\.DescriptionList struct literal` +var directLabelBadge = api.LabelBadge{Label: "Status", Value: "active"} // want `avoid direct api\.LabelBadge struct literal` +var directAdmonition = api.Admonition{Severity: api.SeverityWarning} // want `avoid direct api\.Admonition struct literal` +var directCollapsed = api.Collapsed{Label: "Details"} // want `avoid direct api\.Collapsed struct literal` +var directDiff = api.Diff{Before: "old", After: "new"} // want `avoid direct api\.Diff struct literal` +var directStackTrace = api.StackTrace{Raw: "panic"} // want `avoid direct api\.StackTrace struct literal` +var directHTMLElement = api.HtmlElement{Tag: "span", Content: "raw"} // want `avoid direct api\.HtmlElement struct literal` + +type RenderBuilder struct{} + +func (r RenderBuilder) Pretty() api.Text { + text := api.Text{}.Append("hello") + return api.Text{}.Append(text.ANSI()) // want `avoid \.ANSI\(\) inside clicky render builders` +} + +func (r RenderBuilder) PrettyFull() api.Textable { + text := api.Text{}.Append("hello") + return api.Text{}.Append(text.HTML()) // want `avoid \.HTML\(\) inside clicky render builders` +} + +func (r RenderBuilder) PrettyShort() api.Textable { + text := api.Text{}.Append("hello") + return api.Text{}.Append(text.String()) // want `avoid \.String\(\) inside clicky render builders` +} + +func (r RenderBuilder) PrettyRow(_ interface{}) map[string]api.Text { + text := api.Text{}.Append("hello") + return map[string]api.Text{ + "name": api.Text{}.Append(text.Markdown()), // want `avoid \.Markdown\(\) inside clicky render builders` + } +} + +func BuildTextable() api.Textable { + text := api.Text{}.Append("hello") + return api.Text{}.Append(text.MarkdownSlack()) // want `avoid \.MarkdownSlack\(\) inside clicky render builders` +} + +func BuildTable() api.TextTable { + table := api.TextTable{} // want `avoid direct api\.TextTable struct literal` + _ = table.CompactHTML() // want `avoid \.CompactHTML\(\) inside clicky render builders` + return table +} + +func BuildList() api.TextList { + list := api.TextList{api.Text{}.Append("hello")} // want `avoid direct api\.TextList struct literal` + _ = list.AsANSI() // want `avoid \.AsANSI\(\) inside clicky render builders` + return list +} diff --git a/lint/testdata/src/github.com/flanksource/clicky/api/stub.go b/lint/testdata/src/github.com/flanksource/clicky/api/stub.go index 9358d420..cb2a6b50 100644 --- a/lint/testdata/src/github.com/flanksource/clicky/api/stub.go +++ b/lint/testdata/src/github.com/flanksource/clicky/api/stub.go @@ -18,6 +18,9 @@ func (t Text) String() string { return t.Content } func (t Text) ANSI() string { return t.Content } func (t Text) HTML() string { return t.Content } func (t Text) Markdown() string { return t.Content } +func (t Text) MarkdownSlack() string { + return t.Content +} func (t Text) Append(text any, styles ...string) Text { return t } func (t Text) Appendf(format string, args ...any) Text { return t } @@ -34,3 +37,160 @@ func (tb *TextBuilder) Build() Text { return tb.text } func SuccessText(content string) Text { return NewText(content).Build() } func ErrorText(content string) Text { return NewText(content).Build() } + +type TextList []Textable + +func (tl TextList) String() string { return "" } +func (tl TextList) ANSI() string { return "" } +func (tl TextList) HTML() string { return "" } +func (tl TextList) Markdown() string { return "" } +func (tl TextList) AsANSI() []string { return nil } +func (tl TextList) AsHTML() []string { return nil } +func (tl TextList) AsMarkdown() []string { return nil } + +type List struct { + Items []Textable +} + +func (l List) String() string { return "" } +func (l List) ANSI() string { return "" } +func (l List) HTML() string { return "" } +func (l List) Markdown() string { return "" } + +type TextTable struct { + Headers TextList +} + +func (tt TextTable) String() string { return "" } +func (tt TextTable) ANSI() string { return "" } +func (tt TextTable) HTML() string { return "" } +func (tt TextTable) Markdown() string { return "" } +func (tt TextTable) CompactHTML() string { return "" } +func (tt TextTable) StaticHTML() string { return "" } + +type TextTree struct { + Node Textable +} + +func (tt TextTree) String() string { return "" } +func (tt TextTree) ANSI() string { return "" } +func (tt TextTree) HTML() string { return "" } +func (tt TextTree) Markdown() string { return "" } + +type Code struct { + Content string + Language string +} + +func (c Code) String() string { return "" } +func (c Code) ANSI() string { return "" } +func (c Code) HTML() string { return "" } +func (c Code) Markdown() string { return "" } + +type Button struct { + Label string + Href string +} + +func (b Button) String() string { return "" } +func (b Button) ANSI() string { return "" } +func (b Button) HTML() string { return "" } +func (b Button) Markdown() string { return "" } + +type ButtonGroup struct { + Buttons []Button +} + +func (b ButtonGroup) String() string { return "" } +func (b ButtonGroup) ANSI() string { return "" } +func (b ButtonGroup) HTML() string { return "" } +func (b ButtonGroup) Markdown() string { return "" } + +type KeyValuePair struct { + Key string + Value any +} + +func (kv KeyValuePair) String() string { return "" } +func (kv KeyValuePair) ANSI() string { return "" } +func (kv KeyValuePair) HTML() string { return "" } +func (kv KeyValuePair) Markdown() string { return "" } + +type DescriptionList struct { + Items []KeyValuePair +} + +func (dl DescriptionList) String() string { return "" } +func (dl DescriptionList) ANSI() string { return "" } +func (dl DescriptionList) HTML() string { return "" } +func (dl DescriptionList) Markdown() string { return "" } + +type LabelBadge struct { + Label string + Value string +} + +func (b LabelBadge) String() string { return "" } +func (b LabelBadge) ANSI() string { return "" } +func (b LabelBadge) HTML() string { return "" } +func (b LabelBadge) Markdown() string { return "" } + +type Severity int + +const SeverityWarning Severity = 3 + +type Admonition struct { + Severity Severity + Body Textable +} + +func (a Admonition) String() string { return "" } +func (a Admonition) ANSI() string { return "" } +func (a Admonition) HTML() string { return "" } +func (a Admonition) Markdown() string { return "" } + +type Collapsed struct { + Label string + Content Textable +} + +func (c Collapsed) String() string { return "" } +func (c Collapsed) ANSI() string { return "" } +func (c Collapsed) HTML() string { return "" } +func (c Collapsed) Markdown() string { return "" } + +type Diff struct { + Before string + After string +} + +func (d Diff) String() string { return "" } +func (d Diff) ANSI() string { return "" } +func (d Diff) HTML() string { return "" } +func (d Diff) Markdown() string { return "" } + +type StackTrace struct { + Raw string +} + +func (s StackTrace) String() string { return "" } +func (s StackTrace) ANSI() string { return "" } +func (s StackTrace) HTML() string { return "" } +func (s StackTrace) Markdown() string { return "" } + +type HtmlElement struct { + Tag string + Content string +} + +func (e HtmlElement) String() string { return "" } +func (e HtmlElement) ANSI() string { return "" } +func (e HtmlElement) HTML() string { return "" } +func (e HtmlElement) Markdown() string { return "" } + +type Comment string + +func (c Comment) String() string { return "" } +func (c Comment) ANSI() string { return "" } +func (c Comment) HTML() string { return "" } +func (c Comment) Markdown() string { return "" } diff --git a/lint/testdata/src/github.com/flanksource/clicky/stub.go b/lint/testdata/src/github.com/flanksource/clicky/stub.go new file mode 100644 index 00000000..854218b3 --- /dev/null +++ b/lint/testdata/src/github.com/flanksource/clicky/stub.go @@ -0,0 +1,49 @@ +package clicky + +import "github.com/flanksource/clicky/api" + +func Text(content string, styles ...string) api.Text { return api.Text{} } + +func List(items ...api.Textable) api.List { return api.List{} } + +func TextList(items ...api.Textable) api.TextList { return api.TextList(items) } + +func Table(headers ...string) api.TextTable { return api.TextTable{} } + +func Tree(node api.Textable, children ...api.TextTree) api.TextTree { return api.TextTree{} } + +func KeyValue(key string, value any, styles ...string) api.KeyValuePair { + return api.KeyValuePair{} +} + +func Map[T any](m map[string]T, styles ...string) api.DescriptionList { + return api.DescriptionList{} +} + +func CodeBlock(language, content string, styles ...string) api.Code { return api.Code{} } + +func Button(label, href string, options ...func(*api.Button)) api.Button { return api.Button{} } + +func ButtonGroup(buttons ...api.Button) api.ButtonGroup { return api.ButtonGroup{} } + +func LabelBadge(label, value string, options ...func(*api.LabelBadge)) api.LabelBadge { + return api.LabelBadge{} +} + +func Collapsed(label string, content api.Textable, styles ...string) api.Collapsed { + return api.Collapsed{} +} + +func Admonition(severity api.Severity, title, body api.Textable) api.Admonition { + return api.Admonition{} +} + +func Diff(before, after, fromLabel, toLabel string) api.Diff { return api.Diff{} } + +func StackTrace(input string) api.StackTrace { return api.StackTrace{} } + +func Comment(text string) api.Comment { return api.Comment(text) } + +func HTMLElement(tag, content string, attrs ...map[string]string) api.HtmlElement { + return api.HtmlElement{} +} diff --git a/lint/testdata/src/good/good.go b/lint/testdata/src/good/good.go index 34d312c3..6418abb2 100644 --- a/lint/testdata/src/good/good.go +++ b/lint/testdata/src/good/good.go @@ -1,6 +1,11 @@ package good -import "github.com/flanksource/clicky/api" +import ( + "fmt" + + "github.com/flanksource/clicky" + "github.com/flanksource/clicky/api" +) // Rule 1 exception: empty literal for chaining var chained = api.Text{}.Append("hello", "font-bold") @@ -25,6 +30,11 @@ func (m MyType) PrettyRow(opts interface{}) map[string]api.Text { return nil } +// Rule 2 exception: PrettyShort method +func (m MyType) PrettyShort() api.Textable { + return api.Text{}.Append("hello") +} + // Using builder pattern - fine var built = api.NewText("hello").Bold().Build() @@ -33,3 +43,36 @@ var success = api.SuccessText("done") // Fluent chaining - fine var fluent = api.Text{}.Append("label", "font-bold").Space().Append("value") + +var helperText = clicky.Text("hello", "font-bold") +var helperList = clicky.List(clicky.Text("one")) +var helperTextList = clicky.TextList(clicky.Text("one"), clicky.Text("two")) +var helperTable = clicky.Table("Name", "Status") +var helperTree = clicky.Tree(clicky.Text("root")) +var helperCode = clicky.CodeBlock("go", "package main") +var helperButton = clicky.Button("Open", "/orders") +var helperButtonGroup = clicky.ButtonGroup(helperButton) +var helperKeyValue = clicky.KeyValue("status", "active") +var helperMap = clicky.Map(map[string]string{"env": "prod"}) +var helperLabelBadge = clicky.LabelBadge("Status", "active") +var helperCollapsed = clicky.Collapsed("Details", clicky.Text("body")) +var helperAdmonition = clicky.Admonition(api.SeverityWarning, nil, clicky.Text("body")) +var helperDiff = clicky.Diff("old", "new", "old", "new") +var helperStackTrace = clicky.StackTrace("panic") +var helperHTMLElement = clicky.HTMLElement("span", "raw") + +type domainID string + +func (d domainID) String() string { + return string(d) +} + +func BuildDetail(id domainID) api.Textable { + return api.Text{}.Append(id.String()).Space().Append("ready", "text-green-600") +} + +func BuildRows(name string) map[string]api.Text { + return map[string]api.Text{ + "name": api.Text{}.Append(fmt.Sprintf("%s", name)), + } +} diff --git a/metrics/codec.go b/metrics/codec.go new file mode 100644 index 00000000..d72fcb48 --- /dev/null +++ b/metrics/codec.go @@ -0,0 +1,40 @@ +package metrics + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// EncodeMember renders a Point as a sorted-set member string of the form +// ":". The millisecond timestamp prefix doubles as the +// sorted-set score, and including it in the member keeps members unique even +// when two observations share a value. The value is formatted with -1 +// precision so it round-trips losslessly through ParseMember. +func EncodeMember(p Point) string { + return strconv.FormatInt(p.At.UnixMilli(), 10) + ":" + + strconv.FormatFloat(p.Value, 'g', -1, 64) +} + +// ParseMember reverses EncodeMember. It returns an error for any member that +// does not match the ":" shape so a corrupt entry surfaces +// loudly rather than decoding to a zero Point. +func ParseMember(member string) (Point, error) { + tsPart, valPart, ok := strings.Cut(member, ":") + if !ok { + return Point{}, fmt.Errorf("metrics: malformed member %q: missing ':' separator", member) + } + ms, err := strconv.ParseInt(tsPart, 10, 64) + if err != nil { + return Point{}, fmt.Errorf("metrics: malformed member %q: bad timestamp: %w", member, err) + } + val, err := strconv.ParseFloat(valPart, 64) + if err != nil { + return Point{}, fmt.Errorf("metrics: malformed member %q: bad value: %w", member, err) + } + // Normalise to UTC: the wire format is a location-agnostic epoch, so the + // canonical decoded instant is UTC. This keeps decoded points comparable + // regardless of the process's local zone. + return Point{At: time.UnixMilli(ms).UTC(), Value: val}, nil +} diff --git a/metrics/handler.go b/metrics/handler.go new file mode 100644 index 00000000..4966aded --- /dev/null +++ b/metrics/handler.go @@ -0,0 +1,97 @@ +package metrics + +import ( + "encoding/json" + "net/http" + "time" +) + +// defaultWindow is the look-back applied when a request omits `since`. +const defaultWindow = time.Hour + +// queryResponse is the JSON envelope returned by the metrics endpoint. +type queryResponse struct { + ID string `json:"id"` + Points []Point `json:"points"` +} + +// RegisterRoutes mounts the metrics read endpoint on mux under prefix: +// +// GET {prefix}/metrics/{id}?since=&until= +// +// The handler owns all request parsing, querying, and JSON encoding — callers +// supply only a Timeseries. prefix is the leading path segment shared with the +// rest of the API (e.g. "/api/v1"); it is used verbatim, so pass it without a +// trailing slash. +func RegisterRoutes(mux *http.ServeMux, ts Timeseries, prefix string) { + mux.Handle(prefix+"/metrics/", Handler(ts, prefix)) +} + +// Handler returns the metrics endpoint as a standalone http.Handler for +// callers that compose their own mux. It expects to be mounted such that +// request paths look like {prefix}/metrics/{id}. +func Handler(ts Timeseries, prefix string) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET "+prefix+"/metrics/{id}", func(w http.ResponseWriter, r *http.Request) { + serveQuery(w, r, ts) + }) + return mux +} + +func serveQuery(w http.ResponseWriter, r *http.Request, ts Timeseries) { + id := r.PathValue("id") + if id == "" { + http.Error(w, "missing metric id", http.StatusBadRequest) + return + } + + now := time.Now() + until, err := resolveBound(r.URL.Query().Get("until"), now, now) + if err != nil { + http.Error(w, "invalid until: "+err.Error(), http.StatusBadRequest) + return + } + since, err := resolveBound(r.URL.Query().Get("since"), now, until.Add(-defaultWindow)) + if err != nil { + http.Error(w, "invalid since: "+err.Error(), http.StatusBadRequest) + return + } + if since.After(until) { + http.Error(w, "since must be before until", http.StatusBadRequest) + return + } + + points, err := ts.Query(QueryRequest{ID: id, Since: since, Until: until}) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(queryResponse{ID: id, Points: points}) +} + +// resolveBound interprets a since/until query value. An empty value yields +// fallback. An RFC3339 timestamp is returned as-is. A Go duration (e.g. "1h", +// "15m") is interpreted relative to now: a look-back for positive durations +// (now-d) so "?since=1h" reads "the last hour". +func resolveBound(raw string, now, fallback time.Time) (time.Time, error) { + if raw == "" { + return fallback, nil + } + if t, err := time.Parse(time.RFC3339, raw); err == nil { + return t, nil + } + d, err := time.ParseDuration(raw) + if err != nil { + return time.Time{}, err + } + return now.Add(-absDuration(d)), nil +} + +func absDuration(d time.Duration) time.Duration { + if d < 0 { + return -d + } + return d +} diff --git a/metrics/inmemory.go b/metrics/inmemory.go new file mode 100644 index 00000000..878a8ced --- /dev/null +++ b/metrics/inmemory.go @@ -0,0 +1,95 @@ +package metrics + +import ( + "sort" + "sync" + "time" +) + +// MemoryConfig tunes the in-process Timeseries. Zero fields fall back to +// defaults: Retention 1h, MaxPoints 4096. +type MemoryConfig struct { + // Retention drops points older than this on each Record. Zero -> 1h. + Retention time.Duration + // MaxPoints caps the points kept per metric ID; the oldest are dropped + // first on overflow. Zero -> 4096. + MaxPoints int +} + +const ( + defaultMemoryRetention = time.Hour + defaultMemoryMaxPoints = 4096 +) + +// memoryStore is a bounded, in-process Timeseries. Points per ID are kept +// sorted ascending by time so Record's trim and Query's range scan are both +// cheap. It holds no external resources and is safe for concurrent use. +type memoryStore struct { + mu sync.Mutex + series map[string][]Point + retention time.Duration + maxPoints int +} + +// NewMemory returns an in-process Timeseries. It needs no backend and is the +// zero-config default for CLIs, tests, and single-process servers. +func NewMemory(cfg MemoryConfig) Timeseries { + if cfg.Retention <= 0 { + cfg.Retention = defaultMemoryRetention + } + if cfg.MaxPoints <= 0 { + cfg.MaxPoints = defaultMemoryMaxPoints + } + return &memoryStore{ + series: make(map[string][]Point), + retention: cfg.Retention, + maxPoints: cfg.MaxPoints, + } +} + +func (m *memoryStore) Record(req RecordRequest) error { + at := req.At + if at.IsZero() { + at = time.Now() + } + m.mu.Lock() + defer m.mu.Unlock() + + pts := append(m.series[req.ID], Point{At: at, Value: req.Value}) + // Keep ascending by time; the appended point is usually already the + // latest, so this is a near-no-op in the common path. + sort.Slice(pts, func(i, j int) bool { return pts[i].At.Before(pts[j].At) }) + + cutoff := at.Add(-m.retention) + pts = dropBefore(pts, cutoff) + if len(pts) > m.maxPoints { + pts = pts[len(pts)-m.maxPoints:] + } + m.series[req.ID] = pts + return nil +} + +func (m *memoryStore) Query(req QueryRequest) ([]Point, error) { + m.mu.Lock() + defer m.mu.Unlock() + + src := m.series[req.ID] + out := make([]Point, 0, len(src)) + for _, p := range src { + if !req.Since.IsZero() && p.At.Before(req.Since) { + continue + } + if !req.Until.IsZero() && p.At.After(req.Until) { + continue + } + out = append(out, p) + } + return out, nil +} + +// dropBefore returns the suffix of pts (sorted ascending) whose timestamps are +// at or after cutoff. +func dropBefore(pts []Point, cutoff time.Time) []Point { + i := sort.Search(len(pts), func(i int) bool { return !pts[i].At.Before(cutoff) }) + return pts[i:] +} diff --git a/metrics/metrics_suite_test.go b/metrics/metrics_suite_test.go new file mode 100644 index 00000000..908a6af5 --- /dev/null +++ b/metrics/metrics_suite_test.go @@ -0,0 +1,13 @@ +package metrics_test + +import ( + "testing" + + "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestMetrics(t *testing.T) { + RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Metrics Suite") +} diff --git a/metrics/timeseries.go b/metrics/timeseries.go new file mode 100644 index 00000000..1823c318 --- /dev/null +++ b/metrics/timeseries.go @@ -0,0 +1,51 @@ +// Package metrics provides a generic timeseries store for recording and +// querying (timestamp, value) points behind a thin Timeseries interface. +// +// The root package ships an in-process implementation (NewMemory) with no +// external dependencies, suitable for CLIs, tests, and single-process +// servers. A cross-process, persistent implementation backed by valkey/redis +// sorted sets lives in the sibling submodule github.com/flanksource/clicky/valkey, +// which depends on valkey-go so the root module stays dependency-free. +// +// Both implementations share the same wire format (see codec.go) so a metric +// recorded by one can be queried by the other, and RegisterRoutes serves +// either over HTTP without knowing which backend it holds. +package metrics + +import "time" + +// Point is a single observation in a timeseries. +type Point struct { + At time.Time `json:"at"` + Value float64 `json:"value"` +} + +// RecordRequest records one Point for metric ID. A zero At is resolved to the +// current time by the implementation. +type RecordRequest struct { + ID string + At time.Time + Value float64 +} + +// QueryRequest reads the points for metric ID whose timestamps fall in +// [Since, Until]. The HTTP handler resolves zero bounds to a default window +// (Until = now, Since = now-1h); implementations treat a zero bound as +// unbounded on that side. +type QueryRequest struct { + ID string + Since time.Time + Until time.Time +} + +// Timeseries stores and retrieves timeseries points. Implementations must be +// safe for concurrent use. +type Timeseries interface { + // Record appends a point. Recording is best-effort instrumentation: an + // implementation backed by an unavailable store may return an error, but + // callers are expected to log-and-continue rather than fail the producer. + Record(req RecordRequest) error + + // Query returns the points in the requested range, ascending by time. + Query(req QueryRequest) ([]Point, error) +} diff --git a/metrics/timeseries_test.go b/metrics/timeseries_test.go new file mode 100644 index 00000000..11cc709f --- /dev/null +++ b/metrics/timeseries_test.go @@ -0,0 +1,123 @@ +package metrics_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/clicky/metrics" +) + +// base is a fixed reference time so specs never depend on wall-clock now. +var base = time.Date(2026, 6, 2, 12, 0, 0, 0, time.UTC) + +func pointsAt(start time.Time, step time.Duration, values ...float64) []metrics.Point { + out := make([]metrics.Point, len(values)) + for i, v := range values { + out[i] = metrics.Point{At: start.Add(time.Duration(i) * step), Value: v} + } + return out +} + +var _ = Describe("Member codec", func() { + It("round-trips a point losslessly", func() { + p := metrics.Point{At: base, Value: 42.5} + decoded, err := metrics.ParseMember(metrics.EncodeMember(p)) + Expect(err).NotTo(HaveOccurred()) + Expect(decoded.At.UnixMilli()).To(Equal(p.At.UnixMilli())) + Expect(decoded.Value).To(Equal(p.Value)) + }) + + It("rejects a malformed member", func() { + _, err := metrics.ParseMember("not-a-member") + Expect(err).To(HaveOccurred()) + }) +}) + +var _ = Describe("In-memory Timeseries", func() { + It("returns only points inside the queried range, ascending", func() { + ts := metrics.NewMemory(metrics.MemoryConfig{Retention: time.Hour, MaxPoints: 100}) + for _, p := range pointsAt(base, time.Minute, 1, 2, 3, 4, 5) { + Expect(ts.Record(metrics.RecordRequest{ID: "cpu", At: p.At, Value: p.Value})).To(Succeed()) + } + + got, err := ts.Query(metrics.QueryRequest{ + ID: "cpu", + Since: base.Add(time.Minute), + Until: base.Add(3 * time.Minute), + }) + Expect(err).NotTo(HaveOccurred()) + Expect(got).To(Equal(pointsAt(base.Add(time.Minute), time.Minute, 2, 3, 4))) + }) + + It("drops points older than the retention window on record", func() { + ts := metrics.NewMemory(metrics.MemoryConfig{Retention: 10 * time.Minute, MaxPoints: 100}) + Expect(ts.Record(metrics.RecordRequest{ID: "cpu", At: base.Add(-time.Hour), Value: 1})).To(Succeed()) + Expect(ts.Record(metrics.RecordRequest{ID: "cpu", At: base, Value: 2})).To(Succeed()) + + got, err := ts.Query(metrics.QueryRequest{ID: "cpu"}) + Expect(err).NotTo(HaveOccurred()) + Expect(got).To(Equal([]metrics.Point{{At: base, Value: 2}})) + }) + + It("caps retained points at MaxPoints, keeping the newest", func() { + ts := metrics.NewMemory(metrics.MemoryConfig{Retention: time.Hour, MaxPoints: 3}) + for _, p := range pointsAt(base, time.Second, 1, 2, 3, 4, 5) { + Expect(ts.Record(metrics.RecordRequest{ID: "cpu", At: p.At, Value: p.Value})).To(Succeed()) + } + got, err := ts.Query(metrics.QueryRequest{ID: "cpu"}) + Expect(err).NotTo(HaveOccurred()) + Expect(got).To(Equal(pointsAt(base.Add(2*time.Second), time.Second, 3, 4, 5))) + }) +}) + +var _ = Describe("HTTP handler", func() { + newServer := func() (*httptest.Server, metrics.Timeseries) { + ts := metrics.NewMemory(metrics.MemoryConfig{Retention: time.Hour, MaxPoints: 100}) + mux := http.NewServeMux() + metrics.RegisterRoutes(mux, ts, "/api/v1") + return httptest.NewServer(mux), ts + } + + It("serves recorded points as a JSON envelope", func() { + srv, ts := newServer() + defer srv.Close() + now := time.Now() + // Record in the recent past so all points fall inside the default + // [now-1h, now] query window. + for i, v := range []float64{10, 20, 30} { + Expect(ts.Record(metrics.RecordRequest{ + ID: "sqlserver.cpu", + At: now.Add(-time.Duration(3-i) * time.Second), + Value: v, + })).To(Succeed()) + } + + resp, err := http.Get(srv.URL + "/api/v1/metrics/sqlserver.cpu?since=1h") + Expect(err).NotTo(HaveOccurred()) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + var body struct { + ID string `json:"id"` + Points []metrics.Point `json:"points"` + } + Expect(json.NewDecoder(resp.Body).Decode(&body)).To(Succeed()) + Expect(body.ID).To(Equal("sqlserver.cpu")) + Expect(body.Points).To(HaveLen(3)) + Expect(body.Points[2].Value).To(Equal(30.0)) + }) + + It("rejects an unparseable since bound", func() { + srv, _ := newServer() + defer srv.Close() + resp, err := http.Get(srv.URL + "/api/v1/metrics/cpu?since=nonsense") + Expect(err).NotTo(HaveOccurred()) + defer resp.Body.Close() + Expect(resp.StatusCode).To(Equal(http.StatusBadRequest)) + }) +}) diff --git a/rpc/context_datafunc_test.go b/rpc/context_datafunc_test.go new file mode 100644 index 00000000..6a95b849 --- /dev/null +++ b/rpc/context_datafunc_test.go @@ -0,0 +1,104 @@ +package rpc + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/spf13/cobra" +) + +type ctxKey string + +const probeKey ctxKey = "probe" + +// TestExecuteCommandPrefersContextDataFunc verifies that when an operation sets +// ContextDataFunc, the executor calls it (not DataFunc) and threads req.Context +// through to it. +func TestExecuteCommandPrefersContextDataFunc(t *testing.T) { + executor := &CommandExecutor{config: &ExecutorConfig{Enabled: true}} + + var gotCtxValue any + dataFuncCalled := false + op := &RPCOperation{ + Name: "probe", + Command: &cobra.Command{Use: "probe"}, + DataFunc: func(map[string]string, []string) (any, error) { + dataFuncCalled = true + return "from-datafunc", nil + }, + ContextDataFunc: func(ctx context.Context, _ map[string]string, _ []string) (any, error) { + gotCtxValue = ctx.Value(probeKey) + return "from-context-datafunc", nil + }, + } + + ctx := context.WithValue(context.Background(), probeKey, "carried") + data, resp, err := executor.ExecuteCommand(op, &ExecutionRequest{Context: ctx}) + if err != nil { + t.Fatalf("ExecuteCommand: %v", err) + } + if !resp.Success { + t.Fatalf("expected success, got %+v", resp) + } + if dataFuncCalled { + t.Error("DataFunc was called despite ContextDataFunc being set") + } + if data != "from-context-datafunc" { + t.Errorf("data = %v, want from-context-datafunc", data) + } + if gotCtxValue != "carried" { + t.Errorf("context value = %v, want carried (context not threaded)", gotCtxValue) + } +} + +// TestExecuteCommandFallsBackToDataFunc verifies the non-context path is intact +// for operations that only set DataFunc. +func TestExecuteCommandFallsBackToDataFunc(t *testing.T) { + executor := &CommandExecutor{config: &ExecutorConfig{Enabled: true}} + op := &RPCOperation{ + Name: "probe", + Command: &cobra.Command{Use: "probe"}, + DataFunc: func(map[string]string, []string) (any, error) { + return "from-datafunc", nil + }, + } + data, resp, err := executor.ExecuteCommand(op, &ExecutionRequest{}) + if err != nil { + t.Fatalf("ExecuteCommand: %v", err) + } + if !resp.Success || data != "from-datafunc" { + t.Errorf("data = %v success = %v, want from-datafunc/true", data, resp.Success) + } +} + +// TestExtractRequestFromHTTPCarriesContext verifies the request's context.Context +// is copied onto ExecutionRequest so a ContextDataFunc can read it. +func TestExtractRequestFromHTTPCarriesContext(t *testing.T) { + executor := &CommandExecutor{config: &ExecutorConfig{Enabled: true}} + op := &RPCOperation{Name: "probe", Path: "/api/v1/probe", Method: "GET"} + + r := httptest.NewRequest("GET", "/api/v1/probe", nil) + ctx := context.WithValue(r.Context(), probeKey, "via-request") + r = r.WithContext(ctx) + + req, err := executor.ExtractRequestFromHTTP(r, op) + if err != nil { + t.Fatalf("ExtractRequestFromHTTP: %v", err) + } + if req.Context == nil { + t.Fatal("req.Context is nil; expected r.Context()") + } + if got := req.Context.Value(probeKey); got != "via-request" { + t.Errorf("req.Context value = %v, want via-request", got) + } +} + +// TestExecutionRequestCtxDefaultsToBackground guards the nil-context fallback so +// older callers building ExecutionRequest without a context don't panic. +func TestExecutionRequestCtxDefaultsToBackground(t *testing.T) { + req := &ExecutionRequest{} + if req.ctx() != context.Background() { + t.Error("ctx() should default to context.Background() when Context is nil") + } +} diff --git a/rpc/converter.go b/rpc/converter.go index 4f02cc53..8dfef793 100644 --- a/rpc/converter.go +++ b/rpc/converter.go @@ -181,6 +181,9 @@ func (c *Converter) ConvertCommand(cmd *cobra.Command) (*RPCOperation, error) { if df := clicky.GetDataFunc(cmd); df != nil { operation.DataFunc = df } + if cdf := clicky.GetContextDataFunc(cmd); cdf != nil { + operation.ContextDataFunc = ContextDataFunc(cdf) + } if lf := clicky.GetLookupFunc(cmd); lf != nil { operation.LookupFunc = lf } @@ -367,6 +370,15 @@ func (c *Converter) generateRESTPath(cmd *cobra.Command, cmdPath string) string // -> /api/v1/policy/{id}/recalculate // Only applies when the command is nested under a parent entity command // (not at the top level under the root command). + // + // Actions declared with WithOptionalID are exempt: they are invocable + // without an id (collection-level summaries like `activity overview`), so + // inserting an {id} segment would make the no-id REST call (which the + // frontend issues) fall through to the entity's get-by-id route. Keep the + // flat /entity/action path for those. + if meta := clicky.GetCommandOpenAPIMeta(cmd); meta != nil && meta.OptionalID { + return strings.Join(pathParts, "/") + } if cmd.Parent() != nil && cmd.Parent().Parent() != nil && !isCRUDOperation(cmd.Name()) { paramName := extractParameterName(cmd.Use) if paramName != "" { diff --git a/rpc/datafunc_wire_test.go b/rpc/datafunc_wire_test.go new file mode 100644 index 00000000..168299af --- /dev/null +++ b/rpc/datafunc_wire_test.go @@ -0,0 +1,55 @@ +package rpc + +import ( + "encoding/json" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHandleExecuteCommand_DataFuncReturnsArray is a regression test for the bug +// where DataFunc-backed entity list/get operations returned the empty +// ExecutionResponse envelope ({success, exit_code, cli}) instead of their +// structured payload when the client asked for JSON. The handler substituted +// `metadata` for `data` on every structured wire format; DataFunc ops carry +// their payload in `data` (metadata.Output is empty), so the result was dropped +// and the web UI showed no rows. The fix gates the substitution on +// !metadata.DataIsStructured. +func TestHandleExecuteCommand_DataFuncReturnsArray(t *testing.T) { + type row struct { + Name string `json:"name"` + } + want := []row{{Name: "dev"}, {Name: "prod"}} + + op := RPCOperation{ + Name: "config list", + Path: "/api/v1/config", + Method: "GET", + DataFunc: func(_ map[string]string, _ []string) (any, error) { + return want, nil + }, + } + service := &RPCService{Name: "api", Operations: []RPCOperation{op}} + executor := NewCommandExecutor(service, &ExecutorConfig{Enabled: true, SkipPreRun: true, PathPrefix: "/api/v1"}) + + server := &SwaggerServer{ + config: &ServeConfig{Executor: &ExecutorConfig{Enabled: true}}, + executor: executor, + } + + req := httptest.NewRequest("GET", "/api/v1/config", nil) + req.Header.Set("Accept", "application/json") + w := httptest.NewRecorder() + + server.handleExecuteCommand(w, req) + + res := w.Result() + require.Equal(t, 200, res.StatusCode) + + var got []row + require.NoError(t, json.NewDecoder(res.Body).Decode(&got), + "JSON body must be the data array, not the {success,exit_code,cli} envelope") + assert.Equal(t, want, got) +} diff --git a/rpc/executor.go b/rpc/executor.go index 82a58732..618f793b 100644 --- a/rpc/executor.go +++ b/rpc/executor.go @@ -2,6 +2,7 @@ package rpc import ( "bytes" + "context" "encoding/json" "fmt" "io" @@ -30,6 +31,20 @@ type CommandExecutor struct { type ExecutionRequest struct { Args []string `json:"args,omitempty"` // Positional arguments Flags map[string]string `json:"flags,omitempty"` // Flag values + + // Context carries the request's context.Context to a ContextDataFunc. On the + // HTTP path it is r.Context(); on the CLI path it is cmd.Context(). It is + // never serialized — it is transport state, not part of the wire contract. + Context context.Context `json:"-"` +} + +// ctx returns the request context, falling back to context.Background() when +// the request was built without one (older callers, tests). +func (r *ExecutionRequest) ctx() context.Context { + if r != nil && r.Context != nil { + return r.Context + } + return context.Background() } // ExecutionResponse represents the result of command execution @@ -43,6 +58,13 @@ type ExecutionResponse struct { Error string `json:"error,omitempty"` // Error description Input *ExecutionRequest `json:"input,omitempty"` // Processed input parameters (included on errors for debugging) CLI string `json:"cli,omitempty"` // Equivalent CLI command to reproduce this execution + + // DataIsStructured is true when the operation returned data via a DataFunc + // (entity list/get) rather than captured stdout. The HTTP layer uses it to + // serialize the data payload directly for structured wire formats instead of + // substituting this (output-less) envelope. Not serialized — it's transport + // metadata, not part of the response contract. + DataIsStructured bool `json:"-"` } // NewCommandExecutor creates a new command executor @@ -192,14 +214,29 @@ func (e *CommandExecutor) ExecuteCommand(op *RPCOperation, req *ExecutionRequest return resp, resp, fmt.Errorf("command execution is disabled") } - // If the operation has a DataFunc (registered via AddCommand), call it directly - // to get structured data without stdout capture. - if op.DataFunc != nil { - data, err := op.DataFunc(req.Flags, req.Args) + // If the operation has a (Context)DataFunc (registered via AddCommand), call + // it directly to get structured data without stdout capture. The + // context-aware variant is preferred so handlers can resolve request-scoped + // state from req.Context(). + if op.ContextDataFunc != nil || op.DataFunc != nil { + var ( + data any + err error + ) + if op.ContextDataFunc != nil { + data, err = op.ContextDataFunc(req.ctx(), req.Flags, req.Args) + } else { + data, err = op.DataFunc(req.Flags, req.Args) + } response := &ExecutionResponse{ Success: err == nil, ExitCode: 0, CLI: buildCLICommand(op, req), + // The payload is op.DataFunc's structured return value, not captured + // stdout. The HTTP layer must serialize `data` directly even for + // structured wire formats (json/yaml) — the envelope's Output is empty + // here, so substituting it would drop the entire result. + DataIsStructured: err == nil, } if err != nil { response.Error = err.Error() @@ -437,7 +474,8 @@ func (e *CommandExecutor) executeWithGlobalCapture(execCmd *cobra.Command, args // ExtractRequestFromHTTP extracts execution parameters from HTTP request func (e *CommandExecutor) ExtractRequestFromHTTP(r *http.Request, op *RPCOperation) (*ExecutionRequest, error) { req := &ExecutionRequest{ - Flags: make(map[string]string), + Flags: make(map[string]string), + Context: r.Context(), } // Extract path parameters from URL using the template. diff --git a/rpc/openapi_test.go b/rpc/openapi_test.go index 3e1aa46e..59da7cae 100644 --- a/rpc/openapi_test.go +++ b/rpc/openapi_test.go @@ -528,6 +528,48 @@ func TestOpenAPIGenerator_EntityResponseSchemas(t *testing.T) { assert.Contains(t, pauseSchema.Properties, "count") } +// TestOpenAPIGenerator_OptionalIDActionPath asserts that an entity action +// declared WithOptionalID generates a flat /api/v1// path +// (no {id} segment), so a no-id call does not collide with the entity's +// get-by-id route. A regular action without WithOptionalID keeps the +// /{id}/ shape. +func TestOpenAPIGenerator_OptionalIDActionPath(t *testing.T) { + const entityName = "openapi-optional-id-stack" + root := &cobra.Command{Use: "testapp"} + + clicky.NewEntity[openAPISchemaListItem, openAPISchemaOpts, openAPISchemaDetail](entityName). + List(func(openAPISchemaOpts) ([]openAPISchemaListItem, error) { + return nil, nil + }). + Get(func(id string) (openAPISchemaDetail, error) { + return openAPISchemaDetail{ID: id, Name: id}, nil + }). + WithAction(clicky.Action("overview", func(string, map[string]string) (openAPIPauseResult, error) { + return openAPIPauseResult{}, nil + }).WithMethod("GET").WithOptionalID()). + WithAction(clicky.Action("restart", func(string, map[string]string) (openAPIRestartResult, error) { + return openAPIRestartResult{Restarted: true}, nil + })). + Register() + + clicky.GenerateCLI(root) + + spec, err := NewOpenAPIGenerator(nil).GenerateFromCobra(root) + require.NoError(t, err) + + flatPath := "/api/v1/" + entityName + "/overview" + idScopedPath := "/api/v1/" + entityName + "/{id}/overview" + assert.Contains(t, spec.Paths, flatPath, + "optional-id action must register a flat path with no {id} segment") + assert.NotContains(t, spec.Paths, idScopedPath, + "optional-id action must NOT register an {id}-scoped path") + assert.Contains(t, spec.Paths[flatPath], "get", + "overview WithMethod(GET) must register under the GET verb") + + assert.Contains(t, spec.Paths, "/api/v1/"+entityName+"/{id}/restart", + "a regular action without WithOptionalID keeps its {id} segment") +} + func requireResponseSchema(t *testing.T, op OpenAPIOperation) *OpenAPISchema { t.Helper() response, ok := op.Responses["200"] diff --git a/rpc/serve.go b/rpc/serve.go index c65eb272..f636b0c5 100644 --- a/rpc/serve.go +++ b/rpc/serve.go @@ -649,9 +649,19 @@ func (s *SwaggerServer) handleExecuteCommand(w http.ResponseWriter, r *http.Requ } } - // Format and write response body using FormatManager (defaults to json) + // Format and write response body using FormatManager (defaults to json). + // Structured wire formats serialize the ExecutionResponse envelope so the + // rendered command output is addressable via `output`; render formats emit + // the command's parsed data directly. opts := extractFormatOpts(r) - s.writeFormattedResponse(w, r, data, opts, statusCode) + body := data + // Substitute the envelope only for stdout-capture commands, whose payload + // lives in metadata.Output. DataFunc operations (entity list/get) carry their + // payload in `data`; serializing the envelope there would drop the result. + if isStructuredWireFormat(opts.Format) && metadata != nil && !metadata.DataIsStructured { + body = metadata + } + s.writeFormattedResponse(w, r, body, opts, statusCode) } func isLookupRequest(r *http.Request) bool { @@ -668,6 +678,16 @@ func (s *SwaggerServer) handleLookupCommand(w http.ResponseWriter, r *http.Reque return } + // __lookup_filter / __lookup_q drive per-filter server-side search. They are + // not declared operation parameters, so ExtractRequestFromHTTP drops them; + // forward them into the flag map for buildLookupFunc to consume. + if v := r.URL.Query().Get("__lookup_filter"); v != "" { + req.Flags["__lookup_filter"] = v + } + if v := r.URL.Query().Get("__lookup_q"); v != "" { + req.Flags["__lookup_q"] = v + } + data, err := op.LookupFunc(req.Flags, req.Args) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -723,7 +743,12 @@ func extractFormatOpts(r *http.Request) formatOptions { opts.Limit, _ = strconv.Atoi(l) } - if f := r.URL.Query().Get("format"); f != "" { + // A `format` of pretty/tree is a command *render* format (it controls how + // the executed command renders its Output), not an HTTP wire format. When + // the client also sends an Accept header asking for a structured format, the + // Accept header wins for the wire format and the render format flows to the + // command unchanged. + if f := r.URL.Query().Get("format"); f != "" && !isRenderFormat(f) { opts.Format = f return opts } @@ -765,6 +790,28 @@ func extractFormatOpts(r *http.Request) formatOptions { return opts } +// isRenderFormat reports whether format is a human-facing render format used to +// style a command's Output (vs. a structured HTTP wire format). +func isRenderFormat(format string) bool { + switch format { + case "pretty", "tree": + return true + default: + return false + } +} + +// isStructuredWireFormat reports whether format serializes the ExecutionResponse +// envelope as structured data (so Output/Success/ExitCode are addressable). +func isStructuredWireFormat(format string) bool { + switch format { + case "json", "clicky-json", "yaml", "yml": + return true + default: + return false + } +} + func formatToContentType(format string) string { switch format { case "clicky-json": diff --git a/rpc/types.go b/rpc/types.go index b4db204e..e285afa1 100644 --- a/rpc/types.go +++ b/rpc/types.go @@ -1,6 +1,7 @@ package rpc import ( + "context" "reflect" "github.com/spf13/cobra" @@ -10,6 +11,13 @@ import ( // Used by commands registered via AddCommand to provide data to the HTTP handler. type DataFunc func(flags map[string]string, args []string) (any, error) +// ContextDataFunc is a context-aware variant of DataFunc. When set on an +// operation, the executor prefers it over DataFunc and passes the request's +// context.Context (from r.Context() on the HTTP path, or cmd.Context() on the +// CLI path). It lets handlers resolve request-scoped state (e.g. a per-request +// database/config bundle) instead of reaching for process globals. +type ContextDataFunc func(ctx context.Context, flags map[string]string, args []string) (any, error) + // RPCOperation represents a generic RPC operation that can be converted to various formats type RPCOperation struct { Name string `json:"name"` @@ -21,6 +29,7 @@ type RPCOperation struct { Method string `json:"method,omitempty"` // HTTP method Tags []string `json:"tags,omitempty"` // For grouping DataFunc DataFunc `json:"-"` // Direct data provider, bypasses stdout capture + ContextDataFunc ContextDataFunc `json:"-"` // Context-aware data provider; preferred over DataFunc when set LookupFunc DataFunc `json:"-"` // Direct filter metadata provider Clicky *ClickyOperationMeta `json:"-"` // Entity semantics used for OpenAPI extensions ResponseType reflect.Type `json:"-"` // Static response type for OpenAPI generation diff --git a/sub_command.go b/sub_command.go index 1ad947e4..4f2ce088 100644 --- a/sub_command.go +++ b/sub_command.go @@ -75,7 +75,7 @@ func flushPendingSubCommands(root *cobra.Command) { if actionName == "" { actionName = p.cmd.Use } - annotateEntityOperationCommand(p.cmd, parent, "action", "", "collection", actionName, "", false, false) + annotateEntityOperationCommand(p.cmd, parent, "action", "", "collection", actionName, "", false, false, false) } parent.AddCommand(p.cmd) } diff --git a/sub_command_test.go b/sub_command_test.go index 9b85faa5..4dd937f0 100644 --- a/sub_command_test.go +++ b/sub_command_test.go @@ -25,6 +25,7 @@ func resetEntityRegistry(t *testing.T) { entityRegistry = nil entityRegistryMu.Unlock() dataFuncRegistry = sync.Map{} + contextDataFuncRegistry = sync.Map{} lookupFuncRegistry = sync.Map{} responseMetaRegistry = sync.Map{} pendingSubCommandsMu.Lock() diff --git a/task/api.go b/task/api.go index 93acfcf2..8166bfb3 100644 --- a/task/api.go +++ b/task/api.go @@ -26,3 +26,58 @@ func JSONHandler(taskIDs ...string) http.Handler { _ = json.NewEncoder(w).Encode(snapshots) }) } + +// RunsHandler serves the run listing (RunMeta per group) for the generic +// task-manager view, filtered by the ?kind=, ?status=, and repeated ?label=k=v +// query params. +func RunsHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + runs := Runs(RunFilter{ + Kind: q.Get("kind"), + Status: q.Get("status"), + Labels: parseLabelFilter(q["label"]), + }) + if runs == nil { + runs = []RunMeta{} + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(runs) + }) +} + +// RunHandler serves the id-scoped snapshot (group + its tasks) for a single run. +// The id is read from the {id} path value of the registered route. +func RunHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + http.Error(w, "missing run id", http.StatusBadRequest) + return + } + snapshots := SnapshotByID(id) + if len(snapshots) == 0 { + http.Error(w, "run not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(snapshots) + }) +} + +// RegisterHandlers wires the generic task-manager API under prefix: +// +// GET {prefix}/tasks run listing (RunMeta[], ?kind=&status=&label=k=v) +// GET {prefix}/tasks/stream SSE stream of TaskSnapshots (?tasks=&kind=) +// GET {prefix}/tasks/{id} id-scoped snapshot (group + tasks) +// +// The {id} route reuses Go 1.22 net/http path-value routing; the stream route is +// registered before the {id} route so "stream" is not treated as an id. +func RegisterHandlers(mux *http.ServeMux, prefix string) { + prefix = strings.TrimSuffix(prefix, "/") + mux.Handle("GET "+prefix+"/tasks", RunsHandler()) + mux.Handle("GET "+prefix+"/tasks/stream", SSEHandler()) + mux.Handle("GET "+prefix+"/tasks/{id}", RunHandler()) +} diff --git a/task/group.go b/task/group.go index 31707c84..67b57bd7 100644 --- a/task/group.go +++ b/task/group.go @@ -9,11 +9,24 @@ import ( "golang.org/x/sync/semaphore" ) +// GroupMetadata is the queryable metadata attached to a run (task group). Kind +// classifies the run (e.g. "sql-fix", "test-run"), Labels carry arbitrary +// key/value facets for filtering, and Owner identifies who started it. All +// fields are optional. +type GroupMetadata struct { + Kind string `json:"kind,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Owner string `json:"owner,omitempty"` +} + // Group represents a group of tasks that can be managed collectively type Group struct { name string + id string // stable unique id; distinct from name for registry drill-down Items []Taskable // Can contain Tasks or nested Groups startTime time.Time + finishedAt time.Time // set lazily the first time the group is observed terminal + metadata GroupMetadata manager *Manager ctx context.Context cancel context.CancelFunc @@ -30,6 +43,86 @@ func WithConcurrency(concurrency int) TaskGroupOption { } } +// WithGroupID sets a caller-supplied stable id for the group. When unset, +// StartGroup assigns a uuid. +func WithGroupID(id string) TaskGroupOption { + return func(group *Group) { + group.id = id + } +} + +// WithKind classifies the run for the task-manager listing/filtering. +func WithKind(kind string) TaskGroupOption { + return func(group *Group) { + group.metadata.Kind = kind + } +} + +// WithLabels attaches filterable key/value facets to the run. +func WithLabels(labels map[string]string) TaskGroupOption { + return func(group *Group) { + group.metadata.Labels = labels + } +} + +// WithOwner records who started the run. +func WithOwner(owner string) TaskGroupOption { + return func(group *Group) { + group.metadata.Owner = owner + } +} + +// ID returns the group's stable unique id. +func (g *Group) ID() string { + g.mu.RLock() + defer g.mu.RUnlock() + return g.id +} + +// Metadata returns a copy of the group's metadata. +func (g *Group) Metadata() GroupMetadata { + g.mu.RLock() + defer g.mu.RUnlock() + md := GroupMetadata{Kind: g.metadata.Kind, Owner: g.metadata.Owner} + if g.metadata.Labels != nil { + md.Labels = make(map[string]string, len(g.metadata.Labels)) + for k, v := range g.metadata.Labels { + md.Labels[k] = v + } + } + return md +} + +// StartedAt returns the group's start time. +func (g *Group) StartedAt() time.Time { + g.mu.RLock() + defer g.mu.RUnlock() + return g.startTime +} + +// FinishedAt returns the time the group first became terminal, or the zero +// time if it is still running/pending. It is recorded lazily by observeTerminal +// (called from snapshotting) so it does not depend on a WaitFor caller. +func (g *Group) FinishedAt() time.Time { + g.mu.RLock() + defer g.mu.RUnlock() + return g.finishedAt +} + +// observeTerminal records finishedAt the first time the group is seen in a +// terminal status. Idempotent; safe to call on every snapshot. +func (g *Group) observeTerminal(status Status, now time.Time) { + switch status { + case StatusRunning, StatusPending: + return + } + g.mu.Lock() + if g.finishedAt.IsZero() { + g.finishedAt = now + } + g.mu.Unlock() +} + func (g *Group) GetTasks() []Taskable { return g.Items } diff --git a/task/manager.go b/task/manager.go index 23b4b0ce..389c616c 100644 --- a/task/manager.go +++ b/task/manager.go @@ -473,11 +473,12 @@ func (tm *Manager) StartWithResult(name string, taskFunc func(flanksourceContext func StartGroup[T any](name string, opts ...TaskGroupOption) TypedGroup[T] { ctx, cancel := context.WithCancel(context.Background()) group := &Group{ - name: name, - Items: make([]Taskable, 0), - manager: global, - ctx: ctx, - cancel: cancel, + name: name, + Items: make([]Taskable, 0), + startTime: time.Now(), + manager: global, + ctx: ctx, + cancel: cancel, } global.groups = append(global.groups, group) @@ -486,6 +487,13 @@ func StartGroup[T any](name string, opts ...TaskGroupOption) TypedGroup[T] { opt(group) } + // Assign a stable id unless the caller supplied one via WithGroupID. The id + // is what the registry drill-down (SnapshotByID) and SSE id filter key on, + // independent of the human-facing name. + if group.id == "" { + group.id = uuid.NewString() + } + if group.concurrency > 0 { group.sem = semaphore.NewWeighted(int64(group.concurrency)) } diff --git a/task/registry.go b/task/registry.go new file mode 100644 index 00000000..f1c754ab --- /dev/null +++ b/task/registry.go @@ -0,0 +1,159 @@ +package task + +import ( + "sort" + "strings" + "time" +) + +// runRetention is how long a finished run is kept queryable before GC removes +// it from the global manager. Live runs are never GC'd. +const runRetention = 10 * time.Minute + +// OnBeforeGC, if non-nil, is called with each group's full snapshot just before +// GCRuns removes it from the in-memory manager. The callback receives the +// group's stable ID and the full snapshot slice (group + child tasks). It is +// called while global.mu is held, so it must not call back into the task +// package. +var OnBeforeGC func(groupID string, snapshots []TaskSnapshot) + +// RunMeta is the listing summary for one run (task group): identity, metadata, +// status, timing, and child-task counts. It is what the generic task-manager +// list view renders; drill-down uses SnapshotByID. +type RunMeta struct { + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Owner string `json:"owner,omitempty"` + Status string `json:"status"` + StartedAt string `json:"startedAt,omitempty"` // RFC3339 + FinishedAt string `json:"finishedAt,omitempty"` // RFC3339 + Total int `json:"total"` + Completed int `json:"completed"` + Failed int `json:"failed"` + Running int `json:"running"` +} + +// RunFilter narrows the runs returned by Runs. Empty fields match everything. +type RunFilter struct { + Kind string + Status string + Labels map[string]string // every entry must match +} + +func (f RunFilter) Matches(m RunMeta) bool { + if f.Kind != "" && m.Kind != f.Kind { + return false + } + if f.Status != "" && m.Status != f.Status { + return false + } + for k, v := range f.Labels { + if m.Labels[k] != v { + return false + } + } + return true +} + +// RunMetaFromSnapshot lifts the group-level fields of a group snapshot into a +// RunMeta. The snapshot's ID is the group name; GroupID carries the stable id. +func RunMetaFromSnapshot(snap TaskSnapshot) RunMeta { + return RunMeta{ + ID: snap.GroupID, + Name: snap.Name, + Kind: snap.Kind, + Labels: snap.Labels, + Owner: snap.Owner, + Status: snap.Status, + StartedAt: snap.StartedAt, + FinishedAt: snap.FinishedAt, + Total: snap.Total, + Completed: snap.Completed, + Failed: snap.Failed, + Running: snap.Running, + } +} + +// Runs returns one RunMeta per registered group, newest-first, optionally +// narrowed by filter. It runs GC first so stale finished runs drop out. +func Runs(filter RunFilter) []RunMeta { + GCRuns() + return RunsRaw(filter) +} + +// RunsRaw is like Runs but does NOT trigger GC first. Callers that manage +// their own GC timing (e.g. an L2-backed wrapper that needs to snapshot +// before GC) use this to avoid double-GC. +func RunsRaw(filter RunFilter) []RunMeta { + if global == nil { + return nil + } + global.mu.RLock() + groups := make([]*Group, len(global.groups)) + copy(groups, global.groups) + global.mu.RUnlock() + + out := make([]RunMeta, 0, len(groups)) + for _, g := range groups { + meta := RunMetaFromSnapshot(SnapshotGroup(g)) + if filter.Matches(meta) { + out = append(out, meta) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].StartedAt > out[j].StartedAt }) + return out +} + +// SnapshotByID returns the group + task snapshots for the run with the given +// stable id (not name). Returns nil when no such run exists. +func SnapshotByID(id string) []TaskSnapshot { + return SnapshotAll(id) +} + +// GCRuns removes finished runs older than runRetention from the global manager. +// A run is "finished" once it has a non-zero FinishedAt (recorded the first time +// it is observed terminal). Live runs are retained regardless of age. If +// OnBeforeGC is set, each evicted group's full snapshot is passed to it before +// removal. +func GCRuns() { + if global == nil { + return + } + now := time.Now() + global.mu.Lock() + defer global.mu.Unlock() + kept := global.groups[:0] + for _, g := range global.groups { + g.observeTerminal(g.Status(), now) + finished := g.FinishedAt() + if !finished.IsZero() && now.Sub(finished) > runRetention { + if OnBeforeGC != nil { + OnBeforeGC(g.ID(), snapshotGroupWithTasks(g)) + } + continue + } + kept = append(kept, g) + } + global.groups = kept +} + +// parseLabelFilter parses repeated "k=v" query values into a label match map. +func parseLabelFilter(values []string) map[string]string { + if len(values) == 0 { + return nil + } + labels := make(map[string]string, len(values)) + for _, v := range values { + k, val, ok := strings.Cut(v, "=") + if !ok { + continue + } + labels[k] = val + } + if len(labels) == 0 { + return nil + } + return labels +} diff --git a/task/registry_test.go b/task/registry_test.go new file mode 100644 index 00000000..c8709909 --- /dev/null +++ b/task/registry_test.go @@ -0,0 +1,217 @@ +package task + +import ( + "testing" + "time" + + flanksourceContext "github.com/flanksource/commons/context" +) + +// withTestGlobal swaps the package global for a fresh, non-rendering test +// manager and restores it on cleanup, so registry tests don't see groups from +// other tests or the real process manager. +func withTestGlobal(t *testing.T) { + t.Helper() + original := global + global = newTestManager(2) + t.Cleanup(func() { + global.stopRender() + global = original + }) +} + +// runGroupToCompletion adds one task per name and blocks until the group is +// terminal. +func runGroupToCompletion(t *testing.T, g TypedGroup[any], names ...string) { + t.Helper() + for _, name := range names { + g.Add(name, func(ctx flanksourceContext.Context, tk *Task) (any, error) { + tk.Success() + return nil, nil + }) + } + deadline := time.After(5 * time.Second) + for { + if s := g.Status(); s != StatusRunning && s != StatusPending { + return + } + select { + case <-deadline: + t.Fatalf("group %q did not finish; status=%s", g.Name(), g.Status()) + case <-time.After(10 * time.Millisecond): + } + } +} + +func TestRunsListsGroupsWithMetadataAndDrillsDownByID(t *testing.T) { + withTestGlobal(t) + + gFix := StartGroup[any]("fix-run", + WithKind("sql-fix"), + WithLabels(map[string]string{"database": "OI" + "PA"}), + WithConcurrency(1), + ) + gTest := StartGroup[any]("test-run", + WithKind("test-run"), + WithConcurrency(1), + ) + runGroupToCompletion(t, gFix, "rebuild idx_a", "update stats") + runGroupToCompletion(t, gTest, "step 0") + + runs := Runs(RunFilter{}) + if len(runs) != 2 { + t.Fatalf("expected 2 runs, got %d", len(runs)) + } + + byKind := Runs(RunFilter{Kind: "sql-fix"}) + if len(byKind) != 1 || byKind[0].Name != "fix-run" { + t.Fatalf("kind filter expected [fix-run], got %+v", byKind) + } + if byKind[0].Total != 2 || byKind[0].Completed != 2 { + t.Fatalf("expected 2/2 completed, got %d/%d", byKind[0].Completed, byKind[0].Total) + } + + byLabel := Runs(RunFilter{Labels: map[string]string{"database": "OI" + "PA"}}) + if len(byLabel) != 1 || byLabel[0].Kind != "sql-fix" { + t.Fatalf("label filter expected sql-fix run, got %+v", byLabel) + } + + // Drill down by stable id returns only that run's group + tasks. + id := gFix.ID() + snaps := SnapshotByID(id) + groups, tasks := 0, 0 + for _, s := range snaps { + switch s.Type { + case "group": + groups++ + if s.GroupID != id { + t.Fatalf("group snapshot GroupID = %q, want %q", s.GroupID, id) + } + case "task": + tasks++ + if s.GroupID != id { + t.Fatalf("task snapshot GroupID = %q, want %q", s.GroupID, id) + } + } + } + if groups != 1 || tasks != 2 { + t.Fatalf("drill-down expected 1 group + 2 tasks, got %d + %d", groups, tasks) + } +} + +func TestFinishedAtRecordedOnTerminal(t *testing.T) { + withTestGlobal(t) + g := StartGroup[any]("done-run", WithConcurrency(1)) + runGroupToCompletion(t, g, "only step") + + // Snapshotting observes terminal and records finishedAt. + _ = SnapshotGroup(g.Group) + if g.FinishedAt().IsZero() { + t.Fatal("expected FinishedAt to be set after terminal snapshot") + } +} + +func TestGCRunsDropsExpiredFinishedRuns(t *testing.T) { + withTestGlobal(t) + g := StartGroup[any]("old-run", WithConcurrency(1)) + runGroupToCompletion(t, g, "step") + + // Force an old finishedAt so GC removes it. + g.mu.Lock() + g.finishedAt = time.Now().Add(-2 * runRetention) + g.mu.Unlock() + + GCRuns() + if got := len(Runs(RunFilter{})); got != 0 { + t.Fatalf("expected expired run to be GC'd, still have %d", got) + } +} + +func TestOnBeforeGCCalledWithSnapshotBeforeEviction(t *testing.T) { + withTestGlobal(t) + + g := StartGroup[any]("gc-hook-run", WithKind("test"), WithConcurrency(1)) + groupID := g.ID() + runGroupToCompletion(t, g, "step-a", "step-b") + + g.mu.Lock() + g.finishedAt = time.Now().Add(-2 * runRetention) + g.mu.Unlock() + + var capturedID string + var capturedSnaps []TaskSnapshot + OnBeforeGC = func(id string, snaps []TaskSnapshot) { + capturedID = id + capturedSnaps = snaps + } + t.Cleanup(func() { OnBeforeGC = nil }) + + GCRuns() + + if capturedID != groupID { + t.Fatalf("OnBeforeGC groupID = %q, want %q", capturedID, groupID) + } + if len(capturedSnaps) != 3 { + t.Fatalf("expected 3 snapshots (1 group + 2 tasks), got %d", len(capturedSnaps)) + } + if capturedSnaps[0].Type != "group" { + t.Fatalf("first snapshot should be group, got %q", capturedSnaps[0].Type) + } + if capturedSnaps[0].Total != 2 { + t.Fatalf("group snapshot Total = %d, want 2", capturedSnaps[0].Total) + } +} + +func TestOnBeforeGCNotCalledForLiveRuns(t *testing.T) { + withTestGlobal(t) + + g := StartGroup[any]("live-run", WithConcurrency(1)) + runGroupToCompletion(t, g, "step") + + called := false + OnBeforeGC = func(_ string, _ []TaskSnapshot) { called = true } + t.Cleanup(func() { OnBeforeGC = nil }) + + GCRuns() + + if called { + t.Fatal("OnBeforeGC should not be called for runs within retention period") + } +} + +func TestRunsRawSkipsGC(t *testing.T) { + withTestGlobal(t) + + g := StartGroup[any]("raw-run", WithConcurrency(1)) + runGroupToCompletion(t, g, "step") + + g.mu.Lock() + g.finishedAt = time.Now().Add(-2 * runRetention) + g.mu.Unlock() + + runs := RunsRaw(RunFilter{}) + if len(runs) != 1 { + t.Fatalf("RunsRaw should return expired runs (no GC), got %d", len(runs)) + } + + runs = Runs(RunFilter{}) + if len(runs) != 0 { + t.Fatalf("Runs should GC expired runs, got %d", len(runs)) + } +} + +func TestSnapshotAllBackwardCompatByName(t *testing.T) { + withTestGlobal(t) + g := StartGroup[any]("named-run", WithConcurrency(1)) + runGroupToCompletion(t, g, "step") + + // Legacy name-keyed filtering still works alongside id-keyed. + byName := SnapshotAll("named-run") + byID := SnapshotAll(g.ID()) + if len(byName) == 0 || len(byID) == 0 { + t.Fatalf("expected both name and id filters to return snapshots; name=%d id=%d", len(byName), len(byID)) + } + if byName[0].ID != "named-run" { + t.Fatalf("group snapshot ID should stay the name for back-compat, got %q", byName[0].ID) + } +} diff --git a/task/snapshot.go b/task/snapshot.go index 8c7dbbab..f2f821cb 100644 --- a/task/snapshot.go +++ b/task/snapshot.go @@ -1,6 +1,8 @@ package task import ( + "time" + "github.com/flanksource/commons/text" ) @@ -24,17 +26,31 @@ type TaskSnapshot struct { Completed int `json:"completed,omitempty"` // group: completed tasks Failed int `json:"failed,omitempty"` // group: failed tasks Running int `json:"running,omitempty"` // group: running tasks + + // Registry metadata (additive). For a group these describe the run itself; + // for a task GroupID links it to its parent run so the SSE/JSON clients can + // key on a stable id rather than the human-facing name. + GroupID string `json:"groupId,omitempty"` + Kind string `json:"kind,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Owner string `json:"owner,omitempty"` + StartedAt string `json:"startedAt,omitempty"` // RFC3339 + FinishedAt string `json:"finishedAt,omitempty"` // RFC3339 } -// SnapshotTask creates a TaskSnapshot from a Task. -func SnapshotTask(t *Task, groupName string) TaskSnapshot { +// SnapshotTask creates a TaskSnapshot from a Task. group is the parent group, or +// nil for an ungrouped task; its name and id are recorded on the snapshot. +func SnapshotTask(t *Task, group *Group) TaskSnapshot { snap := TaskSnapshot{ ID: t.ID(), Name: t.Name(), Type: "task", - Group: groupName, Status: string(t.Status()), } + if group != nil { + snap.Group = group.Name() + snap.GroupID = group.ID() + } if d := t.Duration(); d > 0 { snap.Duration = text.HumanizeDuration(d) } @@ -56,12 +72,28 @@ func SnapshotTask(t *Task, groupName string) TaskSnapshot { } // SnapshotGroup creates a TaskSnapshot from a Group with aggregate child stats. +// The snapshot ID stays the group NAME for backward compatibility with the +// name-keyed Preact UI and JSONHandler; the stable id is carried separately in +// GroupID. Observing a terminal status here records finishedAt lazily. func SnapshotGroup(g *Group) TaskSnapshot { + status := g.Status() + g.observeTerminal(status, time.Now()) + md := g.Metadata() snap := TaskSnapshot{ - ID: g.Name(), - Name: g.Name(), - Type: "group", - Status: string(g.Status()), + ID: g.Name(), + Name: g.Name(), + Type: "group", + Status: string(status), + GroupID: g.ID(), + Kind: md.Kind, + Labels: md.Labels, + Owner: md.Owner, + } + if started := g.StartedAt(); !started.IsZero() { + snap.StartedAt = started.UTC().Format(time.RFC3339) + } + if finished := g.FinishedAt(); !finished.IsZero() { + snap.FinishedAt = finished.UTC().Format(time.RFC3339) } g.mu.RLock() items := g.Items @@ -84,8 +116,24 @@ func SnapshotGroup(g *Group) TaskSnapshot { return snap } +// snapshotGroupWithTasks returns the group snapshot followed by its child task +// snapshots. The group's own lock is acquired internally; the caller must NOT +// hold global.mu exclusively (RLock is fine). +func snapshotGroupWithTasks(g *Group) []TaskSnapshot { + snaps := []TaskSnapshot{SnapshotGroup(g)} + g.mu.RLock() + items := g.Items + g.mu.RUnlock() + for _, item := range items { + snaps = append(snaps, SnapshotTask(item.GetTask(), g)) + } + return snaps +} + // SnapshotAll returns snapshots for all groups and their tasks. -// If taskIDs is non-empty, only groups whose name matches are included. +// If taskIDs is non-empty, only groups whose name OR stable id matches are +// included (matching by id lets the registry/SSE drill into one run; matching +// by name preserves the legacy name-keyed callers). func SnapshotAll(taskIDs ...string) []TaskSnapshot { if global == nil { return nil @@ -104,18 +152,10 @@ func SnapshotAll(taskIDs ...string) []TaskSnapshot { global.mu.RUnlock() for _, g := range groups { - if len(filter) > 0 && !filter[g.Name()] { + if len(filter) > 0 && !filter[g.Name()] && !filter[g.ID()] { continue } - snapshots = append(snapshots, SnapshotGroup(g)) - - g.mu.RLock() - items := g.Items - g.mu.RUnlock() - - for _, item := range items { - snapshots = append(snapshots, SnapshotTask(item.GetTask(), g.Name())) - } + snapshots = append(snapshots, snapshotGroupWithTasks(g)...) } return snapshots diff --git a/task/sse.go b/task/sse.go index 6ad16536..f83fdd8c 100644 --- a/task/sse.go +++ b/task/sse.go @@ -14,11 +14,18 @@ import ( // It sends an "event: done" when all tracked groups have completed. func SSEHandler(taskIDs ...string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Merge query param IDs with function-level IDs + // Merge query param IDs with function-level IDs. A ?kind= filter is + // resolved to the matching runs' ids so the stream can follow a whole + // kind, not just an explicit id/name list. ids := append([]string{}, taskIDs...) if q := r.URL.Query().Get("tasks"); q != "" { ids = append(ids, strings.Split(q, ",")...) } + if kind := r.URL.Query().Get("kind"); kind != "" { + for _, run := range Runs(RunFilter{Kind: kind}) { + ids = append(ids, run.ID) + } + } flusher, ok := w.(http.Flusher) if !ok { diff --git a/task/stop_test.go b/task/stop_test.go index 0ccfb89c..95b25afe 100644 --- a/task/stop_test.go +++ b/task/stop_test.go @@ -22,7 +22,7 @@ func TestSnapshotTaskIDIsStableAcrossRename(t *testing.T) { after := task.ID() assert.Equal(t, before, after) - assert.Equal(t, before, SnapshotTask(task, "group").ID) + assert.Equal(t, before, SnapshotTask(task, nil).ID) } func TestStopTaskCancelsRunningTaskByID(t *testing.T) { diff --git a/task/task.go b/task/task.go index fc86314a..b8a4576b 100644 --- a/task/task.go +++ b/task/task.go @@ -483,14 +483,34 @@ func (t *Task) WaitFor() *WaitResult { for !t.completed.Load() { select { case <-t.ctx.Done(): - // Task was canceled externally + // ctx.Done fires for two distinct reasons: a genuine external + // cancellation while the task is still pending/running, OR the task + // itself reaching a terminal status — SetStatus cancels t.ctx on + // Success/Failed/Warning/Cancelled. In the latter case the result is + // still being stored by runFunc (the task closure typically calls + // t.Success() before `return result`), so bailing out here would + // return the zero value before the result lands. Only treat ctx.Done + // as a real cancellation when the status is still non-terminal. t.mu.Lock() - if t.status == StatusRunning || t.status == StatusPending { + terminal := t.status != StatusRunning && t.status != StatusPending + if !terminal { t.status = StatusCancelled t.endTime = time.Now() t.completed.Store(true) } t.mu.Unlock() + if !terminal { + goto done + } + // Self-cancel during terminal SetStatus: ctx is now permanently + // ready, so re-selecting on it would busy-spin. Wait on doneChan + // (closed by the worker immediately after it stores the result and + // flips completed) so we wake the instant the result lands, with the + // overall timeout as a backstop. + select { + case <-t.doneChan: + case <-timeout: + } goto done case <-timeout: // Timeout fallback to prevent infinite waiting diff --git a/task/waitfor_result_test.go b/task/waitfor_result_test.go new file mode 100644 index 00000000..f60e3815 --- /dev/null +++ b/task/waitfor_result_test.go @@ -0,0 +1,44 @@ +package task + +import ( + "testing" + + flanksourceContext "github.com/flanksource/commons/context" + "github.com/stretchr/testify/assert" +) + +type waitForPayload struct { + N int + Label string +} + +// TestGetResultsReturnsResultAfterInClosureSuccess guards against a result-loss +// race: a task closure that calls t.Success() (or any terminal SetStatus) BEFORE +// `return result` cancels the task's own context, because SetStatus cancels +// t.ctx on terminal statuses. WaitFor watches t.ctx.Done(); without the fix it +// returned the instant the closure self-cancelled — before runFunc stored the +// result — so GetResults handed back the zero value. +// +// Many iterations are run because the window between t.Success() and the result +// store is small; pre-fix this produced zero-valued results intermittently. +func TestGetResultsReturnsResultAfterInClosureSuccess(t *testing.T) { + const iterations = 1000 + + for i := 0; i < iterations; i++ { + want := waitForPayload{N: i + 1, Label: "item"} + group := StartGroup[waitForPayload]("self-cancel-result") + group.Add("t", func(ctx flanksourceContext.Context, tk *Task) (waitForPayload, error) { + // Mirror a real task body that marks itself done before returning. + tk.SetName("running") + tk.Success() + return want, nil + }) + + results, err := group.GetResults() + assert.NoError(t, err) + assert.Len(t, results, 1) + for _, got := range results { + assert.Equal(t, want, got, "iteration %d: GetResults must return the populated result, not the zero value", i) + } + } +} diff --git a/valkey/go.mod b/valkey/go.mod new file mode 100644 index 00000000..13f29b4b --- /dev/null +++ b/valkey/go.mod @@ -0,0 +1,29 @@ +module github.com/flanksource/clicky/valkey + +go 1.26.1 + +require ( + github.com/alicebob/miniredis/v2 v2.38.0 + github.com/flanksource/clicky v0.0.0 + github.com/onsi/ginkgo/v2 v2.28.1 + github.com/onsi/gomega v1.39.1 + github.com/valkey-io/valkey-go v1.0.75 +) + +require ( + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.44.0 // indirect +) + +replace github.com/flanksource/clicky => ../ diff --git a/valkey/go.sum b/valkey/go.sum new file mode 100644 index 00000000..5bc0f7e6 --- /dev/null +++ b/valkey/go.sum @@ -0,0 +1,75 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw= +github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= +github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= +github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= +github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= +github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/valkey-io/valkey-go v1.0.75 h1:cfq9DODW2ntuUgyHJmFWb4/p+xpLpQB1t5SQyWM9uJ4= +github.com/valkey-io/valkey-go v1.0.75/go.mod h1:6X581PhgfeMkJmyfjIsa2eFdq6dy3Qkkg9zwjM1p42M= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/valkey/timeseries.go b/valkey/timeseries.go new file mode 100644 index 00000000..8905dd8a --- /dev/null +++ b/valkey/timeseries.go @@ -0,0 +1,120 @@ +// Package valkey implements clicky's metrics.Timeseries on top of a +// valkey/redis sorted set, one ZSET per metric ID. It lives in a separate Go +// module so the valkey-go dependency stays out of the root clicky module: +// callers that only need the in-process store (metrics.NewMemory) never pull +// it in. +// +// Each point is stored as a ZSET member ":" with the +// millisecond timestamp as the score (see metrics.EncodeMember). Range queries +// are ZRANGEBYSCORE; retention is enforced point-precisely on every Record via +// ZREMRANGEBYSCORE plus an EXPIRE backstop on the key. +package valkey + +import ( + "context" + "strconv" + "time" + + "github.com/valkey-io/valkey-go" + + "github.com/flanksource/clicky/metrics" +) + +// opTimeout caps any single round-trip so recording or querying metrics never +// stalls the caller. Mirrors the cache l2OpTimeout used by callers. +const opTimeout = 250 * time.Millisecond + +const defaultRetention = 7 * 24 * time.Hour + +// Config tunes the valkey-backed store. +type Config struct { + // KeyPrefix is prepended to every key so metrics share the caller's + // namespace (e.g. an app-wide prefix). The full key is + // KeyPrefix + "metric:" + id. + KeyPrefix string + // Retention drops points older than this on each Record. Zero -> 7d. + Retention time.Duration +} + +type store struct { + client valkey.Client + keyPrefix string + retention time.Duration +} + +// New returns a metrics.Timeseries backed by client. The client is owned by +// the caller (New does not close it), so an app can share its existing +// connection rather than opening a second one. +func New(client valkey.Client, cfg Config) metrics.Timeseries { + if cfg.Retention <= 0 { + cfg.Retention = defaultRetention + } + return &store{ + client: client, + keyPrefix: cfg.KeyPrefix, + retention: cfg.Retention, + } +} + +func (s *store) key(id string) string { + return s.keyPrefix + "metric:" + id +} + +func (s *store) Record(req metrics.RecordRequest) error { + at := req.At + if at.IsZero() { + at = time.Now() + } + key := s.key(req.ID) + point := metrics.Point{At: at, Value: req.Value} + cutoffMs := at.Add(-s.retention).UnixMilli() + + ctx, cancel := context.WithTimeout(context.Background(), opTimeout) + defer cancel() + + cmds := valkey.Commands{ + s.client.B().Zadd().Key(key).ScoreMember(). + ScoreMember(float64(at.UnixMilli()), metrics.EncodeMember(point)).Build(), + // Exclusive lower bound "(" keeps a point recorded exactly at + // the cutoff edge. + s.client.B().Zremrangebyscore().Key(key). + Min("-inf").Max("(" + strconv.FormatInt(cutoffMs, 10)).Build(), + s.client.B().Expire().Key(key).Seconds(int64(s.retention.Seconds())).Build(), + } + for _, resp := range s.client.DoMulti(ctx, cmds...) { + if err := resp.Error(); err != nil { + return err + } + } + return nil +} + +func (s *store) Query(req metrics.QueryRequest) ([]metrics.Point, error) { + ctx, cancel := context.WithTimeout(context.Background(), opTimeout) + defer cancel() + + members, err := s.client.Do(ctx, s.client.B().Zrangebyscore().Key(s.key(req.ID)). + Min(scoreBound(req.Since, "-inf")). + Max(scoreBound(req.Until, "+inf")).Build()).AsStrSlice() + if err != nil { + return nil, err + } + points := make([]metrics.Point, 0, len(members)) + for _, m := range members { + p, err := metrics.ParseMember(m) + if err != nil { + return nil, err + } + points = append(points, p) + } + return points, nil +} + +// scoreBound renders a ZRANGEBYSCORE bound: a zero time falls back to the +// unbounded sentinel ("-inf"/"+inf"), otherwise the unix-millis score. +func scoreBound(t time.Time, unbounded string) string { + if t.IsZero() { + return unbounded + } + return strconv.FormatInt(t.UnixMilli(), 10) +} diff --git a/valkey/timeseries_test.go b/valkey/timeseries_test.go new file mode 100644 index 00000000..2d2ad040 --- /dev/null +++ b/valkey/timeseries_test.go @@ -0,0 +1,87 @@ +package valkey_test + +import ( + "time" + + "github.com/alicebob/miniredis/v2" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + valkeygo "github.com/valkey-io/valkey-go" + + "github.com/flanksource/clicky/metrics" + "github.com/flanksource/clicky/valkey" +) + +var base = time.Date(2026, 6, 2, 12, 0, 0, 0, time.UTC) + +func newClient() (valkeygo.Client, *miniredis.Miniredis) { + mr, err := miniredis.Run() + Expect(err).NotTo(HaveOccurred()) + client, err := valkeygo.NewClient(valkeygo.ClientOption{ + InitAddress: []string{mr.Addr()}, + DisableCache: true, + }) + Expect(err).NotTo(HaveOccurred()) + return client, mr +} + +var _ = Describe("Valkey Timeseries", func() { + var ( + client valkeygo.Client + mr *miniredis.Miniredis + ts metrics.Timeseries + ) + + BeforeEach(func() { + client, mr = newClient() + ts = valkey.New(client, valkey.Config{KeyPrefix: "app:", Retention: time.Hour}) + }) + + AfterEach(func() { + client.Close() + mr.Close() + }) + + It("round-trips recorded points within a query range, ascending", func() { + for i, v := range []float64{1, 2, 3, 4, 5} { + Expect(ts.Record(metrics.RecordRequest{ + ID: "cpu", + At: base.Add(time.Duration(i) * time.Minute), + Value: v, + })).To(Succeed()) + } + + got, err := ts.Query(metrics.QueryRequest{ + ID: "cpu", + Since: base.Add(time.Minute), + Until: base.Add(3 * time.Minute), + }) + Expect(err).NotTo(HaveOccurred()) + Expect(got).To(Equal([]metrics.Point{ + {At: base.Add(time.Minute), Value: 2}, + {At: base.Add(2 * time.Minute), Value: 3}, + {At: base.Add(3 * time.Minute), Value: 4}, + })) + }) + + It("trims points older than the retention window on record", func() { + ts = valkey.New(client, valkey.Config{KeyPrefix: "app:", Retention: 10 * time.Minute}) + Expect(ts.Record(metrics.RecordRequest{ID: "cpu", At: base.Add(-time.Hour), Value: 1})).To(Succeed()) + Expect(ts.Record(metrics.RecordRequest{ID: "cpu", At: base, Value: 2})).To(Succeed()) + + got, err := ts.Query(metrics.QueryRequest{ID: "cpu"}) + Expect(err).NotTo(HaveOccurred()) + Expect(got).To(Equal([]metrics.Point{{At: base, Value: 2}})) + }) + + It("sets an expiry on the metric key", func() { + Expect(ts.Record(metrics.RecordRequest{ID: "cpu", At: base, Value: 1})).To(Succeed()) + Expect(mr.TTL("app:metric:cpu")).To(Equal(time.Hour)) + }) + + It("returns an empty slice for an unknown metric", func() { + got, err := ts.Query(metrics.QueryRequest{ID: "missing"}) + Expect(err).NotTo(HaveOccurred()) + Expect(got).To(BeEmpty()) + }) +}) diff --git a/valkey/valkey_suite_test.go b/valkey/valkey_suite_test.go new file mode 100644 index 00000000..e7bbe3b8 --- /dev/null +++ b/valkey/valkey_suite_test.go @@ -0,0 +1,13 @@ +package valkey_test + +import ( + "testing" + + "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestValkey(t *testing.T) { + RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Valkey Timeseries Suite") +}