Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
db257c2
green: policy model with YAML generation (slice 1)
dangrondahl Apr 7, 2026
7ee86f3
green: expression builder helpers (slice 2)
dangrondahl Apr 7, 2026
307b8f4
green: skeleton create policy-file command with huh (slice 3)
dangrondahl Apr 7, 2026
f1c8941
green: attestation loop in wizard (slice 4)
dangrondahl Apr 7, 2026
636024c
green: expression builder wizard for conditions and exceptions (slice 5)
dangrondahl Apr 7, 2026
1ee753e
green: optional API lookups for flows and custom attestation types (s…
dangrondahl Apr 7, 2026
9a908c6
green: preview screen before writing policy YAML (slice 7)
dangrondahl Apr 7, 2026
0e4e415
green: add "Match by flow tag" mode to expression builder
dangrondahl Apr 7, 2026
c4309bc
refactor: rewrite wizard as bubbletea model with side-by-side layout
dangrondahl Apr 7, 2026
0966799
fix: confirm options not rendering + preview panel too narrow
dangrondahl Apr 7, 2026
df1f6b0
fix: confirm selections ignored due to bubbletea value semantics
dangrondahl Apr 7, 2026
1812622
fix: configure each rule fully before moving to the next
dangrondahl Apr 7, 2026
596f607
refactor: wizard asks for output filename instead of --output-file flag
dangrondahl Apr 7, 2026
11bba47
fix: always emit name explicitly, add wildcard type with validation
dangrondahl Apr 7, 2026
a989807
refactor: split createPolicyFile into focused files
dangrondahl Apr 7, 2026
4370127
refactor: move policy wizard to internal/policywizard
dangrondahl Apr 7, 2026
346ee03
green: add 38 tests for policywizard state machine
dangrondahl Apr 7, 2026
2f0b930
style: apply Kosli brand colors to policy wizard TUI
dangrondahl Apr 8, 2026
b967714
feat: optionally upload policy to Kosli after creating the file
dangrondahl Apr 8, 2026
682c518
feat: show spinner while fetching API data on startup
dangrondahl Apr 8, 2026
9a9bea4
feat: show default org in upload details with ability to change
dangrondahl Apr 8, 2026
be20ef7
fix: allow tabbing past filename and org fields to accept defaults
dangrondahl Apr 8, 2026
0c10db1
feat: show completion screen with policy URL inside the TUI
dangrondahl Apr 8, 2026
7f0797a
revert: remove policy upload feature, keep spinner and defaults
dangrondahl Apr 8, 2026
b11d2a0
fix: show immediate feedback before TUI starts
dangrondahl Apr 8, 2026
42b0aaa
cleanup: remove unused HasAPICredentials field
dangrondahl Apr 8, 2026
467c828
style: reduce TUI minimum width from 91 to 76 columns
dangrondahl Apr 8, 2026
715f1fc
style: fix alignment in step enum
dangrondahl Apr 8, 2026
b83939a
fix: validate output file extension and prevent overwrite
dangrondahl Apr 8, 2026
95accab
fix: guard against nil dereference in applyExpression
dangrondahl Apr 8, 2026
f5d6b2a
fix: use comma-ok type assertion for wizard model
dangrondahl Apr 8, 2026
a56e1b5
fix: guard FlowNameInExpr against empty slice
dangrondahl Apr 8, 2026
9409515
style: use slices.Clone for clearer slice copy intent
dangrondahl Apr 8, 2026
c57263e
fix: remove in/matches from custom comparison operators
dangrondahl Apr 8, 2026
e1eb928
fix: generate matches() function form for custom comparison
dangrondahl Apr 8, 2026
3aa1d34
feat: support exists() function in custom comparison
dangrondahl Apr 8, 2026
2b38e2d
feat: support and/or/not logical operators in expression builder
dangrondahl Apr 8, 2026
324137a
fix: not is a prefix operator, not a function
dangrondahl Apr 8, 2026
858caea
style: clarify that value input is ignored for exists operator
dangrondahl Apr 8, 2026
38c5434
docs: note that ComparisonExpr always quotes values
dangrondahl Apr 8, 2026
5c8c65f
refactor: extract fetchNameList to reduce duplication in API fetchers
dangrondahl Apr 8, 2026
476bef9
docs: add comment explaining default case in buildForm
dangrondahl Apr 8, 2026
872c07d
fix: detect terminal width at startup instead of hard-coding 120
dangrondahl Apr 8, 2026
e3f0cb9
fix: make UnwrapExpr tolerant of varying whitespace
dangrondahl Apr 8, 2026
2c51003
fix: validate regex in artifact name input
dangrondahl Apr 8, 2026
c47ae44
fix: restore sigs.k8s.io/kind to v0.31.0 after rebase
dangrondahl Apr 9, 2026
1aba159
fix: validate regex when matches operator is used in custom comparison
dangrondahl Apr 9, 2026
47ebc66
fix: parenthesize NegateExpr to avoid operator precedence ambiguity
dangrondahl Apr 9, 2026
aeae085
fix: guard CombineExprs against zero args
dangrondahl Apr 9, 2026
2c28464
fix: clear validationErr on successful retry in stepExprCustomOp
dangrondahl Apr 9, 2026
2dd5d56
fix: reject empty value for comparison and matches operators
dangrondahl Apr 9, 2026
aaa321f
fix: check file existence during wizard instead of only after
dangrondahl Apr 16, 2026
103089f
merge: resolve conflicts with main (dependency versions + TODO.md)
dangrondahl Apr 23, 2026
9a48e7d
fix: update policy schema URL to docs.kosli.com
dangrondahl Apr 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@
- [x] Slice 3: Show params in `--show-input` output
- [x] Slice 4: Update help text and examples

## kosli create policy-file

- [x] Slice 1: Policy model + YAML generation (`internal/policy/`)
- [x] Slice 2: Expression builder
- [x] Slice 3: Skeleton Cobra command + huh dependency
- [x] Slice 4: Attestation loop in wizard
- [x] Slice 5: Expression builder wizard
- [x] Slice 6: API lookups for flows and custom attestation types
- [x] Slice 7: Preview screen + polish

## Fakes & contract tests for GitHub API integration

### Slice 1: FakeGitHubClient + contract tests (`internal/github`) ← active
Expand Down
1 change: 1 addition & 0 deletions cmd/kosli/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ func newCreateCmd(out io.Writer) *cobra.Command {
newCreateEnvironmentCmd(out),
newCreateFlowCmd(out),
newCreatePolicyCmd(out),
newCreatePolicyFileCmd(out),
newCreateAttestationTypeCmd(out),
)
return cmd
Expand Down
158 changes: 158 additions & 0 deletions cmd/kosli/createPolicyFile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package main

import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"

tea "github.com/charmbracelet/bubbletea"
"github.com/kosli-dev/cli/internal/policywizard"
"github.com/kosli-dev/cli/internal/requests"
"github.com/spf13/cobra"
"golang.org/x/term"
)

