diff --git a/go.mod b/go.mod index 25483824..f503d672 100644 --- a/go.mod +++ b/go.mod @@ -8,9 +8,10 @@ require ( github.com/atotto/clipboard v0.1.4 github.com/blacktop/go-dwarf v1.0.14 github.com/blacktop/go-macho v1.1.282 - github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/glamour v0.10.0 + github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/charmbracelet/x/ansi v0.9.3 github.com/getkin/kin-openapi v0.144.0 @@ -48,13 +49,16 @@ require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect + github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dlclark/regexp2 v1.11.0 // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -77,6 +81,7 @@ require ( github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // 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 diff --git a/go.sum b/go.sum index d22e401d..54f855e1 100644 --- a/go.sum +++ b/go.sum @@ -35,6 +35,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/adrg/frontmatter v0.2.0 h1:/DgnNe82o03riBd1S+ZDjd43wAmC6W35q67NHeLkPd4= github.com/adrg/frontmatter v0.2.0/go.mod h1:93rQCj3z3ZlwyxxpQioRKC1wDLto4aXHrbqIsnH9wmE= @@ -52,8 +54,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 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.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +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/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/blacktop/go-dwarf v1.0.14 h1:OjmzfSgg/qAKckn2tWFebcgKgJ7HOqCj7bS+CiE1lrY= @@ -61,33 +63,49 @@ github.com/blacktop/go-dwarf v1.0.14/go.mod h1:4W2FKgSFYcZLDwnR7k+apv5i3nrau4NGl github.com/blacktop/go-macho v1.1.282 h1:DW3HYz5zVCT6+Jlp1+hxauYvEgxqZsGMvu8/1j4+Ibg= github.com/blacktop/go-macho v1.1.282/go.mod h1:Hc5E2Lvt/U1VT+jOxr1O5l/LNFJeMYK4eAmDfazTiGc= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +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/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= -github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= +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.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +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/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +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.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +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 v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -97,6 +115,8 @@ github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cn github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= +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/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -255,6 +275,8 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 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/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= diff --git a/internal/sync/bootstrap/bootstrap.go b/internal/sync/bootstrap/bootstrap.go new file mode 100644 index 00000000..e001309f --- /dev/null +++ b/internal/sync/bootstrap/bootstrap.go @@ -0,0 +1,316 @@ +package bootstrap + +import ( + "cmp" + "errors" + "fmt" + "io" + "path" + "slices" + "strings" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncapi "github.com/launchdarkly/ldcli/internal/sync/api" + syncconsole "github.com/launchdarkly/ldcli/internal/sync/console" + syncinteractive "github.com/launchdarkly/ldcli/internal/sync/interactive" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" + syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" +) + +// Catalog lists projects and configs available for bootstrap. +type Catalog interface { + Projects() ([]syncapi.Project, error) + Configs(projectKey string) ([]syncapi.Config, error) +} + +// ManifestStore persists the synchronization baseline after local files are written. +type ManifestStore interface { + Load() (syncmanifest.Manifest, bool, error) + Write(syncmanifest.Manifest) error +} + +// Options contains the dependencies and streams for one bootstrap flow. +type Options struct { + Catalog Catalog + Store synclocal.Store + Manifest ManifestStore + Input io.Reader + Output io.Writer + Initial bool + DryRun bool +} + +// Run interactively selects prompt variations and writes their local wrappers. +func Run(options Options) error { + if !syncinteractive.StreamsAreTerminal(options.Input, options.Output) { + return fmt.Errorf( + "interactive prompt selection requires a terminal; run this command in a terminal", + ) + } + + files, canceled, err := selectVariationFiles(options) + if err != nil { + return err + } + if canceled { + return nil + } + return finishSelection(options, files) +} + +// selectVariationFiles guides the user from project to config to variations +// and converts the selections into local wrapper definitions. +func selectVariationFiles(options Options) ([]synclocal.VariationFile, bool, error) { + console := syncconsole.New(options.Output) + _ = console.Line("Loading LaunchDarkly projects...") + projects, err := options.Catalog.Projects() + if err != nil { + return nil, false, err + } + project, canceled, err := syncinteractive.Select( + options.Input, + options.Output, + "Choose a LaunchDarkly project", + projectChoices(projects), + ) + if err != nil || canceled { + return nil, canceled, err + } + + _ = console.Line("Loading configs...") + configs, err := options.Catalog.Configs(project.Key) + if err != nil { + return nil, false, err + } + config, canceled, err := syncinteractive.Select( + options.Input, + options.Output, + "Choose a config", + configChoices(configs), + ) + if err != nil || canceled { + return nil, canceled, err + } + if len(config.Variations) == 0 { + return nil, false, fmt.Errorf("config %q has no prompt variations", config.Key) + } + + choices, existingCount, err := variationChoices( + options.Store, + project.Key, + config, + ) + if err != nil { + return nil, false, err + } + if len(choices) == 0 { + return nil, false, nil + } + + action := "write" + if options.DryRun { + action = "preview" + } + description := fmt.Sprintf("Choose one or more variations to %s.", action) + if existingCount != 0 { + description += fmt.Sprintf(" %d already synced variations are omitted.", existingCount) + } + variations, canceled, err := syncinteractive.MultiSelect( + options.Input, + options.Output, + "Select prompt variations", + description, + choices, + ) + if err != nil || canceled { + return nil, canceled, err + } + + files := make([]synclocal.VariationFile, 0, len(variations)) + for _, variation := range variations { + files = append(files, synclocal.VariationFile{ + ProjectKey: project.Key, + ConfigKey: config.Key, + Upsert: true, + Variation: variation, + }) + } + return files, false, nil +} + +// variationChoices returns unsynchronized variations in stable display order +// and separately counts variations that already have local wrappers. +func variationChoices( + store synclocal.Store, + projectKey string, + config syncapi.Config, +) ([]syncinteractive.Choice[syncdomain.Variation], int, error) { + slices.SortFunc(config.Variations, func(left, right syncdomain.Variation) int { + return cmp.Or( + cmp.Compare(strings.ToLower(left.Name), strings.ToLower(right.Name)), + cmp.Compare(left.Key, right.Key), + ) + }) + + choices := make([]syncinteractive.Choice[syncdomain.Variation], 0, len(config.Variations)) + existingCount := 0 + for _, variation := range config.Variations { + exists, err := store.VariationExists(projectKey, config.Key, variation.Key) + if err != nil { + return nil, 0, err + } + if exists { + existingCount++ + continue + } + choices = append(choices, syncinteractive.Choice[syncdomain.Variation]{ + Title: variation.Name, Description: variation.Key, Value: variation, + }) + } + return choices, existingCount, nil +} + +// projectChoices adapts API projects to the shared interactive choice model. +func projectChoices(projects []syncapi.Project) []syncinteractive.Choice[syncapi.Project] { + choices := make([]syncinteractive.Choice[syncapi.Project], 0, len(projects)) + for _, project := range projects { + choices = append(choices, syncinteractive.Choice[syncapi.Project]{ + Title: project.Name, Description: project.Key, Value: project, + }) + } + return choices +} + +// configChoices adapts configs to labels that include both identity and mode. +func configChoices(configs []syncapi.Config) []syncinteractive.Choice[syncapi.Config] { + choices := make([]syncinteractive.Choice[syncapi.Config], 0, len(configs)) + for _, config := range configs { + choices = append(choices, syncinteractive.Choice[syncapi.Config]{ + Title: config.Name, Description: fmt.Sprintf("%s ยท %s", config.Key, config.Mode), Value: config, + }) + } + return choices +} + +// finishSelection validates the selected variations, renders dry-run previews, +// or commits the wrappers and their manifest fingerprints together. +func finishSelection(options Options, files []synclocal.VariationFile) error { + if len(files) == 0 { + writeNoChangeSummary(options.Output, options.DryRun) + return nil + } + for _, file := range files { + if err := syncdomain.ValidateDirectAPIVariation(file.Variation); err != nil { + return err + } + } + if options.DryRun { + previews, err := options.Store.RenderVariations(files) + if err != nil { + return err + } + writePreviews(options.Output, previews) + return nil + } + + manifest, _, err := options.Manifest.Load() + if err != nil { + return err + } + for _, file := range files { + lookupKey := file.ConfigKey + "/" + file.Variation.Key + fingerprint, err := syncdomain.FingerprintVariation(file.ProjectKey, lookupKey, file.Variation) + if err != nil { + return err + } + manifest.SetFingerprint(syncdomain.ResourceID{ + Kind: syncdomain.KindVariation, ProjectKey: file.ProjectKey, LookupKey: lookupKey, + }, fingerprint) + } + + var paths []string + if options.Initial { + paths, err = options.Store.Bootstrap(files) + } else { + paths, err = options.Store.Add(files) + } + if err != nil { + return err + } + // The manifest is written last so it never claims a wrapper exists before + // that wrapper reaches disk. Roll back the wrappers if persistence fails. + if err := options.Manifest.Write(manifest); err != nil { + return errors.Join(err, rollbackVariationFiles(options.Store, files)) + } + + writeSummary(options.Output, options.Initial, len(paths)) + + return nil +} + +// rollbackVariationFiles removes wrappers created by a failed bootstrap or add. +func rollbackVariationFiles(store synclocal.Store, files []synclocal.VariationFile) error { + deletions := make([]synclocal.VariationDeletion, 0, len(files)) + for _, file := range files { + deletions = append(deletions, synclocal.VariationDeletion{ + ProjectKey: file.ProjectKey, + ConfigKey: file.ConfigKey, + VariationKey: file.Variation.Key, + }) + } + _, err := store.DeleteVariations(deletions) + return err +} + +// writeNoChangeSummary explains that every available variation is already local. +func writeNoChangeSummary(output io.Writer, dryRun bool) { + message := "No variations added; every variation in that config is already synced." + if dryRun { + message = "No variations would be added; every variation in that config is already synced." + } + _ = syncconsole.New(output).Line(message) +} + +// writeSummary reports how many wrapper files were created. +func writeSummary(output io.Writer, initial bool, count int) { + verb := "Added" + if initial { + verb = "Bootstrapped" + } + + resource := "variation file" + if count != 1 { + resource += "s" + } + + _ = syncconsole.New(output).Printf( + "%s %d %s in %s.\n", + verb, + count, + resource, + syncdomain.RootDir, + ) +} + +// writePreviews prints each dry-run file with clear boundaries and its target path. +func writePreviews(output io.Writer, previews []synclocal.RenderedVariationFile) { + console := syncconsole.New(output) + for index, preview := range previews { + if index != 0 { + _ = console.Line("") + } + _ = console.Line("============================================================") + _ = console.Printf( + "File %d of %d\nWould create: %s\n", + index+1, + len(previews), + path.Join(syncdomain.RootDir, preview.Path), + ) + _ = console.Line("------------------------------------------------------------") + _ = console.WriteBytes(preview.Content) + if len(preview.Content) == 0 || preview.Content[len(preview.Content)-1] != '\n' { + _ = console.Line("") + } + _ = console.Line("============================================================") + } +} diff --git a/internal/sync/bootstrap/bootstrap_test.go b/internal/sync/bootstrap/bootstrap_test.go new file mode 100644 index 00000000..b16c1e6d --- /dev/null +++ b/internal/sync/bootstrap/bootstrap_test.go @@ -0,0 +1,285 @@ +package bootstrap + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncapi "github.com/launchdarkly/ldcli/internal/sync/api" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" + syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" +) + +func TestVariationChoicesSortsAndOmitsExistingVariations(t *testing.T) { + root := t.TempDir() + store := synclocal.NewStore(root) + _, err := store.Bootstrap([]synclocal.VariationFile{{ + ProjectKey: "project", + ConfigKey: "config", + Variation: syncdomain.Variation{ + Mode: syncdomain.VariationModeCompletion, + Key: "existing", + Name: "Existing", + }, + }}) + require.NoError(t, err) + config := syncapi.Config{ + Key: "config", + Mode: syncdomain.VariationModeCompletion, + Variations: []syncdomain.Variation{ + {Key: "z", Name: "Zulu"}, + {Key: "existing", Name: "Alpha"}, + {Key: "a", Name: "Alpha"}, + }, + } + + choices, existingCount, err := variationChoices(store, "project", config) + require.NoError(t, err) + assert.Equal(t, 1, existingCount) + require.Len(t, choices, 2) + assert.Equal(t, "a", choices[0].Value.Key) + assert.Equal(t, "z", choices[1].Value.Key) +} + +func TestVariationChoicesReturnsLocalInspectionError(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.Mkdir( + filepath.Join(root, syncdomain.RootDir), + 0o755, + )) + require.NoError(t, os.WriteFile( + filepath.Join(root, syncdomain.RootDir, "project"), + []byte("not a directory"), + 0o644, + )) + + config := syncapi.Config{ + Key: "config", + Variations: []syncdomain.Variation{{ + Key: "variation", + }}, + } + + _, _, err := variationChoices(synclocal.NewStore(root), "project", config) + require.ErrorContains(t, err, "inspect variation variation") +} + +func TestRunRequiresTerminal(t *testing.T) { + err := Run(Options{ + Catalog: &fakeCatalog{}, + Store: synclocal.NewStore(t.TempDir()), + Input: bytes.NewBuffer(nil), + Output: bytes.NewBuffer(nil), + Initial: true, + }) + + require.ErrorContains( + t, + err, + "interactive prompt selection requires a terminal", + ) +} + +func TestFinishSelectionDryRunDoesNotCreateFiles(t *testing.T) { + root := t.TempDir() + var output bytes.Buffer + + err := finishSelection(Options{ + Store: synclocal.NewStore(root), + Output: &output, + Initial: true, + DryRun: true, + }, []synclocal.VariationFile{{ + ProjectKey: "project", + ConfigKey: "config", + Upsert: true, + Variation: syncdomain.Variation{ + Key: "variation", + Name: "Variation", + Mode: syncdomain.VariationModeAgent, + Instructions: "Be helpful.", + }, + }}) + + require.NoError(t, err) + assert.Equal( + t, + `============================================================ +File 1 of 1 +Would create: .launchdarkly/project/configs/config/variation.prompt.md +------------------------------------------------------------ +--- +formatVersion: 1 +upsert: true +mode: agent +key: variation +name: Variation +--- + +Be helpful. +============================================================ +`, + output.String(), + ) + _, err = os.Stat(filepath.Join(root, syncdomain.RootDir)) + assert.ErrorIs(t, err, os.ErrNotExist) +} + +func TestFinishSelectionWritesInitialManifest(t *testing.T) { + root := t.TempDir() + var output bytes.Buffer + variation := syncdomain.Variation{ + Mode: syncdomain.VariationModeAgent, Key: "variation", Name: "Variation", Instructions: "Be helpful.", + } + + err := finishSelection(Options{ + Store: synclocal.NewStore(root), + Manifest: syncmanifest.NewStore(root), + Output: &output, + Initial: true, + }, []synclocal.VariationFile{{ + ProjectKey: "project", ConfigKey: "config", Upsert: true, Variation: variation, + }}) + + require.NoError(t, err) + manifest, exists, err := syncmanifest.NewStore(root).Load() + require.NoError(t, err) + require.True(t, exists) + require.Len(t, manifest.Resources, 1) + expected, err := syncdomain.FingerprintVariation("project", "config/variation", variation) + require.NoError(t, err) + assert.Equal(t, expected, manifest.Resources[0].Fingerprint) +} + +func TestFinishSelectionAddsMultipleVersionedVariationsToExistingManifest(t *testing.T) { + root := t.TempDir() + store := synclocal.NewStore(root) + manifestStore := syncmanifest.NewStore(root) + first := syncdomain.Variation{ + Mode: syncdomain.VariationModeAgent, Key: "first", Name: "First", Instructions: "First.", + } + second := syncdomain.Variation{ + Mode: syncdomain.VariationModeAgent, Key: "second", Name: "Second", Instructions: "Second.", + ModelConfigKey: "model", ModelConfigVersion: 2, + } + third := syncdomain.Variation{ + Mode: syncdomain.VariationModeAgent, Key: "third", Name: "Third", Instructions: "Third.", + ModelConfigKey: "model", ModelConfigVersion: 3, + } + require.NoError(t, finishSelection(Options{ + Store: store, Manifest: manifestStore, Output: io.Discard, Initial: true, + }, []synclocal.VariationFile{{ + ProjectKey: "project", ConfigKey: "config", Variation: first, + }})) + + require.NoError(t, finishSelection(Options{ + Store: store, Manifest: manifestStore, Output: io.Discard, + }, []synclocal.VariationFile{ + {ProjectKey: "project", ConfigKey: "config", Variation: second}, + {ProjectKey: "project", ConfigKey: "config", Variation: third}, + })) + + manifest, exists, err := manifestStore.Load() + require.NoError(t, err) + require.True(t, exists) + require.Len(t, manifest.Resources, 3) + assert.Equal(t, "config/first", manifest.Resources[0].LookupKey) + assert.Equal(t, "config/second", manifest.Resources[1].LookupKey) + assert.Equal(t, "config/third", manifest.Resources[2].LookupKey) +} + +func TestFinishSelectionRollsBackFilesWhenManifestWriteFails(t *testing.T) { + root := t.TempDir() + variation := syncdomain.Variation{ + Mode: syncdomain.VariationModeAgent, Key: "variation", Name: "Variation", Instructions: "Be helpful.", + } + + err := finishSelection(Options{ + Store: synclocal.NewStore(root), + Manifest: failingManifestStore{}, + Output: io.Discard, + Initial: true, + }, []synclocal.VariationFile{{ + ProjectKey: "project", ConfigKey: "config", Variation: variation, + }}) + + require.ErrorContains(t, err, "write manifest") + _, statErr := os.Stat(filepath.Join(root, syncdomain.RootDir)) + require.ErrorIs(t, statErr, os.ErrNotExist) +} + +func TestWriteSummary(t *testing.T) { + var output bytes.Buffer + + writeSummary( + &output, + true, + 2, + ) + assert.Equal( + t, + "Bootstrapped 2 variation files in .launchdarkly.\n", + output.String(), + ) + + output.Reset() + writeNoChangeSummary(&output, false) + assert.Contains(t, output.String(), "No variations added") +} + +func TestWritePreviewsPrintsEveryFile(t *testing.T) { + var output bytes.Buffer + + writePreviews(&output, []synclocal.RenderedVariationFile{ + {Path: "project/configs/config/first.prompt.md", Content: []byte("first\n")}, + {Path: "project/configs/config/second.prompt.md", Content: []byte("second\n")}, + }) + + assert.Equal( + t, + `============================================================ +File 1 of 2 +Would create: .launchdarkly/project/configs/config/first.prompt.md +------------------------------------------------------------ +first +============================================================ + +============================================================ +File 2 of 2 +Would create: .launchdarkly/project/configs/config/second.prompt.md +------------------------------------------------------------ +second +============================================================ +`, + output.String(), + ) +} + +type fakeCatalog struct{} + +var _ Catalog = &fakeCatalog{} + +func (*fakeCatalog) Projects() ([]syncapi.Project, error) { + return nil, nil +} + +func (*fakeCatalog) Configs(string) ([]syncapi.Config, error) { + return nil, nil +} + +type failingManifestStore struct{} + +func (failingManifestStore) Load() (syncmanifest.Manifest, bool, error) { + return syncmanifest.New(), false, nil +} + +func (failingManifestStore) Write(syncmanifest.Manifest) error { + return errors.New("write manifest") +} diff --git a/internal/sync/console/writer.go b/internal/sync/console/writer.go new file mode 100644 index 00000000..bf07934e --- /dev/null +++ b/internal/sync/console/writer.go @@ -0,0 +1,41 @@ +// Package console provides the shared writer used for sync command output. +package console + +import ( + "fmt" + "io" +) + +// Writer writes user-facing sync output to a command stream. +type Writer struct { + output io.Writer +} + +// New creates a console writer for output. +func New(output io.Writer) Writer { + return Writer{output: output} +} + +// Write writes text without adding a newline. +func (writer Writer) Write(text string) error { + _, err := io.WriteString(writer.output, text) + return err +} + +// WriteBytes writes bytes without adding a newline. +func (writer Writer) WriteBytes(content []byte) error { + _, err := writer.output.Write(content) + return err +} + +// Line writes text followed by a newline. +func (writer Writer) Line(text string) error { + _, err := fmt.Fprintln(writer.output, text) + return err +} + +// Printf writes formatted text. +func (writer Writer) Printf(format string, values ...any) error { + _, err := fmt.Fprintf(writer.output, format, values...) + return err +} diff --git a/internal/sync/console/writer_test.go b/internal/sync/console/writer_test.go new file mode 100644 index 00000000..1365d879 --- /dev/null +++ b/internal/sync/console/writer_test.go @@ -0,0 +1,21 @@ +package console + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWriter(t *testing.T) { + var output bytes.Buffer + writer := New(&output) + + require.NoError(t, writer.Write("Loading")) + require.NoError(t, writer.Printf(" %d", 2)) + require.NoError(t, writer.Line(" pages")) + require.NoError(t, writer.WriteBytes([]byte("Done\n"))) + + assert.Equal(t, "Loading 2 pages\nDone\n", output.String()) +} diff --git a/internal/sync/interactive/form.go b/internal/sync/interactive/form.go new file mode 100644 index 00000000..419a5ac8 --- /dev/null +++ b/internal/sync/interactive/form.go @@ -0,0 +1,136 @@ +package interactive + +import ( + "errors" + "fmt" + "io" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" +) + +const choiceSeparator = "\n " + +// Choice is one labeled value in an interactive selection. +type Choice[T any] struct { + Title string + Description string + Value T +} + +// Select asks the user to choose one value. +func Select[T any]( + input io.Reader, + output io.Writer, + title string, + choices []Choice[T], +) (T, bool, error) { + var zero T + if len(choices) == 0 { + return zero, false, fmt.Errorf("%s: no choices are available", title) + } + + selected := 0 + options := make([]huh.Option[int], len(choices)) + for index, choice := range choices { + options[index] = huh.NewOption(choiceLabel(choice), index) + } + + canceled, err := RunForm( + input, + output, + huh.NewSelect[int](). + Title(title). + Options(options...). + Value(&selected), + ) + if err != nil || canceled { + return zero, canceled, err + } + return choices[selected].Value, false, nil +} + +// MultiSelect asks the user to choose one or more values. +func MultiSelect[T any]( + input io.Reader, + output io.Writer, + title string, + description string, + choices []Choice[T], +) ([]T, bool, error) { + if len(choices) == 0 { + return nil, false, nil + } + + var selected []int + options := make([]huh.Option[int], len(choices)) + for index, choice := range choices { + options[index] = huh.NewOption(choiceLabel(choice), index) + } + + field := huh.NewMultiSelect[int](). + Title(title). + Description(description). + Options(options...). + Value(&selected). + Validate(func(values []int) error { + if len(values) == 0 { + return errors.New("select at least one resource") + } + return nil + }) + canceled, err := RunForm(input, output, field) + if err != nil || canceled { + return nil, canceled, err + } + + values := make([]T, 0, len(selected)) + for _, index := range selected { + values = append(values, choices[index].Value) + } + return values, false, nil +} + +// RunForm runs fields with the shared sync theme and terminal streams. +func RunForm( + input io.Reader, + output io.Writer, + fields ...huh.Field, +) (bool, error) { + err := huh.NewForm(huh.NewGroup(fields...)). + WithInput(input). + WithOutput(output). + WithTheme(formTheme()). + WithProgramOptions(tea.WithAltScreen()). + Run() + if errors.Is(err, huh.ErrUserAborted) { + return true, nil + } + return false, err +} + +// choiceLabel keeps the human-readable name visually above the stable key. +func choiceLabel[T any](choice Choice[T]) string { + if choice.Description == "" { + return choice.Title + } + return choice.Title + choiceSeparator + choice.Description + "\n" +} + +// formTheme applies the subdued sync palette consistently to every form. +func formTheme() *huh.Theme { + theme := huh.ThemeBase() + accent := lipgloss.Color("170") + + theme.Focused.Title = theme.Focused.Title.Foreground(accent).Bold(true) + theme.Focused.SelectSelector = theme.Focused.SelectSelector.Foreground(accent) + theme.Focused.MultiSelectSelector = theme.Focused.MultiSelectSelector.Foreground(accent) + theme.Focused.SelectedOption = theme.Focused.SelectedOption.Foreground(accent) + theme.Focused.SelectedPrefix = theme.Focused.SelectedPrefix.Foreground(accent) + theme.Focused.FocusedButton = theme.Focused.FocusedButton. + Background(accent). + Foreground(lipgloss.Color("0")) + + return theme +} diff --git a/internal/sync/interactive/form_test.go b/internal/sync/interactive/form_test.go new file mode 100644 index 00000000..92d52793 --- /dev/null +++ b/internal/sync/interactive/form_test.go @@ -0,0 +1,23 @@ +package interactive + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestChoiceLabelPlacesDescriptionBelowTitle(t *testing.T) { + assert.Equal( + t, + "Support prompt\n support\n", + choiceLabel(Choice[string]{ + Title: "Support prompt", + Description: "support", + }), + ) + assert.Equal( + t, + "Support prompt", + choiceLabel(Choice[string]{Title: "Support prompt"}), + ) +} diff --git a/internal/sync/interactive/terminal.go b/internal/sync/interactive/terminal.go new file mode 100644 index 00000000..0a8c96ca --- /dev/null +++ b/internal/sync/interactive/terminal.go @@ -0,0 +1,16 @@ +// Package interactive contains shared helpers for terminal-based sync flows. +package interactive + +import ( + "io" + "os" + + "golang.org/x/term" +) + +// StreamsAreTerminal reports whether both interactive streams are terminals. +func StreamsAreTerminal(input io.Reader, output io.Writer) bool { + in, inputIsFile := input.(*os.File) + out, outputIsFile := output.(*os.File) + return inputIsFile && outputIsFile && term.IsTerminal(int(in.Fd())) && term.IsTerminal(int(out.Fd())) +}