Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
0ff5281
green: policy model with YAML generation (slice 1)
dangrondahl Apr 7, 2026
8a520a2
green: expression builder helpers (slice 2)
dangrondahl Apr 7, 2026
9e09e60
green: skeleton create policy-file command with huh (slice 3)
dangrondahl Apr 7, 2026
614ee30
green: attestation loop in wizard (slice 4)
dangrondahl Apr 7, 2026
79e33bf
green: expression builder wizard for conditions and exceptions (slice 5)
dangrondahl Apr 7, 2026
79edd82
green: optional API lookups for flows and custom attestation types (s…
dangrondahl Apr 7, 2026
62ebe42
green: preview screen before writing policy YAML (slice 7)
dangrondahl Apr 7, 2026
6dec77f
green: add "Match by flow tag" mode to expression builder
dangrondahl Apr 7, 2026
efe2ec1
refactor: rewrite wizard as bubbletea model with side-by-side layout
dangrondahl Apr 7, 2026
275e7da
fix: confirm options not rendering + preview panel too narrow
dangrondahl Apr 7, 2026
19eae60
fix: confirm selections ignored due to bubbletea value semantics
dangrondahl Apr 7, 2026
024418f
fix: configure each rule fully before moving to the next
dangrondahl Apr 7, 2026
29acfe6
refactor: wizard asks for output filename instead of --output-file flag
dangrondahl Apr 7, 2026
89c3858
fix: always emit name explicitly, add wildcard type with validation
dangrondahl Apr 7, 2026
72205a3
refactor: split createPolicyFile into focused files
dangrondahl Apr 7, 2026
f60f386
refactor: move policy wizard to internal/policywizard
dangrondahl Apr 7, 2026
eebfde2
green: add 38 tests for policywizard state machine
dangrondahl Apr 7, 2026
51fc780
style: apply Kosli brand colors to policy wizard TUI
dangrondahl Apr 8, 2026
2b533ef
feat: optionally upload policy to Kosli after creating the file
dangrondahl Apr 8, 2026
75dfbb1
feat: show spinner while fetching API data on startup
dangrondahl Apr 8, 2026
fa9595c
feat: show default org in upload details with ability to change
dangrondahl Apr 8, 2026
92c1fc4
fix: allow tabbing past filename and org fields to accept defaults
dangrondahl Apr 8, 2026
a09f6f2
feat: show completion screen with policy URL inside the TUI
dangrondahl Apr 8, 2026
b021e88
revert: remove policy upload feature, keep spinner and defaults
dangrondahl Apr 8, 2026
1701796
fix: show immediate feedback before TUI starts
dangrondahl Apr 8, 2026
59a6415
cleanup: remove unused HasAPICredentials field
dangrondahl Apr 8, 2026
fff862b
style: reduce TUI minimum width from 91 to 76 columns
dangrondahl Apr 8, 2026
6232841
style: fix alignment in step enum
dangrondahl Apr 8, 2026
d5d71a3
fix: validate output file extension and prevent overwrite
dangrondahl Apr 8, 2026
31dd3c6
fix: guard against nil dereference in applyExpression
dangrondahl Apr 8, 2026
6164c27
fix: use comma-ok type assertion for wizard model
dangrondahl Apr 8, 2026
c014b0f
fix: guard FlowNameInExpr against empty slice
dangrondahl Apr 8, 2026
335180a
style: use slices.Clone for clearer slice copy intent
dangrondahl Apr 8, 2026
ff45033
fix: remove in/matches from custom comparison operators
dangrondahl Apr 8, 2026
db5103b
fix: generate matches() function form for custom comparison
dangrondahl Apr 8, 2026
f5d6d48
feat: support exists() function in custom comparison
dangrondahl Apr 8, 2026
e7a0f8d
feat: support and/or/not logical operators in expression builder
dangrondahl Apr 8, 2026
8d2dee3
fix: not is a prefix operator, not a function
dangrondahl Apr 8, 2026
2fc8ae6
style: clarify that value input is ignored for exists operator
dangrondahl Apr 8, 2026
0f01af2
docs: note that ComparisonExpr always quotes values
dangrondahl Apr 8, 2026
ca33a1a
refactor: extract fetchNameList to reduce duplication in API fetchers
dangrondahl Apr 8, 2026
d480043
docs: add comment explaining default case in buildForm
dangrondahl Apr 8, 2026
692dfa0
fix: detect terminal width at startup instead of hard-coding 120
dangrondahl Apr 8, 2026
0a8ac1a
fix: make UnwrapExpr tolerant of varying whitespace
dangrondahl Apr 8, 2026
048b2a7
fix: validate regex in artifact name input
dangrondahl Apr 8, 2026
61ff258
fix: restore sigs.k8s.io/kind to v0.31.0 after rebase
dangrondahl Apr 9, 2026
41a4e99
fix: validate regex when matches operator is used in custom comparison
dangrondahl Apr 9, 2026
c83ccd9
fix: parenthesize NegateExpr to avoid operator precedence ambiguity
dangrondahl Apr 9, 2026
f4226b8
fix: guard CombineExprs against zero args
dangrondahl Apr 9, 2026
6d0c7f5
fix: clear validationErr on successful retry in stepExprCustomOp
dangrondahl Apr 9, 2026
5ad8b6e
fix: reject empty value for comparison and matches operators
dangrondahl Apr 9, 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 @@ -54,3 +54,13 @@
- [x] Slice 2: Add `--params` flag across all three commands
- [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
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()
},
}

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
}

err = os.WriteFile(outPath, yamlBytes, 0644)
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
}

func fetchFlowNames() []string {
return fetchNameList("api/v2/flows", nil)
}

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 {
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")
}
26 changes: 26 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ require (
github.com/aws/aws-sdk-go-v2/service/lambda v1.88.5
github.com/aws/aws-sdk-go-v2/service/s3 v1.98.0
github.com/aws/smithy-go v1.24.2
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.3.2+incompatible
github.com/go-git/go-billy/v5 v5.8.0
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.8 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
Expand All @@ -78,10 +83,20 @@ require (
github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // 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 @@ -98,8 +113,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 Down Expand Up @@ -147,8 +164,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.0 // indirect
github.com/moby/sys/capability v0.4.0 // indirect
Expand All @@ -158,6 +179,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 @@ -176,6 +200,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 @@ -196,6 +221,7 @@ 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
Expand Down
Loading
Loading