const createPolicyFileShortDesc = `Interactively create a Kosli environment policy YAML file.`

const createPolicyFileLongDesc = createPolicyFileShortDesc + `
Launches an interactive wizard that guides you through building a policy file
conforming to the Kosli environment policy schema. The generated YAML is
written to a file you specify at the end of the wizard.

This command does not upload the policy to Kosli. Use ^kosli create policy^
to upload the generated file.

If ^--api-token^ and ^--org^ are set, the wizard will fetch flow names and
custom attestation types from the Kosli API to offer as suggestions.
`

const createPolicyFileExample = `
# create a policy file interactively:
kosli create policy-file
`

func newCreatePolicyFileCmd(out io.Writer) *cobra.Command {
cmd := &cobra.Command{
Use: "policy-file",
Short: createPolicyFileShortDesc,
Long: createPolicyFileLongDesc,
Example: createPolicyFileExample,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runCreatePolicyFile()
Comment thread
dangrondahl marked this conversation as resolved.
},
}

return cmd
}

func runCreatePolicyFile() error {
if !term.IsTerminal(int(os.Stdin.Fd())) {
return fmt.Errorf("this command requires an interactive terminal; write policy YAML manually or use 'kosli create policy' directly")
}

ctx := &policywizard.Context{}
if global.ApiToken != "" && global.Org != "" {
fmt.Fprint(os.Stderr, "Starting Kosli Policy Builder...\r")
ctx.FetchFunc = func() policywizard.FetchResult {
return policywizard.FetchResult{
FlowNames: fetchFlowNames(),
CustomAttestTypes: fetchCustomAttestationTypes(),
}
}
}

m := policywizard.NewModel(ctx)
finalModel, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
if err != nil {
return fmt.Errorf("wizard error: %w", err)
}

wm, ok := finalModel.(policywizard.Model)
if !ok {
return fmt.Errorf("unexpected model type from wizard")
}
if wm.Cancelled {
logger.Info("policy file creation cancelled")
return nil
}

yamlBytes, err := wm.Policy.ToYAML()
if err != nil {
return fmt.Errorf("failed to generate policy YAML: %w", err)
}

outPath := filepath.Clean(wm.OutputFile)
if err := validateOutputFile(outPath); err != nil {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UX note: file-exists check happens after the entire wizard completes

validateOutputFile runs after the user has gone through the full wizard. If the file already exists, the user loses all their work. Consider validating the file at wizard time (in stepSaveFile's validator) rather than only after the wizard exits.

One option: add an os.Stat check in validateYAMLExtension (forms.go:259) so the user gets immediate feedback within the wizard.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UX: file-exists check happens after the entire wizard completes

If the target file already exists, the user loses all their work after completing the full wizard. Consider moving the os.Stat existence check into validateYAMLExtension in forms.go so the user gets immediate feedback within the wizard at stepSaveFile. That way they can pick a different name without restarting.

Something like:

func validateYAMLExtension(s string) error {
	if s == "" {
		return nil
	}
	ext := strings.ToLower(filepath.Ext(s))
	if ext != ".yaml" && ext != ".yml" {
		return fmt.Errorf("file must have a .yaml or .yml extension")
	}
	if _, err := os.Stat(s); err == nil {
		return fmt.Errorf("file %q already exists; choose a different name", s)
	}
	return nil
}

The post-wizard validateOutputFile can remain as a safety net, but the primary check should be at input time.

return err
Comment thread
dangrondahl marked this conversation as resolved.
}

err = os.WriteFile(outPath, yamlBytes, 0644)
Comment thread
dangrondahl marked this conversation as resolved.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UX: file-exists check happens after the entire wizard

If the target file already exists, the user loses all their wizard work. Consider moving the os.Stat existence check into validateYAMLExtension in forms.go so the user gets immediate feedback within the wizard at stepSaveFile:

func validateYAMLExtension(s string) error {
	if s == "" {
		return nil
	}
	ext := strings.ToLower(filepath.Ext(s))
	if ext != ".yaml" && ext != ".yml" {
		return fmt.Errorf("file must have a .yaml or .yml extension")
	}
	if _, err := os.Stat(s); err == nil {
		return fmt.Errorf("file %q already exists; choose a different name", s)
	}
	return nil
}

The post-wizard validateOutputFile can remain as a safety net, but the primary check should happen at input time to avoid frustration.

if err != nil {
return fmt.Errorf("failed to write policy file: %w", err)
}
logger.Info("policy file written to %s", outPath)
return nil
}

func validateOutputFile(path string) error {
ext := strings.ToLower(filepath.Ext(path))
if ext != ".yaml" && ext != ".yml" {
return fmt.Errorf("output file must have a .yaml or .yml extension, got %q", filepath.Base(path))
}
if _, err := os.Stat(path); err == nil {
return fmt.Errorf("file %q already exists; remove it or choose a different name", path)
}
return nil
}
Comment thread
dangrondahl marked this conversation as resolved.

func fetchFlowNames() []string {
return fetchNameList("api/v2/flows", nil)
}
Comment thread
dangrondahl marked this conversation as resolved.

func fetchCustomAttestationTypes() []string {
return fetchNameList("api/v2/custom-attestation-types", func(name string) string {
return "custom:" + name
})
}

func fetchNameList(apiPath string, transform func(string) string) []string {
u, err := url.JoinPath(global.Host, apiPath, global.Org)
if err != nil {
logger.Debug("failed to build URL for %s: %v", apiPath, err)
return nil
}

reqParams := &requests.RequestParams{
Method: http.MethodGet,
URL: u,
Token: global.ApiToken,
}
response, err := kosliClient.Do(reqParams)
if err != nil {
logger.Debug("failed to fetch %s: %v", apiPath, err)
return nil
}

var items []map[string]any
if err := json.Unmarshal([]byte(response.Body), &items); err != nil {
Comment thread
dangrondahl marked this conversation as resolved.
logger.Debug("failed to parse %s response: %v", apiPath, err)
return nil
}

names := make([]string, 0, len(items))
for _, item := range items {
if name, ok := item["name"].(string); ok {
if transform != nil {
name = transform(name)
}
names = append(names, name)
}
}
return names
}
49 changes: 49 additions & 0 deletions cmd/kosli/createPolicyFile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package main

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestValidateOutputFile_AcceptsYamlExtension(t *testing.T) {
dir := t.TempDir()
assert.NoError(t, validateOutputFile(filepath.Join(dir, "policy.yaml")))
}

func TestValidateOutputFile_AcceptsYmlExtension(t *testing.T) {
dir := t.TempDir()
assert.NoError(t, validateOutputFile(filepath.Join(dir, "policy.yml")))
}

func TestValidateOutputFile_AcceptsUppercaseExtension(t *testing.T) {
dir := t.TempDir()
assert.NoError(t, validateOutputFile(filepath.Join(dir, "policy.YAML")))
}

func TestValidateOutputFile_RejectsNonYamlExtension(t *testing.T) {
dir := t.TempDir()
err := validateOutputFile(filepath.Join(dir, "policy.json"))
require.Error(t, err)
assert.Contains(t, err.Error(), ".yaml or .yml")
}

func TestValidateOutputFile_RejectsNoExtension(t *testing.T) {
dir := t.TempDir()
err := validateOutputFile(filepath.Join(dir, "policy"))
require.Error(t, err)
assert.Contains(t, err.Error(), ".yaml or .yml")
}

func TestValidateOutputFile_RejectsExistingFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "existing.yaml")
require.NoError(t, os.WriteFile(path, []byte("test"), 0644))

err := validateOutputFile(path)
require.Error(t, err)
assert.Contains(t, err.Error(), "already exists")
}
41 changes: 34 additions & 7 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,23 @@ require (
github.com/aws/aws-sdk-go-v2/service/lambda v1.89.1
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1
github.com/aws/smithy-go v1.25.0
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/containers/image/v5 v5.36.2
github.com/docker/docker v28.5.2+incompatible
github.com/docker/docker v28.3.2+incompatible
github.com/go-git/go-billy/v5 v5.8.0
github.com/go-git/go-git/v5 v5.18.0
github.com/go-playground/validator/v10 v10.30.2
github.com/google/go-github/v42 v42.0.0
github.com/hashicorp/go-retryablehttp v0.7.8
github.com/joshdk/go-junit v1.0.0
github.com/mattn/go-shellwords v1.0.13
github.com/mattn/go-shellwords v1.0.12
github.com/maxcnunes/httpfake v1.2.4
github.com/microsoft/azure-devops-go-api/azuredevops v1.0.0-b5
github.com/mitchellh/go-homedir v1.1.0
github.com/open-policy-agent/opa v1.15.2
github.com/open-policy-agent/opa v1.15.1
github.com/otiai10/copy v1.14.1
github.com/owenrumney/go-sarif/v2 v2.3.3
github.com/pkg/errors v0.9.1
Expand Down Expand Up @@ -64,6 +68,7 @@ require (
github.com/ProtonMail/go-crypto v1.2.0 // indirect
github.com/agnivade/levenshtein v1.2.1 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect
Expand All @@ -77,10 +82,20 @@ require (
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/blang/semver/v4 v4.0.0 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
Expand All @@ -97,8 +112,10 @@ require (
github.com/docker/docker-credential-helpers v0.9.3 // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/evanphx/json-patch/v5 v5.6.0 // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
Expand All @@ -118,6 +135,7 @@ require (
github.com/gobwas/glob v0.2.3 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
Expand Down Expand Up @@ -145,8 +163,12 @@ require (
github.com/lestrrat-go/httprc/v3 v3.0.2 // indirect
github.com/lestrrat-go/jwx/v3 v3.0.13 // indirect
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/spdystream v0.5.1 // indirect
github.com/moby/sys/capability v0.4.0 // indirect
Expand All @@ -156,6 +178,9 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
github.com/onsi/ginkgo/v2 v2.27.2 // indirect
Expand All @@ -174,6 +199,7 @@ require (
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.17.0 // indirect
github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
Expand All @@ -194,15 +220,16 @@ require (
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yashtewari/glob-intersection v0.2.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/sdk v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
Expand Down
Loading
Loading