From 368d3a64081b5fe24290b270cf50ceca14d2c761 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Thu, 10 Sep 2026 16:25:34 -0400 Subject: [PATCH 1/3] feat(sync): bootstrap local prompt configs --- cmd/sync/bootstrap_test.go | 129 ++++++++ cmd/sync/debug.go | 11 +- cmd/sync/prompt.go | 64 +++- cmd/sync/prompt_test.go | 3 +- internal/sync/api/client.go | 296 +++++++++++++++++ internal/sync/api/client_test.go | 315 ++++++++++++++++++ internal/sync/bootstrap/bootstrap.go | 320 ++++++++++++++++++ internal/sync/bootstrap/bootstrap_test.go | 307 ++++++++++++++++++ internal/sync/bootstrap/update.go | 359 +++++++++++++++++++++ internal/sync/bootstrap/view.go | 149 +++++++++ internal/sync/client.go | 140 -------- internal/sync/client_test.go | 127 -------- internal/sync/{ => local}/compile.go | 42 ++- internal/sync/{ => local}/compile_test.go | 88 +++-- internal/sync/local/parser.go | 40 +++ internal/sync/local/store.go | 272 ++++++++++++++++ internal/sync/local/store_test.go | 186 +++++++++++ internal/sync/{ => local}/tool.go | 42 +-- internal/sync/{ => local}/tool_test.go | 26 +- internal/sync/{ => local}/variation.go | 182 +++++------ internal/sync/local/variation_test.go | 209 ++++++++++++ internal/sync/parser.go | 20 -- internal/sync/{ => repository}/git.go | 2 +- internal/sync/{ => repository}/git_test.go | 2 +- internal/sync/resource.go | 51 ++- internal/sync/variation_test.go | 117 ------- 26 files changed, 2894 insertions(+), 605 deletions(-) create mode 100644 cmd/sync/bootstrap_test.go create mode 100644 internal/sync/api/client.go create mode 100644 internal/sync/api/client_test.go create mode 100644 internal/sync/bootstrap/bootstrap.go create mode 100644 internal/sync/bootstrap/bootstrap_test.go create mode 100644 internal/sync/bootstrap/update.go create mode 100644 internal/sync/bootstrap/view.go delete mode 100644 internal/sync/client.go delete mode 100644 internal/sync/client_test.go rename internal/sync/{ => local}/compile.go (61%) rename internal/sync/{ => local}/compile_test.go (76%) create mode 100644 internal/sync/local/parser.go create mode 100644 internal/sync/local/store.go create mode 100644 internal/sync/local/store_test.go rename internal/sync/{ => local}/tool.go (54%) rename internal/sync/{ => local}/tool_test.go (76%) rename internal/sync/{ => local}/variation.go (52%) create mode 100644 internal/sync/local/variation_test.go delete mode 100644 internal/sync/parser.go rename internal/sync/{ => repository}/git.go (99%) rename internal/sync/{ => repository}/git_test.go (99%) delete mode 100644 internal/sync/variation_test.go diff --git a/cmd/sync/bootstrap_test.go b/cmd/sync/bootstrap_test.go new file mode 100644 index 00000000..084bd691 --- /dev/null +++ b/cmd/sync/bootstrap_test.go @@ -0,0 +1,129 @@ +package sync + +import ( + "bytes" + "net/url" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/cmd/cliflags" + "github.com/launchdarkly/ldcli/internal/resources" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncbootstrap "github.com/launchdarkly/ldcli/internal/sync/bootstrap" +) + +func TestRunPromptUsesSharedFlowForBootstrapAndAdd(t *testing.T) { + tests := map[string]struct { + createDirectory bool + add bool + wantInitial bool + }{ + "missing directory bootstraps": { + wantInitial: true, + }, + "add uses existing directory": { + createDirectory: true, + add: true, + wantInitial: false, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + root := initBootstrapRepo(t) + if test.createDirectory { + require.NoError(t, os.Mkdir(filepath.Join(root, syncdomain.RootDir), 0o755)) + } + t.Chdir(root) + + var called bool + runner := func(options syncbootstrap.Options) error { + called = true + assert.Equal(t, test.wantInitial, options.Initial) + assert.NotNil(t, options.Input) + assert.NotNil(t, options.Output) + return nil + } + + viper.Set(cliflags.AccessTokenFlag, "token") + viper.Set(cliflags.BaseURIFlag, "https://example.com") + t.Cleanup(viper.Reset) + + command := newPromptCmd(noopResourceClient{}, runner) + require.NoError(t, command.Flags().Set(addFlag, boolString(test.add))) + require.NoError(t, command.RunE(command, nil)) + assert.True(t, called) + }) + } +} + +func TestWriteRequestDebugIncludesQuery(t *testing.T) { + var output bytes.Buffer + writeRequestDebug( + &output, + "GET", + "https://example.com/api/v2/projects", + url.Values{ + "sort": {"name"}, + "limit": {"25"}, + "offset": {"50"}, + }, + nil, + ) + + assert.Contains( + t, + output.String(), + "Path: /api/v2/projects?limit=25&offset=50&sort=name", + ) +} + +type noopResourceClient struct{} + +var _ resources.Client = noopResourceClient{} + +func (noopResourceClient) MakeRequest( + string, + string, + string, + string, + url.Values, + []byte, + bool, +) ([]byte, error) { + return nil, nil +} + +func (noopResourceClient) MakeUnauthenticatedRequest(string, string, []byte) ([]byte, error) { + return nil, nil +} + +func initBootstrapRepo(t *testing.T) string { + t.Helper() + + root := t.TempDir() + command := exec.Command("git", "init", "--quiet") + command.Dir = root + output, err := command.CombinedOutput() + require.NoError(t, err, string(output)) + + command = exec.Command("git", "remote", "add", "origin", "git@github.com:launchdarkly/ldcli.git") + command.Dir = root + output, err = command.CombinedOutput() + require.NoError(t, err, string(output)) + + return root +} + +func boolString(value bool) string { + if value { + return "true" + } + return "false" +} diff --git a/cmd/sync/debug.go b/cmd/sync/debug.go index 82abd3f8..0a6a5077 100644 --- a/cmd/sync/debug.go +++ b/cmd/sync/debug.go @@ -22,7 +22,7 @@ func (c debugClient) MakeRequest( body []byte, isBeta bool, ) ([]byte, error) { - writeRequestDebug(c.out, method, endpoint, body) + writeRequestDebug(c.out, method, endpoint, query, body) return c.next.MakeRequest(accessToken, method, endpoint, contentType, query, body, isBeta) } @@ -31,9 +31,16 @@ func (c debugClient) MakeUnauthenticatedRequest(method, endpoint string, body [] return c.next.MakeUnauthenticatedRequest(method, endpoint, body) } -func writeRequestDebug(out io.Writer, method, endpoint string, body []byte) { +func writeRequestDebug(out io.Writer, method, endpoint string, query url.Values, body []byte) { path := endpoint if parsed, err := url.Parse(endpoint); err == nil { + values := parsed.Query() + for name, entries := range query { + for _, entry := range entries { + values.Add(name, entry) + } + } + parsed.RawQuery = values.Encode() path = parsed.RequestURI() } diff --git a/cmd/sync/prompt.go b/cmd/sync/prompt.go index dff30129..dc876d6f 100644 --- a/cmd/sync/prompt.go +++ b/cmd/sync/prompt.go @@ -12,44 +12,52 @@ import ( "github.com/launchdarkly/ldcli/cmd/validators" "github.com/launchdarkly/ldcli/internal/output" "github.com/launchdarkly/ldcli/internal/resources" - syncapi "github.com/launchdarkly/ldcli/internal/sync" + syncapi "github.com/launchdarkly/ldcli/internal/sync/api" + syncbootstrap "github.com/launchdarkly/ldcli/internal/sync/bootstrap" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" + syncrepository "github.com/launchdarkly/ldcli/internal/sync/repository" ) -const debugFlag = "debug" +const ( + addFlag = "add" + debugFlag = "debug" +) + +type bootstrapRunner func(syncbootstrap.Options) error func NewPromptCmd(client resources.Client) *cobra.Command { + return newPromptCmd(client, syncbootstrap.Run) +} + +func newPromptCmd(client resources.Client, bootstrap bootstrapRunner) *cobra.Command { cmd := &cobra.Command{ Use: "prompt", - Short: "Check synchronization status for local prompts", - Long: "Read local prompt resources and check their synchronization status with LaunchDarkly.", + Short: "Synchronize local prompt variations with LaunchDarkly", + Long: "Bootstrap, add, and synchronize local prompt variations with LaunchDarkly.", Args: func(cmd *cobra.Command, args []string) error { if err := cobra.NoArgs(cmd, args); err != nil { return err } return validators.Validate()(cmd, args) }, - RunE: runPrompt(client), + RunE: runPrompt(client, bootstrap), } - cmd.Flags().Bool(debugFlag, false, "Print the status request method, path, and body") + cmd.Flags().Bool(addFlag, false, "Select additional prompt variations to sync") + cmd.Flags().Bool(debugFlag, false, "Print request methods, paths, and bodies") cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) return cmd } -func runPrompt(client resources.Client) func(*cobra.Command, []string) error { +func runPrompt(client resources.Client, bootstrap bootstrapRunner) func(*cobra.Command, []string) error { return func(cmd *cobra.Command, _ []string) error { cwd, err := os.Getwd() if err != nil { return fmt.Errorf("get working directory: %w", err) } - repo, err := syncapi.IdentifyRepo(cwd) - if err != nil { - return err - } - - localResources, err := syncapi.Compile(os.DirFS(repo.Root)) + repo, err := syncrepository.IdentifyRepo(cwd) if err != nil { return err } @@ -60,9 +68,37 @@ func runPrompt(client resources.Client) func(*cobra.Command, []string) error { requestClient = debugClient{next: client, out: cmd.ErrOrStderr()} } - _, err = syncapi.NewAPIClient(requestClient).Status( + api := syncapi.NewAPIClient( + requestClient, viper.GetString(cliflags.AccessTokenFlag), viper.GetString(cliflags.BaseURIFlag), + ) + store := synclocal.NewStore(repo.Root) + storeExists, err := store.Exists() + if err != nil { + return err + } + add, _ := cmd.Flags().GetBool(addFlag) + if !storeExists || add { + err := bootstrap(syncbootstrap.Options{ + API: api, + Store: store, + Input: cmd.InOrStdin(), + Output: cmd.OutOrStdout(), + Initial: !storeExists, + }) + if err != nil { + return output.NewCmdOutputError(err, cliflags.GetOutputKind(cmd)) + } + return nil + } + + localResources, err := synclocal.Compile(os.DirFS(repo.Root)) + if err != nil { + return err + } + + _, err = api.Status( repo.Identifier, localResources, ) diff --git a/cmd/sync/prompt_test.go b/cmd/sync/prompt_test.go index 9e68cd6e..507c58a6 100644 --- a/cmd/sync/prompt_test.go +++ b/cmd/sync/prompt_test.go @@ -220,12 +220,13 @@ func writePrompt(t *testing.T, root, project, config, key string, upsert bool) { contents := []byte(`--- formatVersion: 1 upsert: ` + strconv.FormatBool(upsert) + ` +mode: completion key: ` + key + ` name: Test prompt --- Say hello. `) - require.NoError(t, os.WriteFile(filepath.Join(dir, key+".prompt"), contents, 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, key+".prompt.md"), contents, 0o644)) } func runGit(t *testing.T, dir string, args ...string) { diff --git a/internal/sync/api/client.go b/internal/sync/api/client.go new file mode 100644 index 00000000..0bcecb43 --- /dev/null +++ b/internal/sync/api/client.go @@ -0,0 +1,296 @@ +package api + +import ( + "encoding/json" + "fmt" + "maps" + "net/http" + "net/url" + + "github.com/launchdarkly/ldcli/internal/resources" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +const listPageLimit = 25 + +type APIClient struct { + client resources.Client + accessToken string + baseURI string +} + +type Project struct { + Key string `json:"key"` + Name string `json:"name"` +} + +type Config struct { + Key string `json:"key"` + Name string `json:"name"` + Mode syncdomain.VariationMode `json:"mode"` + Variations []syncdomain.Variation `json:"variations"` +} + +type ResourceStatus struct { + ProjectKey string `json:"projectKey"` + ResourceKind syncdomain.Kind `json:"resourceKind"` + LookupKey string `json:"lookupKey"` + Status string `json:"status"` + SyncDirection string `json:"syncDirection"` + ServerFingerprint syncdomain.Fingerprint `json:"serverFingerprint,omitempty"` + Error any `json:"error,omitempty"` +} + +type statusRequest struct { + RepoIdentifier string `json:"repoIdentifier"` + Resources []statusRequestResource `json:"resources"` +} + +type statusRequestResource struct { + Fingerprint syncdomain.Fingerprint `json:"fingerprint"` + ResourceKind syncdomain.Kind `json:"resourceKind"` + LookupKey string `json:"lookupKey"` + Upsert bool `json:"upsert"` +} + +type projectResources struct { + ProjectKey string + Resources []syncdomain.SyncedResource +} + +type listResponse[T any] struct { + Items []T `json:"items"` + TotalCount int `json:"totalCount"` +} + +func NewAPIClient(client resources.Client, accessToken, baseURI string) APIClient { + return APIClient{ + client: client, + accessToken: accessToken, + baseURI: baseURI, + } +} + +func (c APIClient) Projects(search string) ([]Project, error) { + endpoint, err := url.JoinPath(c.baseURI, "api/v2/projects") + if err != nil { + return nil, fmt.Errorf("build projects endpoint: %w", err) + } + + query := url.Values{"sort": {"name"}} + if search != "" { + query.Set("filter", "query:"+search) + } + return listAll[Project](c, endpoint, "projects", false, query) +} + +func (c APIClient) Configs(projectKey, search string) ([]Config, error) { + endpoint, err := url.JoinPath(c.baseURI, "api/v2/projects", projectKey, "ai-configs") + if err != nil { + return nil, fmt.Errorf("build configs endpoint: %w", err) + } + + query := url.Values{ + "sort": {"name"}, + "filter": {configFilter(search)}, + } + configs, err := listAll[Config](c, endpoint, "configs", true, query) + if err != nil { + return nil, err + } + for i := range configs { + if err := configs[i].applyMode(); err != nil { + return nil, err + } + } + + return configs, nil +} + +func configFilter(search string) string { + const modes = `mode anyOf ["agent","completion"]` + if search == "" { + return modes + } + + encoded, _ := json.Marshal(search) + return "query equals " + string(encoded) + "," + modes +} + +func (c APIClient) Config( + projectKey, + configKey string, +) (Config, error) { + endpoint, err := url.JoinPath(c.baseURI, "api/v2/projects", projectKey, "ai-configs", configKey) + if err != nil { + return Config{}, fmt.Errorf("build config endpoint: %w", err) + } + + response, err := c.client.MakeRequest( + c.accessToken, + http.MethodGet, + endpoint, + "", + nil, + nil, + true, + ) + if err != nil { + return Config{}, fmt.Errorf("get config %q: %w", configKey, err) + } + + var config Config + if err := json.Unmarshal(response, &config); err != nil { + return Config{}, fmt.Errorf("decode config response: %w", err) + } + if err := config.applyMode(); err != nil { + return Config{}, err + } + + return config, nil +} + +func (c *Config) applyMode() error { + if c.Mode == "" { + c.Mode = syncdomain.VariationModeCompletion + } + if !c.Mode.Valid() { + return fmt.Errorf("config %q has unsupported mode %q", c.Key, c.Mode) + } + for i := range c.Variations { + c.Variations[i].Mode = c.Mode + } + + return nil +} + +func (c APIClient) Status( + repoIdentifier string, + synced []syncdomain.SyncedResource, +) ([]ResourceStatus, error) { + statuses := make([]ResourceStatus, 0, len(synced)) + + for _, project := range groupResourcesByProject(synced) { + projectStatuses, err := c.projectStatus(repoIdentifier, project) + if err != nil { + return nil, err + } + statuses = append(statuses, projectStatuses...) + } + + return statuses, nil +} + +func listAll[T any]( + c APIClient, + endpoint, + resourceName string, + isBeta bool, + baseQuery url.Values, +) ([]T, error) { + var items []T + + for offset := 0; ; offset += listPageLimit { + query := maps.Clone(baseQuery) + query.Set("limit", fmt.Sprintf("%d", listPageLimit)) + query.Set("offset", fmt.Sprintf("%d", offset)) + + response, err := c.client.MakeRequest( + c.accessToken, + http.MethodGet, + endpoint, + "", + query, + nil, + isBeta, + ) + if err != nil { + return nil, fmt.Errorf("list %s: %w", resourceName, err) + } + + var page listResponse[T] + if err := json.Unmarshal(response, &page); err != nil { + return nil, fmt.Errorf("decode %s response: %w", resourceName, err) + } + + items = append(items, page.Items...) + if len(page.Items) < listPageLimit || + (page.TotalCount > 0 && len(items) >= page.TotalCount) { + return items, nil + } + } +} + +func (c APIClient) projectStatus( + repoIdentifier string, + project projectResources, +) ([]ResourceStatus, error) { + request := statusRequest{ + RepoIdentifier: repoIdentifier, + Resources: make([]statusRequestResource, 0, len(project.Resources)), + } + for _, resource := range project.Resources { + request.Resources = append(request.Resources, statusRequestResource{ + Fingerprint: resource.Fingerprint, + ResourceKind: resource.Kind, + LookupKey: resource.LookupKey, + Upsert: resource.Upsert, + }) + } + + body, err := json.MarshalIndent(request, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal status request: %w", err) + } + + endpoint, err := url.JoinPath( + c.baseURI, + "api/v2/projects", + project.ProjectKey, + "ai-configs/sync/status", + ) + if err != nil { + return nil, fmt.Errorf("build status endpoint: %w", err) + } + + response, err := c.client.MakeRequest( + c.accessToken, + http.MethodPost, + endpoint, + "application/json", + nil, + body, + false, + ) + if err != nil { + return nil, err + } + + var statuses []ResourceStatus + if err := json.Unmarshal(response, &statuses); err != nil { + return nil, fmt.Errorf("decode status response: %w", err) + } + + for i := range statuses { + statuses[i].ProjectKey = project.ProjectKey + } + + return statuses, nil +} + +func groupResourcesByProject(synced []syncdomain.SyncedResource) []projectResources { + var projects []projectResources + byProject := make(map[string]int) + + for _, resource := range synced { + index, ok := byProject[resource.ProjectKey] + if !ok { + index = len(projects) + byProject[resource.ProjectKey] = index + projects = append(projects, projectResources{ProjectKey: resource.ProjectKey}) + } + projects[index].Resources = append(projects[index].Resources, resource) + } + + return projects +} diff --git a/internal/sync/api/client_test.go b/internal/sync/api/client_test.go new file mode 100644 index 00000000..e167eb36 --- /dev/null +++ b/internal/sync/api/client_test.go @@ -0,0 +1,315 @@ +package api + +import ( + "encoding/json" + "errors" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/resources" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +type apiRequest struct { + AccessToken string + Method string + Path string + Query url.Values + Body []byte + IsBeta bool +} + +type apiClientStub struct { + Requests []apiRequest + Responses [][]byte + Err error +} + +var _ resources.Client = &apiClientStub{} + +func (c *apiClientStub) MakeRequest( + accessToken string, + method string, + path string, + _ string, + query url.Values, + body []byte, + isBeta bool, +) ([]byte, error) { + c.Requests = append(c.Requests, apiRequest{ + AccessToken: accessToken, + Method: method, + Path: path, + Query: query, + Body: append([]byte(nil), body...), + IsBeta: isBeta, + }) + if c.Err != nil { + return nil, c.Err + } + + return c.Responses[len(c.Requests)-1], nil +} + +func (*apiClientStub) MakeUnauthenticatedRequest(string, string, []byte) ([]byte, error) { + return nil, nil +} + +func TestAPIClient_ProjectsPaginatesInNameOrder(t *testing.T) { + firstPage := make([]Project, listPageLimit) + for index := range firstPage { + firstPage[index] = Project{ + Key: string(rune('a' + index)), + Name: string(rune('A' + index)), + } + } + + transport := &apiClientStub{Responses: [][]byte{ + mustJSON(t, listResponse[Project]{Items: firstPage, TotalCount: 26}), + mustJSON(t, listResponse[Project]{ + Items: []Project{{Key: "z", Name: "Z"}}, + TotalCount: 26, + }), + }} + + projects, err := NewAPIClient( + transport, + "token", + "https://example.com", + ).Projects("my-project") + require.NoError(t, err) + require.Len(t, projects, 26) + require.Len(t, transport.Requests, 2) + + assert.Equal(t, "GET", transport.Requests[0].Method) + assert.Equal(t, "https://example.com/api/v2/projects", transport.Requests[0].Path) + assert.Equal(t, "name", transport.Requests[0].Query.Get("sort")) + assert.Equal(t, "query:my-project", transport.Requests[0].Query.Get("filter")) + assert.Equal(t, "25", transport.Requests[0].Query.Get("limit")) + assert.Equal(t, "0", transport.Requests[0].Query.Get("offset")) + assert.Equal(t, "25", transport.Requests[1].Query.Get("offset")) + assert.Equal(t, "query:my-project", transport.Requests[1].Query.Get("filter")) + assert.Equal(t, "Z", projects[25].Name) +} + +func TestAPIClient_ConfigsPaginatesAndAppliesMode(t *testing.T) { + transport := &apiClientStub{Responses: [][]byte{mustJSON(t, listResponse[Config]{ + Items: []Config{{ + Key: "support", + Name: "Support agent", + Mode: syncdomain.VariationModeAgent, + Variations: []syncdomain.Variation{{ + Key: "helpful", + Name: "Helpful", + }}, + }}, + TotalCount: 1, + })}} + + configs, err := NewAPIClient( + transport, + "token", + "https://example.com", + ).Configs( + "project", + "customer support", + ) + require.NoError(t, err) + require.Len(t, configs, 1) + require.Len(t, configs[0].Variations, 1) + + request := transport.Requests[0] + assert.Equal(t, "https://example.com/api/v2/projects/project/ai-configs", request.Path) + assert.Equal(t, "token", request.AccessToken) + assert.Equal(t, "name", request.Query.Get("sort")) + assert.Equal(t, "25", request.Query.Get("limit")) + assert.Equal( + t, + `query equals "customer support",mode anyOf ["agent","completion"]`, + request.Query.Get("filter"), + ) + assert.True(t, request.IsBeta) + assert.Equal(t, syncdomain.VariationModeAgent, configs[0].Variations[0].Mode) +} + +func TestAPIClient_ConfigsFetchesEveryPage(t *testing.T) { + firstPage := make([]Config, listPageLimit) + for index := range firstPage { + firstPage[index] = Config{ + Key: string(rune('a' + index)), + Name: string(rune('A' + index)), + Mode: syncdomain.VariationModeCompletion, + } + } + transport := &apiClientStub{Responses: [][]byte{ + mustJSON(t, listResponse[Config]{Items: firstPage, TotalCount: 26}), + mustJSON(t, listResponse[Config]{ + Items: []Config{{ + Key: "z", + Name: "Z", + Mode: syncdomain.VariationModeAgent, + }}, + TotalCount: 26, + }), + }} + + configs, err := NewAPIClient( + transport, + "token", + "https://example.com", + ).Configs( + "project", + "", + ) + require.NoError(t, err) + require.Len(t, configs, 26) + require.Len(t, transport.Requests, 2) + assert.Equal(t, "0", transport.Requests[0].Query.Get("offset")) + assert.Equal(t, "25", transport.Requests[1].Query.Get("offset")) + assert.Equal(t, syncdomain.VariationModeAgent, configs[25].Mode) + assert.Equal( + t, + `mode anyOf ["agent","completion"]`, + transport.Requests[1].Query.Get("filter"), + ) + assert.True(t, transport.Requests[1].IsBeta) +} + +func TestAPIClient_ConfigGetsCurrentVariations(t *testing.T) { + transport := &apiClientStub{Responses: [][]byte{[]byte(`{ + "key": "completion", + "name": "Completion", + "mode": "completion", + "variations": [{"key": "strict", "name": "Strict"}] + }`)}} + + config, err := NewAPIClient( + transport, + "token", + "https://example.com", + ).Config( + "project", + "completion", + ) + require.NoError(t, err) + assert.Equal(t, syncdomain.VariationModeCompletion, config.Variations[0].Mode) + assert.Equal( + t, + "https://example.com/api/v2/projects/project/ai-configs/completion", + transport.Requests[0].Path, + ) + assert.True(t, transport.Requests[0].IsBeta) +} + +func TestAPIClient_ConfigRejectsUnsupportedMode(t *testing.T) { + transport := &apiClientStub{Responses: [][]byte{[]byte( + `{"key":"config","name":"Config","mode":"unknown","variations":[]}`, + )}} + + _, err := NewAPIClient( + transport, + "token", + "https://example.com", + ).Config( + "project", + "config", + ) + require.ErrorContains(t, err, `unsupported mode "unknown"`) +} + +func TestAPIClient_ProjectsInvalidResponse(t *testing.T) { + client := NewAPIClient( + &apiClientStub{Responses: [][]byte{[]byte(`not json`)}}, + "token", + "https://example.com", + ) + + _, err := client.Projects("") + require.ErrorContains(t, err, "decode projects response") +} + +func TestAPIClient_Status(t *testing.T) { + transport := &apiClientStub{ + Responses: [][]byte{ + []byte(`[{"resourceKind":"variation","lookupKey":"config/first","status":"local_changed","syncDirection":"code_canonical"}]`), + []byte(`[{"resourceKind":"tool","lookupKey":"search/2","status":"in_sync","syncDirection":"both"}]`), + }, + } + client := NewAPIClient(transport, "token", "https://example.com") + + statuses, err := client.Status( + "launchdarkly/ldcli", + []syncdomain.SyncedResource{ + { + ProjectKey: "alpha", + Kind: syncdomain.KindVariation, + LookupKey: "config/first", + Fingerprint: "sha256.first", + Upsert: true, + }, + { + ProjectKey: "zeta", + Kind: syncdomain.KindTool, + LookupKey: "search/2", + Fingerprint: "sha256.second", + }, + }, + ) + require.NoError(t, err) + require.Len(t, transport.Requests, 2) + require.Len(t, statuses, 2) + + assert.Equal(t, "POST", transport.Requests[0].Method) + assert.Equal(t, "token", transport.Requests[0].AccessToken) + assert.Equal(t, "https://example.com/api/v2/projects/alpha/ai-configs/sync/status", transport.Requests[0].Path) + assert.Equal(t, "alpha", statuses[0].ProjectKey) + assert.Equal(t, "zeta", statuses[1].ProjectKey) + + var request statusRequest + require.NoError(t, json.Unmarshal(transport.Requests[0].Body, &request)) + assert.Equal(t, "launchdarkly/ldcli", request.RepoIdentifier) + require.Len(t, request.Resources, 1) + assert.Equal(t, syncdomain.KindVariation, request.Resources[0].ResourceKind) + assert.Equal(t, "config/first", request.Resources[0].LookupKey) + assert.Equal(t, syncdomain.Fingerprint("sha256.first"), request.Resources[0].Fingerprint) + assert.True(t, request.Resources[0].Upsert) +} + +func TestAPIClient_StatusTransportError(t *testing.T) { + client := NewAPIClient( + &apiClientStub{Err: errors.New("unavailable")}, + "token", + "https://example.com", + ) + + _, err := client.Status( + "launchdarkly/ldcli", + []syncdomain.SyncedResource{{ProjectKey: "proj"}}, + ) + require.ErrorContains(t, err, "unavailable") +} + +func TestAPIClient_StatusInvalidResponse(t *testing.T) { + client := NewAPIClient( + &apiClientStub{Responses: [][]byte{[]byte(`not json`)}}, + "token", + "https://example.com", + ) + + _, err := client.Status( + "launchdarkly/ldcli", + []syncdomain.SyncedResource{{ProjectKey: "proj"}}, + ) + require.ErrorContains(t, err, "decode status response") +} + +func mustJSON(t *testing.T, value any) []byte { + t.Helper() + + data, err := json.Marshal(value) + require.NoError(t, err) + return data +} diff --git a/internal/sync/bootstrap/bootstrap.go b/internal/sync/bootstrap/bootstrap.go new file mode 100644 index 00000000..9e656ace --- /dev/null +++ b/internal/sync/bootstrap/bootstrap.go @@ -0,0 +1,320 @@ +package bootstrap + +import ( + "cmp" + "fmt" + "io" + "os" + "slices" + "strings" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "golang.org/x/term" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncapi "github.com/launchdarkly/ldcli/internal/sync/api" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" +) + +// Options contains the dependencies and streams required by the bootstrap +// wizard. +type Options struct { + API Client + Store synclocal.Store + Input io.Reader + Output io.Writer + Initial bool +} + +type Client interface { + Projects(search string) ([]syncapi.Project, error) + Configs(projectKey, search string) ([]syncapi.Config, error) + Config(projectKey, configKey string) (syncapi.Config, error) +} + +type step int + +const ( + selectProject step = iota + selectConfig + selectVariations +) + +type model struct { + api Client + store synclocal.Store + + step step + width int + height int + + projects remotePicker + configs remotePicker + variationList list.Model + variationsReady bool + + projectKey string + config syncapi.Config + + searching bool + notice string + err error + canceled bool +} + +type remotePicker struct { + list.Model + title string + query string + ready bool +} + +type projectItem struct { + project syncapi.Project +} + +func (i projectItem) Title() string { return i.project.Name } +func (i projectItem) Description() string { return i.project.Key } +func (i projectItem) FilterValue() string { return i.project.Name + " " + i.project.Key } + +type configItem struct { + config syncapi.Config +} + +func (i configItem) Title() string { return i.config.Name } +func (i configItem) Description() string { + return fmt.Sprintf("%s · %s", i.config.Key, i.config.Mode) +} +func (i configItem) FilterValue() string { return i.config.Name + " " + i.config.Key } + +type variationItem struct { + variation syncdomain.Variation + selected bool + existing bool +} + +func (i variationItem) Title() string { return i.variation.Name } +func (i variationItem) FilterValue() string { return i.variation.Name + " " + i.variation.Key } +func (i variationItem) Description() string { + if i.existing { + return i.variation.Key + " · already synced" + } + return i.variation.Key +} + +type remoteFetchedMsg struct { + step step + projectKey string + query string + items []list.Item +} + +type fetchFailedMsg struct { + step step + projectKey string + query string + err error +} + +type configFetchedMsg struct { + projectKey string + config syncapi.Config + existing map[string]bool +} + +type errMsg struct { + err error +} + +func newModel(options Options) model { + return model{ + api: options.API, + store: options.Store, + step: selectProject, + projects: remotePicker{title: "Select a LaunchDarkly project"}, + configs: remotePicker{title: "Select a Config"}, + } +} + +func (m model) Init() tea.Cmd { + return m.fetchProjects("") +} + +func (m model) fetchProjects(search string) tea.Cmd { + return func() tea.Msg { + projects, err := m.api.Projects(search) + if err != nil { + return fetchFailedMsg{step: selectProject, query: search, err: err} + } + + items := make([]list.Item, len(projects)) + for index, project := range projects { + items[index] = projectItem{project: project} + } + + return remoteFetchedMsg{ + step: selectProject, + query: search, + items: items, + } + } +} + +func (m model) fetchConfigs(search string) tea.Cmd { + projectKey := m.projectKey + + return func() tea.Msg { + configs, err := m.api.Configs( + projectKey, + search, + ) + if err != nil { + return fetchFailedMsg{ + step: selectConfig, + projectKey: projectKey, + query: search, + err: err, + } + } + + items := make([]list.Item, len(configs)) + for index, config := range configs { + items[index] = configItem{config: config} + } + + return remoteFetchedMsg{ + step: selectConfig, + projectKey: projectKey, + query: search, + items: items, + } + } +} + +func (m model) fetchConfig() tea.Cmd { + projectKey, configKey := m.projectKey, m.config.Key + + return func() tea.Msg { + config, err := m.api.Config( + projectKey, + configKey, + ) + if err != nil { + return errMsg{err: err} + } + + slices.SortFunc(config.Variations, func(a, b syncdomain.Variation) int { + return cmp.Or( + cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)), + cmp.Compare(a.Key, b.Key), + ) + }) + + existing := make(map[string]bool, len(config.Variations)) + for _, variation := range config.Variations { + found, err := m.store.VariationExists(projectKey, configKey, variation.Key) + if err != nil { + return errMsg{err: err} + } + existing[variation.Key] = found + } + + return configFetchedMsg{ + projectKey: projectKey, + config: config, + existing: existing, + } + } +} + +func (m model) selectedVariationFiles() []synclocal.VariationFile { + var resources []synclocal.VariationFile + + for _, raw := range m.variationList.Items() { + item, ok := raw.(variationItem) + if !ok || !item.selected { + continue + } + + resources = append(resources, synclocal.VariationFile{ + ProjectKey: m.projectKey, + ConfigKey: m.config.Key, + Upsert: true, + Variation: item.variation, + }) + } + + return resources +} + +// Run starts the interactive wizard and writes a completion summary. +func Run(options Options) error { + if !terminalStreams(options.Input, options.Output) { + return fmt.Errorf("interactive prompt selection requires a terminal; run this command in a terminal") + } + + program := tea.NewProgram( + newModel(options), + tea.WithAltScreen(), + tea.WithInput(options.Input), + tea.WithOutput(options.Output), + ) + final, err := program.Run() + if err != nil { + return err + } + + result, ok := final.(model) + if !ok { + return fmt.Errorf("bootstrap returned an unexpected model") + } + if result.err != nil || result.canceled { + return result.err + } + + files := result.selectedVariationFiles() + if len(files) == 0 { + writeSummary(options.Output, options.Initial, true, nil) + return nil + } + + var paths []string + if options.Initial { + paths, err = options.Store.Bootstrap(files) + } else { + paths, err = options.Store.Add(files) + } + if err != nil { + return err + } + + writeSummary(options.Output, options.Initial, false, paths) + return nil +} + +func terminalStreams(input io.Reader, output io.Writer) bool { + in, inOK := input.(*os.File) + out, outOK := output.(*os.File) + + return inOK && outOK && + term.IsTerminal(int(in.Fd())) && + term.IsTerminal(int(out.Fd())) +} + +func writeSummary(out io.Writer, initial, noChange bool, paths []string) { + if noChange { + fmt.Fprintln(out, "No variations added; every variation in that Config is already synced.") + return + } + + action := "Added" + if initial { + action = "Bootstrapped" + } + resource := "variation file" + if len(paths) != 1 { + resource += "s" + } + fmt.Fprintf(out, "%s %d %s in %s.\n", action, len(paths), resource, syncdomain.RootDir) +} diff --git a/internal/sync/bootstrap/bootstrap_test.go b/internal/sync/bootstrap/bootstrap_test.go new file mode 100644 index 00000000..4c042e44 --- /dev/null +++ b/internal/sync/bootstrap/bootstrap_test.go @@ -0,0 +1,307 @@ +package bootstrap + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "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" +) + +func TestModelSelectsAndWritesVariations(t *testing.T) { + root := t.TempDir() + result := newModel(Options{ + API: testAPIClient(), + Store: synclocal.NewStore(root), + Initial: true, + }) + result = updateModel(t, result, tea.WindowSizeMsg{Width: 80, Height: 24}) + result = updateModel(t, result, projectResults( + "", + syncapi.Project{Key: "project", Name: "Project"}, + )) + + next, cmd := result.handleEnter() + result = next.(model) + assert.Equal(t, selectConfig, result.step) + assert.NotNil(t, cmd) + + result = updateModel(t, result, remoteFetchedMsg{ + step: selectConfig, + projectKey: "project", + items: []list.Item{configItem{config: syncapi.Config{ + Key: "assistant", Name: "Assistant", Mode: syncdomain.VariationModeAgent, + }}}, + }) + next, cmd = result.handleEnter() + result = next.(model) + assert.Equal(t, selectVariations, result.step) + assert.NotNil(t, cmd) + + result = updateModel(t, result, configFetchedMsg{ + projectKey: "project", + config: syncapi.Config{ + Key: "assistant", + Name: "Assistant", + Mode: syncdomain.VariationModeAgent, + Variations: []syncdomain.Variation{ + { + Mode: syncdomain.VariationModeAgent, + Key: "friendly", + Name: "Friendly", + }, + { + Mode: syncdomain.VariationModeAgent, + Key: "existing", + Name: "Existing", + }, + }, + }, + existing: map[string]bool{"existing": true}, + }) + + result.toggleVariation() + selected, _ := result.variationCounts() + assert.Equal(t, 1, selected) + result.selectAllVariations() + assert.False(t, result.variationList.Items()[1].(variationItem).selected) + + next, cmd = result.handleEnter() + result = next.(model) + require.NotNil(t, cmd) + + files := result.selectedVariationFiles() + require.Len(t, files, 1) + paths, err := result.store.Bootstrap(files) + require.NoError(t, err) + assert.Equal(t, []string{"project/configs/assistant/friendly.prompt.md"}, paths) + + contents, err := os.ReadFile(filepath.Join( + root, + syncdomain.RootDir, + "project", + "configs", + "assistant", + "friendly.prompt.md", + )) + require.NoError(t, err) + assert.Contains(t, string(contents), "mode: agent") + _, err = os.Stat(filepath.Join( + root, + syncdomain.RootDir, + "project", + "configs", + "assistant", + "existing.prompt.md", + )) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestModelAllExistingIsNoOp(t *testing.T) { + result := newModel(Options{ + API: testAPIClient(), + Store: synclocal.NewStore(t.TempDir()), + }) + result.step = selectVariations + result.variationsReady = true + result.variationList = newVariationList([]list.Item{ + variationItem{ + variation: syncdomain.Variation{Key: "existing", Name: "Existing"}, + existing: true, + }, + }, 80, 20) + + next, cmd := result.handleEnter() + result = next.(model) + assert.Equal(t, selectVariations, result.step) + selected, _ := result.variationCounts() + assert.Zero(t, selected) + assert.NotNil(t, cmd) +} + +func TestModelRequiresSelection(t *testing.T) { + result := newModel(Options{ + API: testAPIClient(), + Store: synclocal.NewStore(t.TempDir()), + }) + result.step = selectVariations + result.variationsReady = true + result.variationList = newVariationList([]list.Item{ + variationItem{variation: syncdomain.Variation{Key: "available", Name: "Available"}}, + }, 80, 20) + + next, cmd := result.handleEnter() + result = next.(model) + assert.Equal(t, selectVariations, result.step) + assert.Equal(t, "Select at least one variation to continue.", result.notice) + assert.Nil(t, cmd) +} + +func TestModelIgnoresStaleSearchResultsAndErrors(t *testing.T) { + result := newModel(Options{ + API: testAPIClient(), + Store: synclocal.NewStore(t.TempDir()), + }) + result = updateModel(t, result, projectResults( + "", + syncapi.Project{Key: "original", Name: "Original"}, + )) + result.projects.SetFilterText("customer") + + result = updateModel(t, result, projectResults( + "old query", + syncapi.Project{Key: "stale", Name: "Stale"}, + )) + assert.Equal(t, "original", result.projects.Items()[0].(projectItem).project.Key) + result = updateModel(t, result, fetchFailedMsg{ + step: selectProject, + query: "old query", + err: assert.AnError, + }) + assert.NoError(t, result.err) + + result = updateModel(t, result, projectResults( + "customer", + syncapi.Project{Key: "current", Name: "Current"}, + )) + assert.Equal(t, "current", result.projects.Items()[0].(projectItem).project.Key) + require.Len(t, result.projects.VisibleItems(), 1) + assert.Equal(t, "customer", result.projects.query) +} + +func TestModelUsesConfigLabel(t *testing.T) { + result := newModel(Options{ + API: testAPIClient(), + Store: synclocal.NewStore(t.TempDir()), + }) + result.projectKey = "project" + result.step = selectConfig + result = updateModel(t, result, tea.WindowSizeMsg{Width: 80, Height: 24}) + result = updateModel(t, result, remoteFetchedMsg{ + step: selectConfig, + projectKey: "project", + items: []list.Item{configItem{config: syncapi.Config{ + Key: "config", Name: "Config", Mode: syncdomain.VariationModeCompletion, + }}}, + }) + + assert.Equal(t, "Select a Config", result.configs.Title) + assert.Contains(t, result.View(), "Select a Config") +} + +func TestFetchConfigsScopesSearchAndModes(t *testing.T) { + client := &fakeClient{} + result := newModel(Options{ + API: client, + Store: synclocal.NewStore(t.TempDir()), + }) + result.projectKey = "project" + + message, ok := result.fetchConfigs("support")().(remoteFetchedMsg) + require.True(t, ok) + assert.Equal(t, "project", message.projectKey) + assert.Equal(t, "support", message.query) + assert.Equal(t, "project", client.projectKey) + assert.Equal(t, "support", client.search) +} + +func TestModelFilterInputDoesNotQuit(t *testing.T) { + result := newModel(Options{ + API: testAPIClient(), + Store: synclocal.NewStore(t.TempDir()), + }) + result = updateModel(t, result, projectResults( + "", + syncapi.Project{Key: "project", Name: "Project"}, + )) + result = updateModel(t, result, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + require.True(t, result.isFiltering()) + + result = updateModel(t, result, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + assert.False(t, result.canceled) + assert.True(t, result.isFiltering()) +} + +func TestModelAPIErrorQuitsWithError(t *testing.T) { + result := newModel(Options{ + API: testAPIClient(), + Store: synclocal.NewStore(t.TempDir()), + }) + + updated, cmd := result.Update(errMsg{err: assert.AnError}) + result = updated.(model) + assert.ErrorIs(t, result.err, assert.AnError) + assert.NotNil(t, cmd) +} + +func TestRunRequiresTerminal(t *testing.T) { + err := Run(Options{ + API: testAPIClient(), + 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 TestWriteSummary(t *testing.T) { + var output bytes.Buffer + writeSummary(&output, true, false, []string{"a.prompt.md", "b.prompt.md"}) + assert.Equal(t, "Bootstrapped 2 variation files in .launchdarkly.\n", output.String()) + + output.Reset() + writeSummary(&output, false, true, nil) + assert.Contains(t, output.String(), "No variations added") +} + +func updateModel(t *testing.T, current model, message tea.Msg) model { + t.Helper() + updated, _ := current.Update(message) + result, ok := updated.(model) + require.True(t, ok) + return result +} + +func testAPIClient() Client { + return &fakeClient{} +} + +type fakeClient struct { + projectKey string + search string +} + +var _ Client = &fakeClient{} + +func (*fakeClient) Projects(string) ([]syncapi.Project, error) { + return nil, nil +} + +func (client *fakeClient) Configs( + projectKey, search string, +) ([]syncapi.Config, error) { + client.projectKey = projectKey + client.search = search + return nil, nil +} + +func (*fakeClient) Config(string, string) (syncapi.Config, error) { + return syncapi.Config{}, nil +} + +func projectResults(query string, projects ...syncapi.Project) remoteFetchedMsg { + items := make([]list.Item, len(projects)) + for index, project := range projects { + items[index] = projectItem{project: project} + } + return remoteFetchedMsg{step: selectProject, query: query, items: items} +} diff --git a/internal/sync/bootstrap/update.go b/internal/sync/bootstrap/update.go new file mode 100644 index 00000000..ab0fe2ba --- /dev/null +++ b/internal/sync/bootstrap/update.go @@ -0,0 +1,359 @@ +package bootstrap + +import ( + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" +) + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.resizeLists() + + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c": + m.canceled = true + return m, tea.Quit + case "q": + if !m.isFiltering() { + m.canceled = true + return m, tea.Quit + } + case "b": + if !m.isFiltering() { + return m.handleBack() + } + case " ": + if m.step == selectVariations && !m.isFiltering() { + return m, m.toggleVariation() + } + case "a": + if m.step == selectVariations && !m.isFiltering() { + return m, m.selectAllVariations() + } + case "enter": + if m.isFiltering() { + switch m.step { + case selectProject, selectConfig: + return m.submitServerSearch(msg) + } + } + return m.handleEnter() + } + + case remoteFetchedMsg: + picker := m.picker(msg.step) + if picker == nil || + msg.step != m.step || + (msg.step == selectConfig && msg.projectKey != m.projectKey) || + (picker.ready && msg.query != picker.FilterValue()) { + return m, nil + } + m.searching = false + picker.query = msg.query + if !picker.ready { + picker.ready = true + picker.Model = newServerList(msg.items, picker.title, m.width, m.listHeight()) + return m, nil + } + replaceServerItems(&picker.Model, msg.items, msg.query) + return m, nil + + case fetchFailedMsg: + if msg.step != m.step || + (msg.step == selectConfig && msg.projectKey != m.projectKey) { + return m, nil + } + if remote := m.remoteList(); remote != nil && msg.query != remote.FilterValue() { + return m, nil + } + m.err = msg.err + return m, tea.Quit + + case list.FilterMatchesMsg: + if m.step == selectProject || m.step == selectConfig { + return m, nil + } + + case configFetchedMsg: + if msg.projectKey != m.projectKey || + msg.config.Key != m.config.Key || + m.step != selectVariations { + return m, nil + } + m.config = msg.config + m.variationsReady = true + items := make([]list.Item, len(msg.config.Variations)) + for index, variation := range msg.config.Variations { + items[index] = variationItem{ + variation: variation, + existing: msg.existing[variation.Key], + } + } + m.variationList = newVariationList(items, m.width, m.listHeight()) + return m, nil + + case errMsg: + m.err = msg.err + return m, tea.Quit + + } + + if remote := m.remoteList(); remote != nil { + before := remote.FilterValue() + updated, cmd := remote.Update(msg) + *remote = updated + if before != "" && remote.FilterValue() == "" { + m.searching = true + return m, tea.Batch(cmd, m.fetchRemote("")) + } + return m, cmd + } + if m.step == selectVariations && m.variationsReady { + var cmd tea.Cmd + m.variationList, cmd = m.variationList.Update(msg) + return m, cmd + } + return m, nil +} + +func newServerList(items []list.Item, title string, width, height int) list.Model { + delegate := themedDefaultDelegate() + result := list.New(items, delegate, width, height) + result.Title = title + result.Filter = serverFilter + configureList(&result) + + return result +} + +func newVariationList(items []list.Item, width, height int) list.Model { + result := list.New(items, variationDelegate{}, width, height) + result.Title = "Select variations" + configureList(&result) + result.AdditionalShortHelpKeys = variationListHints() + + return result +} + +func serverFilter(_ string, targets []string) []list.Rank { + ranks := make([]list.Rank, len(targets)) + for index := range targets { + ranks[index] = list.Rank{Index: index} + } + return ranks +} + +func replaceServerItems(model *list.Model, items []list.Item, query string) { + _ = model.SetItems(items) + if query != "" { + model.SetFilterText(query) + } +} + +func configureList(model *list.Model) { + applyListTheme(model) + model.KeyMap.Quit = key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")) + model.AdditionalShortHelpKeys = func() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), + key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "back")), + } + } +} + +func (m *model) resizeLists() { + if m.projects.ready { + m.projects.SetSize(m.width, m.listHeight()) + } + if m.configs.ready { + m.configs.SetSize(m.width, m.listHeight()) + } + if m.variationsReady { + m.variationList.SetSize(m.width, m.listHeight()) + } +} + +func (m model) listHeight() int { + height := m.height - 2 + if height < 5 { + return 5 + } + return height +} + +func (m model) isFiltering() bool { + if remote := m.remoteList(); remote != nil { + return remote.FilterState() == list.Filtering + } + return m.step == selectVariations && + m.variationsReady && + m.variationList.FilterState() == list.Filtering +} + +func (m model) handleBack() (tea.Model, tea.Cmd) { + m.notice = "" + m.searching = false + switch m.step { + case selectConfig: + m.step = selectProject + m.configs = remotePicker{title: "Select a Config"} + case selectVariations: + m.step = selectConfig + m.variationsReady = false + m.variationList = list.Model{} + } + return m, nil +} + +func (m model) handleEnter() (tea.Model, tea.Cmd) { + m.notice = "" + switch m.step { + case selectProject: + if !m.projects.ready || + m.projects.FilterValue() != m.projects.query || + len(m.projects.Items()) == 0 { + return m, nil + } + selected, ok := m.projects.SelectedItem().(projectItem) + if !ok { + return m, nil + } + m.projectKey = selected.project.Key + m.configs = remotePicker{title: "Select a Config"} + m.step = selectConfig + m.searching = false + return m, m.fetchConfigs("") + + case selectConfig: + if !m.configs.ready || + m.configs.FilterValue() != m.configs.query || + len(m.configs.Items()) == 0 { + return m, nil + } + selected, ok := m.configs.SelectedItem().(configItem) + if !ok { + return m, nil + } + m.config = selected.config + m.variationsReady = false + m.variationList = list.Model{} + m.step = selectVariations + m.searching = false + return m, m.fetchConfig() + + case selectVariations: + if !m.variationsReady { + return m, nil + } + selected, selectable := m.variationCounts() + if selected == 0 { + if selectable == 0 { + return m, tea.Quit + } + m.notice = "Select at least one variation to continue." + return m, nil + } + return m, tea.Quit + } + return m, nil +} + +func (m *model) toggleVariation() tea.Cmd { + item, ok := m.variationList.SelectedItem().(variationItem) + if !ok || item.existing { + return nil + } + + item.selected = !item.selected + items := m.variationList.Items() + for index, raw := range items { + candidate, ok := raw.(variationItem) + if ok && candidate.variation.Key == item.variation.Key { + items[index] = item + break + } + } + m.notice = "" + return m.variationList.SetItems(items) +} + +func (m *model) selectAllVariations() tea.Cmd { + items := m.variationList.Items() + for index, raw := range items { + item, ok := raw.(variationItem) + if !ok || item.existing { + continue + } + item.selected = true + items[index] = item + } + m.notice = "" + return m.variationList.SetItems(items) +} + +func (m model) variationCounts() (selected, selectable int) { + for _, raw := range m.variationList.Items() { + item, ok := raw.(variationItem) + if !ok { + continue + } + if item.selected { + selected++ + } + if !item.existing { + selectable++ + } + } + return selected, selectable +} + +func (m model) submitServerSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + remote := m.remoteList() + if remote == nil { + return m, nil + } + updated, inputCmd := remote.Update(msg) + *remote = updated + finishSubmittedFilter(remote) + m.searching = true + return m, tea.Batch(inputCmd, m.fetchRemote(remote.FilterValue())) +} + +func finishSubmittedFilter(model *list.Model) { + if model.FilterValue() == "" { + model.SetFilterState(list.Unfiltered) + return + } + model.SetFilterState(list.FilterApplied) +} + +func (m *model) remoteList() *list.Model { + picker := m.picker(m.step) + if picker == nil || !picker.ready { + return nil + } + return &picker.Model +} + +func (m model) fetchRemote(search string) tea.Cmd { + if m.step == selectProject { + return m.fetchProjects(search) + } + return m.fetchConfigs(search) +} + +func (m *model) picker(target step) *remotePicker { + switch target { + case selectProject: + return &m.projects + case selectConfig: + return &m.configs + default: + return nil + } +} diff --git a/internal/sync/bootstrap/view.go b/internal/sync/bootstrap/view.go new file mode 100644 index 00000000..9b280f43 --- /dev/null +++ b/internal/sync/bootstrap/view.go @@ -0,0 +1,149 @@ +package bootstrap + +import ( + "fmt" + "io" + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + selectionColor = lipgloss.Color("2") + + selectedStyle = lipgloss.NewStyle().Foreground(selectionColor) + noticeStyle = lipgloss.NewStyle().Bold(true) + mutedStyle = lipgloss.NewStyle().Faint(true) +) + +type variationDelegate struct{} + +func (variationDelegate) Height() int { return 2 } +func (variationDelegate) Spacing() int { return 1 } +func (variationDelegate) Update(tea.Msg, *list.Model) tea.Cmd { + return nil +} + +func (variationDelegate) Render( + writer io.Writer, + model list.Model, + index int, + raw list.Item, +) { + item, ok := raw.(variationItem) + if !ok { + return + } + + cursor := " " + if index == model.Index() { + cursor = "> " + } + checkbox := "[ ]" + if item.selected { + checkbox = "[x]" + } + if item.existing { + checkbox = "[-]" + } + + title := fmt.Sprintf("%s%s %s", cursor, checkbox, item.Title()) + description := " " + item.Description() + switch { + case item.existing: + fmt.Fprintln(writer, mutedStyle.Render(title)) + fmt.Fprint(writer, mutedStyle.Render(description)) + case index == model.Index(): + fmt.Fprintln(writer, selectedStyle.Render(title)) + fmt.Fprint(writer, selectedStyle.Render(description)) + default: + fmt.Fprintln(writer, title) + fmt.Fprint(writer, description) + } +} + +func themedDefaultDelegate() list.DefaultDelegate { + delegate := list.NewDefaultDelegate() + delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle. + Foreground(selectionColor). + BorderForeground(selectionColor) + delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc. + Foreground(selectionColor). + BorderForeground(selectionColor) + delegate.Styles.FilterMatch = delegate.Styles.FilterMatch.Foreground(selectionColor) + return delegate +} + +func applyListTheme(model *list.Model) { + model.Styles.Title = lipgloss.NewStyle().Bold(true).Padding(0, 1) + model.Styles.FilterPrompt = lipgloss.NewStyle() + model.Styles.FilterCursor = lipgloss.NewStyle().Foreground(selectionColor) + model.Styles.StatusBarActiveFilter = lipgloss.NewStyle() + model.Styles.ActivePaginationDot = model.Styles.ActivePaginationDot.Foreground(selectionColor) + model.FilterInput.PromptStyle = model.Styles.FilterPrompt + model.FilterInput.Cursor.Style = model.Styles.FilterCursor + model.Paginator.ActiveDot = model.Styles.ActivePaginationDot.String() +} + +func variationListHints() func() []key.Binding { + return func() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithKeys("space"), key.WithHelp("space", "toggle")), + key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "select all")), + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "write")), + key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "back")), + } + } +} + +func (m model) View() string { + if m.err != nil || m.canceled { + return "" + } + + switch m.step { + case selectProject: + if !m.projects.ready { + return m.loadingView("Loading projects") + } + return m.listView(m.projects.Model) + + case selectConfig: + if !m.configs.ready { + return m.loadingView("Loading Configs") + } + return m.listView(m.configs.Model) + + case selectVariations: + if !m.variationsReady { + return m.loadingView("Loading variations") + } + var view strings.Builder + view.WriteString(m.variationList.View()) + selected, _ := m.variationCounts() + fmt.Fprintf(&view, "\nSelected: %d", selected) + if m.notice != "" { + view.WriteString("\n") + view.WriteString(noticeStyle.Render(m.notice)) + } + return view.String() + + default: + return "" + } +} + +func (m model) loadingView(label string) string { + return fmt.Sprintf("\n %s...\n", label) +} + +func (m model) listView(items list.Model) string { + view := items.View() + if m.searching { + view += "\nSearching..." + } + return view +} diff --git a/internal/sync/client.go b/internal/sync/client.go deleted file mode 100644 index 1af256fd..00000000 --- a/internal/sync/client.go +++ /dev/null @@ -1,140 +0,0 @@ -package sync - -import ( - "encoding/json" - "fmt" - "net/http" - "net/url" - - "github.com/launchdarkly/ldcli/internal/resources" -) - -type APIClient struct { - client resources.Client -} - -type ResourceStatus struct { - ProjectKey string `json:"projectKey"` - ResourceKind Kind `json:"resourceKind"` - LookupKey string `json:"lookupKey"` - Status string `json:"status"` - SyncDirection string `json:"syncDirection"` - ServerFingerprint Fingerprint `json:"serverFingerprint,omitempty"` - Error any `json:"error,omitempty"` -} - -type statusRequest struct { - RepoIdentifier string `json:"repoIdentifier"` - Resources []statusRequestResource `json:"resources"` -} - -type statusRequestResource struct { - Fingerprint Fingerprint `json:"fingerprint"` - ResourceKind Kind `json:"resourceKind"` - LookupKey string `json:"lookupKey"` - Upsert bool `json:"upsert"` -} - -type projectResources struct { - ProjectKey string - Resources []SyncedResource -} - -func NewAPIClient(client resources.Client) APIClient { - return APIClient{client: client} -} - -func (c APIClient) Status( - accessToken string, - baseURI string, - repoIdentifier string, - synced []SyncedResource, -) ([]ResourceStatus, error) { - statuses := make([]ResourceStatus, 0, len(synced)) - - for _, project := range groupResourcesByProject(synced) { - projectStatuses, err := c.projectStatus(accessToken, baseURI, repoIdentifier, project) - if err != nil { - return nil, err - } - statuses = append(statuses, projectStatuses...) - } - - return statuses, nil -} - -func (c APIClient) projectStatus( - accessToken string, - baseURI string, - repoIdentifier string, - project projectResources, -) ([]ResourceStatus, error) { - request := statusRequest{ - RepoIdentifier: repoIdentifier, - Resources: make([]statusRequestResource, 0, len(project.Resources)), - } - for _, resource := range project.Resources { - request.Resources = append(request.Resources, statusRequestResource{ - Fingerprint: resource.Fingerprint, - ResourceKind: resource.Kind, - LookupKey: resource.LookupKey, - Upsert: resource.Upsert, - }) - } - - body, err := json.MarshalIndent(request, "", " ") - if err != nil { - return nil, fmt.Errorf("marshal status request: %w", err) - } - - endpoint, err := url.JoinPath( - baseURI, - "api/v2/projects", - project.ProjectKey, - "ai-configs/sync/status", - ) - if err != nil { - return nil, fmt.Errorf("build status endpoint: %w", err) - } - - response, err := c.client.MakeRequest( - accessToken, - http.MethodPost, - endpoint, - "application/json", - nil, - body, - false, - ) - if err != nil { - return nil, err - } - - var statuses []ResourceStatus - if err := json.Unmarshal(response, &statuses); err != nil { - return nil, fmt.Errorf("decode status response: %w", err) - } - - for i := range statuses { - statuses[i].ProjectKey = project.ProjectKey - } - - return statuses, nil -} - -func groupResourcesByProject(synced []SyncedResource) []projectResources { - var projects []projectResources - byProject := make(map[string]int) - - for _, resource := range synced { - index, ok := byProject[resource.ProjectKey] - if !ok { - index = len(projects) - byProject[resource.ProjectKey] = index - projects = append(projects, projectResources{ProjectKey: resource.ProjectKey}) - } - projects[index].Resources = append(projects[index].Resources, resource) - } - - return projects -} diff --git a/internal/sync/client_test.go b/internal/sync/client_test.go deleted file mode 100644 index 8a44c32d..00000000 --- a/internal/sync/client_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package sync - -import ( - "encoding/json" - "errors" - "net/url" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/launchdarkly/ldcli/internal/resources" -) - -type apiRequest struct { - AccessToken string - Method string - Path string - Body []byte -} - -type apiClientStub struct { - Requests []apiRequest - Responses [][]byte - Err error -} - -var _ resources.Client = &apiClientStub{} - -func (c *apiClientStub) MakeRequest( - accessToken string, - method string, - path string, - _ string, - _ url.Values, - body []byte, - _ bool, -) ([]byte, error) { - c.Requests = append(c.Requests, apiRequest{ - AccessToken: accessToken, - Method: method, - Path: path, - Body: append([]byte(nil), body...), - }) - if c.Err != nil { - return nil, c.Err - } - - return c.Responses[len(c.Requests)-1], nil -} - -func (*apiClientStub) MakeUnauthenticatedRequest(string, string, []byte) ([]byte, error) { - return nil, nil -} - -func TestAPIClient_Status(t *testing.T) { - transport := &apiClientStub{ - Responses: [][]byte{ - []byte(`[{"resourceKind":"variation","lookupKey":"config/first","status":"local_changed","syncDirection":"code_canonical"}]`), - []byte(`[{"resourceKind":"tool","lookupKey":"search/2","status":"in_sync","syncDirection":"both"}]`), - }, - } - client := NewAPIClient(transport) - - statuses, err := client.Status( - "token", - "https://example.com", - "launchdarkly/ldcli", - []SyncedResource{ - { - ProjectKey: "alpha", - Kind: KindVariation, - LookupKey: "config/first", - Fingerprint: "sha256.first", - Upsert: true, - }, - { - ProjectKey: "zeta", - Kind: KindTool, - LookupKey: "search/2", - Fingerprint: "sha256.second", - }, - }, - ) - require.NoError(t, err) - require.Len(t, transport.Requests, 2) - require.Len(t, statuses, 2) - - assert.Equal(t, "POST", transport.Requests[0].Method) - assert.Equal(t, "token", transport.Requests[0].AccessToken) - assert.Equal(t, "https://example.com/api/v2/projects/alpha/ai-configs/sync/status", transport.Requests[0].Path) - assert.Equal(t, "alpha", statuses[0].ProjectKey) - assert.Equal(t, "zeta", statuses[1].ProjectKey) - - var request statusRequest - require.NoError(t, json.Unmarshal(transport.Requests[0].Body, &request)) - assert.Equal(t, "launchdarkly/ldcli", request.RepoIdentifier) - require.Len(t, request.Resources, 1) - assert.Equal(t, KindVariation, request.Resources[0].ResourceKind) - assert.Equal(t, "config/first", request.Resources[0].LookupKey) - assert.Equal(t, Fingerprint("sha256.first"), request.Resources[0].Fingerprint) - assert.True(t, request.Resources[0].Upsert) -} - -func TestAPIClient_StatusTransportError(t *testing.T) { - client := NewAPIClient(&apiClientStub{Err: errors.New("unavailable")}) - - _, err := client.Status( - "token", - "https://example.com", - "launchdarkly/ldcli", - []SyncedResource{{ProjectKey: "proj"}}, - ) - require.ErrorContains(t, err, "unavailable") -} - -func TestAPIClient_StatusInvalidResponse(t *testing.T) { - client := NewAPIClient(&apiClientStub{Responses: [][]byte{[]byte(`not json`)}}) - - _, err := client.Status( - "token", - "https://example.com", - "launchdarkly/ldcli", - []SyncedResource{{ProjectKey: "proj"}}, - ) - require.ErrorContains(t, err, "decode status response") -} diff --git a/internal/sync/compile.go b/internal/sync/local/compile.go similarity index 61% rename from internal/sync/compile.go rename to internal/sync/local/compile.go index 5ed6bea8..05e39119 100644 --- a/internal/sync/compile.go +++ b/internal/sync/local/compile.go @@ -1,4 +1,4 @@ -package sync +package local import ( "cmp" @@ -7,6 +7,8 @@ import ( "path" "slices" "strings" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" ) var ErrNoDirectory = errors.New(".launchdarkly directory not found") @@ -24,12 +26,12 @@ func (e ParseError) Unwrap() error { return e.Err } -func Compile(fsys fs.FS) ([]SyncedResource, error) { - return compile(fsys, DefaultParsers()) +func Compile(fsys fs.FS) ([]syncdomain.SyncedResource, error) { + return compile(fsys, defaultParsers()) } -func compile(fsys fs.FS, parsers []Parser) ([]SyncedResource, error) { - entries, err := fs.ReadDir(fsys, RootDir) +func compile(fsys fs.FS, parsers []parser) ([]syncdomain.SyncedResource, error) { + entries, err := fs.ReadDir(fsys, syncdomain.RootDir) if errors.Is(err, fs.ErrNotExist) { return nil, ErrNoDirectory } @@ -37,7 +39,7 @@ func compile(fsys fs.FS, parsers []Parser) ([]SyncedResource, error) { return nil, err } - var resources []SyncedResource + var resources []syncdomain.SyncedResource for _, entry := range entries { if !entry.IsDir() { @@ -57,8 +59,12 @@ func compile(fsys fs.FS, parsers []Parser) ([]SyncedResource, error) { return resources, nil } -func compileProject(fsys fs.FS, projectKey string, parsers []Parser) ([]SyncedResource, error) { - var resources []SyncedResource +func compileProject( + fsys fs.FS, + projectKey string, + parsers []parser, +) ([]syncdomain.SyncedResource, error) { + var resources []syncdomain.SyncedResource for _, parser := range parsers { parsed, err := compileKind(fsys, projectKey, parser) @@ -72,21 +78,25 @@ func compileProject(fsys fs.FS, projectKey string, parsers []Parser) ([]SyncedRe return resources, nil } -func compileKind(fsys fs.FS, projectKey string, parser Parser) ([]SyncedResource, error) { - dir := path.Join(RootDir, projectKey, parser.Dir()) +func compileKind( + fsys fs.FS, + projectKey string, + parser parser, +) ([]syncdomain.SyncedResource, error) { + dir := path.Join(syncdomain.RootDir, projectKey, parser.dir()) - var resources []SyncedResource + var resources []syncdomain.SyncedResource - err := fs.WalkDir(fsys, dir, func(name string, d fs.DirEntry, err error) error { + err := fs.WalkDir(fsys, dir, func(name string, entry fs.DirEntry, err error) error { if err != nil { return err } - if d.IsDir() { + if entry.IsDir() { return nil } rel := strings.TrimPrefix(name, dir+"/") - if rel == name || !parser.Accept(rel) { + if rel == name || !parser.accept(rel) { return nil } @@ -95,7 +105,7 @@ func compileKind(fsys fs.FS, projectKey string, parser Parser) ([]SyncedResource return err } - resource, err := parser.Parse(File{ + resource, err := parser.parse(file{ ProjectKey: projectKey, RelPath: rel, Data: data, @@ -118,7 +128,7 @@ func compileKind(fsys fs.FS, projectKey string, parser Parser) ([]SyncedResource return resources, nil } -func compareResources(a, b SyncedResource) int { +func compareResources(a, b syncdomain.SyncedResource) int { return cmp.Or( cmp.Compare(a.ProjectKey, b.ProjectKey), cmp.Compare(a.Kind, b.Kind), diff --git a/internal/sync/compile_test.go b/internal/sync/local/compile_test.go similarity index 76% rename from internal/sync/compile_test.go rename to internal/sync/local/compile_test.go index dee58011..3f689e20 100644 --- a/internal/sync/compile_test.go +++ b/internal/sync/local/compile_test.go @@ -1,4 +1,4 @@ -package sync +package local import ( "encoding/json" @@ -9,11 +9,14 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" ) const specPrompt = `--- formatVersion: 1 upsert: true +mode: completion key: my-first-variation name: This is the prompt name @@ -84,7 +87,7 @@ const specTool = `{ func specRepo() fstest.MapFS { return fstest.MapFS{ - ".launchdarkly/proj-key/configs/my-config-key/my-first-variation.prompt": &fstest.MapFile{ + ".launchdarkly/proj-key/configs/my-config-key/my-first-variation.prompt.md": &fstest.MapFile{ Data: []byte(specPrompt), }, ".launchdarkly/proj-key/tools/test-tool.v13.json": &fstest.MapFile{ @@ -98,17 +101,23 @@ func TestCompile(t *testing.T) { require.NoError(t, err) require.Len(t, resources, 2) - variation := mustResource(t, resources, KindVariation, "my-config-key/my-first-variation") + variation := mustResource( + t, + resources, + syncdomain.KindVariation, + "my-config-key/my-first-variation", + ) assert.Equal(t, "proj-key", variation.ProjectKey) assert.True(t, variation.Upsert) - assert.Equal(t, Hash(variation.Payload), variation.Fingerprint) + assert.Equal(t, syncdomain.Hash(variation.Payload), variation.Fingerprint) - var payload variationPayload + var payload syncdomain.Variation require.NoError(t, json.Unmarshal(variation.Payload, &payload)) + assert.Equal(t, syncdomain.VariationModeCompletion, payload.Mode) assert.Equal(t, "my-first-variation", payload.Key) assert.Equal(t, "This is the prompt name", payload.Name) assert.Equal(t, "anthropic-default", payload.ModelConfigKey) - assert.Equal(t, []toolRef{{Key: "test-tool", Version: 13}}, payload.Tools) + assert.Equal(t, []syncdomain.ToolRef{{Key: "test-tool", Version: 13}}, payload.Tools) require.Len(t, payload.Messages, 3) assert.Equal(t, "system", payload.Messages[0].Role) assert.Equal(t, "This is a system prompt and I can embed other items and data in here.\nAsk a nested question.\nStay in character.\nA nested assistant reply.", payload.Messages[0].Content) @@ -117,10 +126,10 @@ func TestCompile(t *testing.T) { assert.Equal(t, "assistant", payload.Messages[2].Role) assert.Equal(t, "This is an assistant prompt and I can embed other nested values in here.\nKeep this in the assistant body.\nKeep this in the assistant body too.\nA nested assistant example.", payload.Messages[2].Content) - tool := mustResource(t, resources, KindTool, "test-tool/13") + tool := mustResource(t, resources, syncdomain.KindTool, "test-tool/13") assert.Equal(t, "proj-key", tool.ProjectKey) assert.False(t, tool.Upsert) - assert.Equal(t, Hash(tool.Payload), tool.Fingerprint) + assert.Equal(t, syncdomain.Hash(tool.Payload), tool.Fingerprint) var toolPayload toolFile require.NoError(t, json.Unmarshal(tool.Payload, &toolPayload)) @@ -172,10 +181,10 @@ func TestCompile_SortsByProjectKindAndKey(t *testing.T) { ".launchdarkly/zeta/tools/zeta-tool.v1.json": &fstest.MapFile{ Data: []byte(`{"key":"zeta-tool","version":1,"schema":{}}`), }, - ".launchdarkly/alpha/configs/cfg/b.prompt": &fstest.MapFile{ + ".launchdarkly/alpha/configs/cfg/b.prompt.md": &fstest.MapFile{ Data: []byte(minimalPrompt("b", "B")), }, - ".launchdarkly/alpha/configs/cfg/a.prompt": &fstest.MapFile{ + ".launchdarkly/alpha/configs/cfg/a.prompt.md": &fstest.MapFile{ Data: []byte(minimalPrompt("a", "A")), }, } @@ -184,13 +193,21 @@ func TestCompile_SortsByProjectKindAndKey(t *testing.T) { require.NoError(t, err) require.Len(t, resources, 3) assert.Equal(t, []string{"alpha", "alpha", "zeta"}, projectKeys(resources)) - assert.Equal(t, []Kind{KindVariation, KindVariation, KindTool}, kinds(resources)) + assert.Equal( + t, + []syncdomain.Kind{ + syncdomain.KindVariation, + syncdomain.KindVariation, + syncdomain.KindTool, + }, + kinds(resources), + ) assert.Equal(t, []string{"cfg/a", "cfg/b", "zeta-tool/1"}, lookupKeys(resources)) } func TestCompile_ParseErrorIncludesPath(t *testing.T) { fsys := fstest.MapFS{ - ".launchdarkly/proj-key/configs/my-config-key/wrong-name.prompt": &fstest.MapFile{ + ".launchdarkly/proj-key/configs/my-config-key/wrong-name.prompt.md": &fstest.MapFile{ Data: []byte(minimalPrompt("my-first-variation", "Name")), }, } @@ -200,13 +217,13 @@ func TestCompile_ParseErrorIncludesPath(t *testing.T) { var parseErr ParseError require.ErrorAs(t, err, &parseErr) - assert.Equal(t, ".launchdarkly/proj-key/configs/my-config-key/wrong-name.prompt", parseErr.Path) + assert.Equal(t, ".launchdarkly/proj-key/configs/my-config-key/wrong-name.prompt.md", parseErr.Path) assert.ErrorContains(t, parseErr.Err, `key "my-first-variation" does not match filename "wrong-name"`) } func TestCompile_MissingFrontMatter(t *testing.T) { fsys := fstest.MapFS{ - ".launchdarkly/proj-key/configs/cfg/var.prompt": &fstest.MapFile{ + ".launchdarkly/proj-key/configs/cfg/var.prompt.md": &fstest.MapFile{ Data: []byte("just a prompt"), }, } @@ -216,12 +233,16 @@ func TestCompile_MissingFrontMatter(t *testing.T) { } func TestHashPrefix(t *testing.T) { - fp := Hash([]byte(`{"key":"x"}`)) - assert.Regexp(t, `^sha256\.[0-9a-f]{64}$`, string(fp)) + fingerprint := syncdomain.Hash([]byte(`{"key":"x"}`)) + assert.Regexp(t, `^sha256\.[0-9a-f]{64}$`, string(fingerprint)) } func TestHash_DiffersForDifferentPayloads(t *testing.T) { - assert.NotEqual(t, Hash([]byte(`{"a":1}`)), Hash([]byte(`{"a":2}`))) + assert.NotEqual( + t, + syncdomain.Hash([]byte(`{"a":1}`)), + syncdomain.Hash([]byte(`{"a":2}`)), + ) } func TestErrNoDirectory_Is(t *testing.T) { @@ -234,37 +255,42 @@ func TestErrNoDirectory_Is(t *testing.T) { } func minimalPrompt(key, name string) string { - return "---\nformatVersion: 1\nkey: " + key + "\nname: " + name + "\n---\n" + return "---\nformatVersion: 1\nmode: completion\nkey: " + key + "\nname: " + name + "\n---\n" } -func projectKeys(resources []SyncedResource) []string { +func projectKeys(resources []syncdomain.SyncedResource) []string { keys := make([]string, len(resources)) - for i, r := range resources { - keys[i] = r.ProjectKey + for index, resource := range resources { + keys[index] = resource.ProjectKey } return keys } -func kinds(resources []SyncedResource) []Kind { - out := make([]Kind, len(resources)) - for i, r := range resources { - out[i] = r.Kind +func kinds(resources []syncdomain.SyncedResource) []syncdomain.Kind { + kinds := make([]syncdomain.Kind, len(resources)) + for index, resource := range resources { + kinds[index] = resource.Kind } - return out + return kinds } -func lookupKeys(resources []SyncedResource) []string { +func lookupKeys(resources []syncdomain.SyncedResource) []string { keys := make([]string, len(resources)) - for i, r := range resources { - keys[i] = r.LookupKey + for index, resource := range resources { + keys[index] = resource.LookupKey } return keys } -func mustResource(t *testing.T, resources []SyncedResource, kind Kind, lookupKey string) SyncedResource { +func mustResource( + t *testing.T, + resources []syncdomain.SyncedResource, + kind syncdomain.Kind, + lookupKey string, +) syncdomain.SyncedResource { t.Helper() for _, resource := range resources { @@ -275,5 +301,5 @@ func mustResource(t *testing.T, resources []SyncedResource, kind Kind, lookupKey t.Fatalf("resource %s %s not found", kind, lookupKey) - return SyncedResource{} + return syncdomain.SyncedResource{} } diff --git a/internal/sync/local/parser.go b/internal/sync/local/parser.go new file mode 100644 index 00000000..5e4ea15e --- /dev/null +++ b/internal/sync/local/parser.go @@ -0,0 +1,40 @@ +package local + +import ( + "bytes" + "encoding/json" + "fmt" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +type file struct { + ProjectKey string + RelPath string + Data []byte +} + +type parser interface { + dir() string + accept(relPath string) bool + parse(file file) (syncdomain.SyncedResource, error) +} + +func defaultParsers() []parser { + return []parser{ + variationParser{}, + toolParser{}, + } +} + +func marshalPayload(value any) (json.RawMessage, error) { + var buf bytes.Buffer + encoder := json.NewEncoder(&buf) + encoder.SetEscapeHTML(false) + + if err := encoder.Encode(value); err != nil { + return nil, fmt.Errorf("marshal payload: %w", err) + } + + return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil +} diff --git a/internal/sync/local/store.go b/internal/sync/local/store.go new file mode 100644 index 00000000..6208e0a1 --- /dev/null +++ b/internal/sync/local/store.go @@ -0,0 +1,272 @@ +package local + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + "gopkg.in/yaml.v3" +) + +var ErrVariationExists = errors.New("variation already exists locally") + +type VariationFile struct { + ProjectKey string + ConfigKey string + Upsert bool + Variation syncdomain.Variation +} + +type Store struct { + root string +} + +func NewStore(repoRoot string) Store { + return Store{root: filepath.Join(repoRoot, syncdomain.RootDir)} +} + +func (s Store) Exists() (bool, error) { + info, err := os.Stat(s.root) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("inspect %s: %w", s.root, err) + } + if !info.IsDir() { + return false, fmt.Errorf("%s exists but is not a directory", s.root) + } + + return true, nil +} + +func (s Store) VariationExists(projectKey, configKey, variationKey string) (bool, error) { + path, err := s.variationPath(projectKey, configKey, variationKey) + if err != nil { + return false, err + } + + _, err = os.Stat(path) + switch { + case err == nil: + return true, nil + case errors.Is(err, os.ErrNotExist): + return false, nil + default: + return false, fmt.Errorf("inspect variation %s: %w", variationKey, err) + } +} + +func (s Store) Bootstrap(resources []VariationFile) ([]string, error) { + if _, err := os.Stat(s.root); err == nil { + return nil, fmt.Errorf("%s already exists", s.root) + } else if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("inspect %s: %w", s.root, err) + } + + stage, err := os.MkdirTemp(filepath.Dir(s.root), ".launchdarkly.tmp-") + if err != nil { + return nil, fmt.Errorf("create bootstrap staging directory: %w", err) + } + defer os.RemoveAll(stage) + + stagedStore := Store{root: stage} + paths, err := stagedStore.createVariations(resources) + if err != nil { + return nil, err + } + if err := os.Rename(stage, s.root); err != nil { + return nil, fmt.Errorf("finish bootstrap: %w", err) + } + + return paths, nil +} + +func (s Store) Add(resources []VariationFile) ([]string, error) { + if err := os.MkdirAll(s.root, 0o755); err != nil { + return nil, fmt.Errorf("create %s: %w", s.root, err) + } + + return s.createVariations(resources) +} + +func (s Store) createVariations(resources []VariationFile) ([]string, error) { + type pendingFile struct { + absolute string + relative string + data []byte + } + + pending := make([]pendingFile, 0, len(resources)) + seen := make(map[string]struct{}, len(resources)) + for _, resource := range resources { + path, err := s.variationPath( + resource.ProjectKey, + resource.ConfigKey, + resource.Variation.Key, + ) + if err != nil { + return nil, err + } + if _, ok := seen[path]; ok { + return nil, fmt.Errorf("variation %q was selected more than once", resource.Variation.Key) + } + seen[path] = struct{}{} + + data, err := marshalVariationFile(resource) + if err != nil { + return nil, err + } + pending = append(pending, pendingFile{ + absolute: path, + relative: filepath.ToSlash(strings.TrimPrefix(path, s.root+string(filepath.Separator))), + data: data, + }) + } + + var created []string + for _, file := range pending { + if err := createFile(file.absolute, file.data); err != nil { + for index := len(created) - 1; index >= 0; index-- { + _ = os.Remove(filepath.Join(s.root, filepath.FromSlash(created[index]))) + } + return nil, err + } + created = append(created, file.relative) + } + + return created, nil +} + +func (s Store) variationPath(projectKey, configKey, variationKey string) (string, error) { + segments := []struct { + name string + value string + }{ + {name: "project key", value: projectKey}, + {name: "config key", value: configKey}, + {name: "variation key", value: variationKey}, + } + for _, segment := range segments { + if err := validatePathSegment(segment.value); err != nil { + return "", fmt.Errorf("invalid %s %q: %w", segment.name, segment.value, err) + } + } + + return filepath.Join( + s.root, + projectKey, + configsDir, + configKey, + variationKey+variationFileSuffix, + ), nil +} + +func validatePathSegment(value string) error { + if value == "" { + return errors.New("must not be empty") + } + if value == "." || value == ".." || strings.ContainsAny(value, `/\`) { + return errors.New("must be a single path segment") + } + if strings.IndexByte(value, 0) >= 0 { + return errors.New("must not contain a null byte") + } + + return nil +} + +func marshalVariationFile(resource VariationFile) ([]byte, error) { + if !resource.Variation.Mode.Valid() { + return nil, fmt.Errorf( + "variation %q has unsupported mode %q", + resource.Variation.Key, + resource.Variation.Mode, + ) + } + switch resource.Variation.Mode { + case syncdomain.VariationModeAgent: + if len(resource.Variation.Messages) != 0 { + return nil, fmt.Errorf("agent variation %q cannot contain messages", resource.Variation.Key) + } + case syncdomain.VariationModeCompletion: + if resource.Variation.Instructions != "" { + return nil, fmt.Errorf("completion variation %q cannot contain instructions", resource.Variation.Key) + } + } + + front, err := yaml.Marshal(variationFrontMatter{ + FormatVersion: 1, + Upsert: resource.Upsert, + Variation: resource.Variation, + }) + if err != nil { + return nil, fmt.Errorf("marshal variation %q: %w", resource.Variation.Key, err) + } + + var file bytes.Buffer + file.WriteString("---\n") + file.Write(front) + file.WriteString("---\n") + if resource.Variation.Mode == syncdomain.VariationModeAgent { + if instructions := strings.TrimSpace(resource.Variation.Instructions); instructions != "" { + fmt.Fprintf(&file, "\n%s\n", instructions) + } + return file.Bytes(), nil + } + for _, message := range resource.Variation.Messages { + if !validMessageRole(message.Role) { + return nil, fmt.Errorf( + "variation %q has unsupported message role %q", + resource.Variation.Key, + message.Role, + ) + } + fmt.Fprintf( + &file, + "\n<%s>\n%s\n\n", + message.Role, + strings.TrimSpace(message.Content), + message.Role, + ) + } + + return file.Bytes(), nil +} + +func createFile(path string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create variation directory: %w", err) + } + + temp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".tmp-") + if err != nil { + return fmt.Errorf("stage variation %s: %w", filepath.Base(path), err) + } + tempPath := temp.Name() + defer os.Remove(tempPath) + + if err := temp.Chmod(0o644); err != nil { + _ = temp.Close() + return fmt.Errorf("set variation permissions: %w", err) + } + if _, err := temp.Write(data); err != nil { + _ = temp.Close() + return fmt.Errorf("write variation %s: %w", filepath.Base(path), err) + } + if err := temp.Close(); err != nil { + return fmt.Errorf("close variation %s: %w", filepath.Base(path), err) + } + if err := os.Link(tempPath, path); err != nil { + if errors.Is(err, os.ErrExist) { + return fmt.Errorf("%w: %s", ErrVariationExists, path) + } + return fmt.Errorf("create variation %s: %w", filepath.Base(path), err) + } + + return nil +} diff --git a/internal/sync/local/store_test.go b/internal/sync/local/store_test.go new file mode 100644 index 00000000..c8960f41 --- /dev/null +++ b/internal/sync/local/store_test.go @@ -0,0 +1,186 @@ +package local + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +func TestStore_BootstrapRoundTripsSupportedModes(t *testing.T) { + root := t.TempDir() + resources := []VariationFile{ + { + ProjectKey: "project", + ConfigKey: "completion-config", + Upsert: true, + Variation: syncdomain.Variation{ + Mode: syncdomain.VariationModeCompletion, + Key: "friendly", + Name: "Friendly", + ModelConfigKey: "claude", + ModelConfigVersion: 3, + Model: map[string]any{"modelName": "claude"}, + OutputFormat: map[string]any{"type": "json_schema"}, + Tools: []syncdomain.ToolRef{{ + Key: "lookup", + Version: 2, + CustomParameters: map[string]any{ + "timeout": 5, + }, + }}, + Messages: []syncdomain.Message{ + {Role: "system", Content: "Be helpful."}, + {Role: "user", Content: "Answer the question."}, + }, + }, + }, + { + ProjectKey: "project", + ConfigKey: "agent-config", + Upsert: true, + Variation: syncdomain.Variation{ + Mode: syncdomain.VariationModeAgent, + Key: "researcher", + Name: "Researcher", + Description: "Researches a topic.", + Instructions: "Check the available sources.", + Skills: []syncdomain.SkillRef{{Key: "research", Version: 4}}, + }, + }, + } + + paths, err := NewStore(root).Bootstrap(resources) + require.NoError(t, err) + assert.ElementsMatch(t, []string{ + "project/configs/completion-config/friendly.prompt.md", + "project/configs/agent-config/researcher.prompt.md", + }, paths) + + agentFile, err := os.ReadFile(filepath.Join( + root, + syncdomain.RootDir, + "project", + configsDir, + "agent-config", + "researcher.prompt.md", + )) + require.NoError(t, err) + assert.NotContains(t, string(agentFile), "instructions:") + assert.Contains(t, string(agentFile), "\nCheck the available sources.\n") + + compiled, err := Compile(os.DirFS(root)) + require.NoError(t, err) + require.Len(t, compiled, len(resources)) + for _, local := range resources { + resource := mustResource( + t, + compiled, + syncdomain.KindVariation, + local.ConfigKey+"/"+local.Variation.Key, + ) + expected, err := marshalPayload(local.Variation) + require.NoError(t, err) + assert.JSONEq(t, string(expected), string(resource.Payload)) + assert.True(t, resource.Upsert) + } +} + +func TestStore_AddNeverOverwritesExistingVariation(t *testing.T) { + root := t.TempDir() + store := NewStore(root) + existing := VariationFile{ + ProjectKey: "project", + ConfigKey: "config", + Upsert: true, + Variation: syncdomain.Variation{ + Mode: syncdomain.VariationModeCompletion, + Key: "existing", + Name: "Existing", + }, + } + _, err := store.Add([]VariationFile{existing}) + require.NoError(t, err) + + path := filepath.Join( + root, + syncdomain.RootDir, + "project", + configsDir, + "config", + "existing.prompt.md", + ) + before, err := os.ReadFile(path) + require.NoError(t, err) + + existing.Variation.Name = "Changed on server" + _, err = store.Add([]VariationFile{existing}) + require.ErrorIs(t, err, ErrVariationExists) + + after, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, before, after) +} + +func TestStore_AddRollsBackNewFilesWhenOneExists(t *testing.T) { + root := t.TempDir() + store := NewStore(root) + existing := localVariation("existing") + _, err := store.Add([]VariationFile{existing}) + require.NoError(t, err) + + _, err = store.Add([]VariationFile{localVariation("new"), existing}) + require.ErrorIs(t, err, ErrVariationExists) + + exists, err := store.VariationExists("project", "config", "new") + require.NoError(t, err) + assert.False(t, exists) +} + +func TestStore_BootstrapFailureLeavesNoDirectory(t *testing.T) { + root := t.TempDir() + resource := localVariation("../unsafe") + + _, err := NewStore(root).Bootstrap([]VariationFile{resource}) + require.ErrorContains(t, err, "must be a single path segment") + _, statErr := os.Stat(filepath.Join(root, syncdomain.RootDir)) + require.ErrorIs(t, statErr, os.ErrNotExist) +} + +func TestStore_RejectsUnsupportedMessageRole(t *testing.T) { + root := t.TempDir() + resource := localVariation("unsupported-role") + resource.Variation.Messages = []syncdomain.Message{{Role: "tool", Content: "result"}} + + _, err := NewStore(root).Bootstrap([]VariationFile{resource}) + require.ErrorContains(t, err, `unsupported message role "tool"`) + _, statErr := os.Stat(filepath.Join(root, syncdomain.RootDir)) + require.ErrorIs(t, statErr, os.ErrNotExist) +} + +func TestStore_BootstrapRefusesExistingDirectory(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(root, syncdomain.RootDir), 0o755)) + + _, err := NewStore(root).Bootstrap([]VariationFile{localVariation("new")}) + require.Error(t, err) + assert.False(t, errors.Is(err, os.ErrNotExist)) +} + +func localVariation(key string) VariationFile { + return VariationFile{ + ProjectKey: "project", + ConfigKey: "config", + Upsert: true, + Variation: syncdomain.Variation{ + Mode: syncdomain.VariationModeCompletion, + Key: key, + Name: key, + }, + } +} diff --git a/internal/sync/tool.go b/internal/sync/local/tool.go similarity index 54% rename from internal/sync/tool.go rename to internal/sync/local/tool.go index d0001384..1a2c37fa 100644 --- a/internal/sync/tool.go +++ b/internal/sync/local/tool.go @@ -1,4 +1,4 @@ -package sync +package local import ( "bytes" @@ -8,6 +8,8 @@ import ( "path" "regexp" "strconv" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" ) const toolsDir = "tools" @@ -16,11 +18,11 @@ var toolFileName = regexp.MustCompile(`^([^/]+)\.v(\d+)\.json$`) type toolParser struct{} -func (toolParser) Dir() string { +func (toolParser) dir() string { return toolsDir } -func (toolParser) Accept(relPath string) bool { +func (toolParser) accept(relPath string) bool { return toolFileName.MatchString(relPath) } @@ -30,31 +32,35 @@ type toolFile struct { Schema any `json:"schema"` } -func (toolParser) Parse(file File) (SyncedResource, error) { +func (toolParser) parse(file file) (syncdomain.SyncedResource, error) { var parsed toolFile - dec := json.NewDecoder(bytes.NewReader(file.Data)) - dec.DisallowUnknownFields() + decoder := json.NewDecoder(bytes.NewReader(file.Data)) + decoder.DisallowUnknownFields() - if err := dec.Decode(&parsed); err != nil { - return SyncedResource{}, fmt.Errorf("invalid tool file: %w", err) + if err := decoder.Decode(&parsed); err != nil { + return syncdomain.SyncedResource{}, fmt.Errorf("invalid tool file: %w", err) } stemKey, stemVersion, err := parseToolFileName(path.Base(file.RelPath)) if err != nil { - return SyncedResource{}, err + return syncdomain.SyncedResource{}, err } switch { case parsed.Key == "": - return SyncedResource{}, errors.New("key is required") + return syncdomain.SyncedResource{}, errors.New("key is required") case parsed.Version == 0: - return SyncedResource{}, errors.New("version is required") + return syncdomain.SyncedResource{}, errors.New("version is required") case parsed.Key != stemKey: - return SyncedResource{}, fmt.Errorf("key %q does not match filename %q", parsed.Key, stemKey) + return syncdomain.SyncedResource{}, fmt.Errorf("key %q does not match filename %q", parsed.Key, stemKey) case parsed.Version != stemVersion: - return SyncedResource{}, fmt.Errorf("version %d does not match filename v%d", parsed.Version, stemVersion) + return syncdomain.SyncedResource{}, fmt.Errorf( + "version %d does not match filename v%d", + parsed.Version, + stemVersion, + ) case parsed.Schema == nil: - return SyncedResource{}, errors.New("schema is required") + return syncdomain.SyncedResource{}, errors.New("schema is required") } payload, err := marshalPayload(toolFile{ @@ -63,15 +69,15 @@ func (toolParser) Parse(file File) (SyncedResource, error) { Schema: parsed.Schema, }) if err != nil { - return SyncedResource{}, err + return syncdomain.SyncedResource{}, err } - return SyncedResource{ - Kind: KindTool, + return syncdomain.SyncedResource{ + Kind: syncdomain.KindTool, ProjectKey: file.ProjectKey, LookupKey: fmt.Sprintf("%s/%d", parsed.Key, parsed.Version), Payload: payload, - Fingerprint: Hash(payload), + Fingerprint: syncdomain.Hash(payload), }, nil } diff --git a/internal/sync/tool_test.go b/internal/sync/local/tool_test.go similarity index 76% rename from internal/sync/tool_test.go rename to internal/sync/local/tool_test.go index a3bd3877..313d5a2f 100644 --- a/internal/sync/tool_test.go +++ b/internal/sync/local/tool_test.go @@ -1,4 +1,4 @@ -package sync +package local import ( "testing" @@ -8,16 +8,16 @@ import ( ) func TestToolParser_Accept(t *testing.T) { - p := toolParser{} + parser := toolParser{} - assert.True(t, p.Accept("test-tool.v13.json")) - assert.False(t, p.Accept("nested/test-tool.v13.json")) - assert.False(t, p.Accept("test-tool.json")) - assert.False(t, p.Accept("test-tool.v13.yaml")) + assert.True(t, parser.accept("test-tool.v13.json")) + assert.False(t, parser.accept("nested/test-tool.v13.json")) + assert.False(t, parser.accept("test-tool.json")) + assert.False(t, parser.accept("test-tool.v13.yaml")) } func TestToolParser_CanonicalizesSchema(t *testing.T) { - file := File{ + input := file{ ProjectKey: "proj", RelPath: "search.v2.json", Data: []byte(`{ @@ -27,10 +27,10 @@ func TestToolParser_CanonicalizesSchema(t *testing.T) { }`), } - first, err := toolParser{}.Parse(file) + first, err := toolParser{}.parse(input) require.NoError(t, err) - second, err := toolParser{}.Parse(File{ + second, err := toolParser{}.parse(file{ ProjectKey: "proj", RelPath: "search.v2.json", Data: []byte(`{"schema":{"a":2,"b":1},"version":2,"key":"search"}`), @@ -42,7 +42,7 @@ func TestToolParser_CanonicalizesSchema(t *testing.T) { } func TestToolParser_KeyMismatch(t *testing.T) { - _, err := toolParser{}.Parse(File{ + _, err := toolParser{}.parse(file{ ProjectKey: "proj", RelPath: "search.v2.json", Data: []byte(`{"key":"other","version":2,"schema":{}}`), @@ -51,7 +51,7 @@ func TestToolParser_KeyMismatch(t *testing.T) { } func TestToolParser_VersionMismatch(t *testing.T) { - _, err := toolParser{}.Parse(File{ + _, err := toolParser{}.parse(file{ ProjectKey: "proj", RelPath: "search.v2.json", Data: []byte(`{"key":"search","version":3,"schema":{}}`), @@ -60,7 +60,7 @@ func TestToolParser_VersionMismatch(t *testing.T) { } func TestToolParser_RejectsUnknownFields(t *testing.T) { - _, err := toolParser{}.Parse(File{ + _, err := toolParser{}.parse(file{ ProjectKey: "proj", RelPath: "search.v2.json", Data: []byte(`{"key":"search","version":2,"schema":{},"extra":true}`), @@ -69,7 +69,7 @@ func TestToolParser_RejectsUnknownFields(t *testing.T) { } func TestToolParser_RequiresSchema(t *testing.T) { - _, err := toolParser{}.Parse(File{ + _, err := toolParser{}.parse(file{ ProjectKey: "proj", RelPath: "search.v2.json", Data: []byte(`{"key":"search","version":2}`), diff --git a/internal/sync/variation.go b/internal/sync/local/variation.go similarity index 52% rename from internal/sync/variation.go rename to internal/sync/local/variation.go index 58063fa3..ab137c44 100644 --- a/internal/sync/variation.go +++ b/internal/sync/local/variation.go @@ -1,4 +1,4 @@ -package sync +package local import ( "bytes" @@ -7,19 +7,23 @@ import ( "path" "strings" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" "gopkg.in/yaml.v3" ) -const configsDir = "configs" +const ( + configsDir = "configs" + variationFileSuffix = ".prompt.md" +) type variationParser struct{} -func (variationParser) Dir() string { +func (variationParser) dir() string { return configsDir } -func (variationParser) Accept(relPath string) bool { - if path.Ext(relPath) != ".prompt" { +func (variationParser) accept(relPath string) bool { + if !strings.HasSuffix(relPath, variationFileSuffix) { return false } @@ -30,80 +34,54 @@ func (variationParser) Accept(relPath string) bool { } type variationFrontMatter struct { - FormatVersion int `yaml:"formatVersion"` - Upsert bool `yaml:"upsert"` - Key string `yaml:"key"` - Name string `yaml:"name"` - ModelConfigKey string `yaml:"modelConfigKey"` - Model map[string]any `yaml:"model"` - OutputFormat map[string]any `yaml:"outputFormat"` - Tools []toolRef `yaml:"tools"` -} - -type toolRef struct { - Key string `json:"key" yaml:"key"` - Version int `json:"version" yaml:"version"` -} - -type message struct { - Role string `json:"role"` - Content string `json:"content"` -} - -type variationPayload struct { - Key string `json:"key"` - Name string `json:"name"` - ModelConfigKey string `json:"modelConfigKey,omitempty"` - Model map[string]any `json:"model,omitempty"` - OutputFormat map[string]any `json:"outputFormat,omitempty"` - Tools []toolRef `json:"tools,omitempty"` - Messages []message `json:"messages,omitempty"` + FormatVersion int `yaml:"formatVersion"` + Upsert bool `yaml:"upsert"` + syncdomain.Variation `yaml:",inline"` } -func (variationParser) Parse(file File) (SyncedResource, error) { +func (variationParser) parse(file file) (syncdomain.SyncedResource, error) { front, body, err := splitFrontMatter(file.Data) if err != nil { - return SyncedResource{}, err + return syncdomain.SyncedResource{}, err } var meta variationFrontMatter - dec := yaml.NewDecoder(bytes.NewReader(front)) - dec.KnownFields(true) + decoder := yaml.NewDecoder(bytes.NewReader(front)) + decoder.KnownFields(true) - if err := dec.Decode(&meta); err != nil { - return SyncedResource{}, fmt.Errorf("invalid front matter: %w", err) + if err := decoder.Decode(&meta); err != nil { + return syncdomain.SyncedResource{}, fmt.Errorf("invalid front matter: %w", err) } if err := validateVariation(file.RelPath, meta); err != nil { - return SyncedResource{}, err + return syncdomain.SyncedResource{}, err } - messages, err := parseMessages(string(body)) - if err != nil { - return SyncedResource{}, err + variation := meta.Variation + switch variation.Mode { + case syncdomain.VariationModeAgent: + variation.Instructions = strings.TrimSpace(string(body)) + case syncdomain.VariationModeCompletion: + messages, err := parseCompletionMessages(string(body)) + if err != nil { + return syncdomain.SyncedResource{}, err + } + variation.Messages = messages } - payload, err := marshalPayload(variationPayload{ - Key: meta.Key, - Name: meta.Name, - ModelConfigKey: meta.ModelConfigKey, - Model: meta.Model, - OutputFormat: meta.OutputFormat, - Tools: meta.Tools, - Messages: messages, - }) + payload, err := marshalPayload(variation) if err != nil { - return SyncedResource{}, err + return syncdomain.SyncedResource{}, err } configKey := path.Dir(file.RelPath) - return SyncedResource{ - Kind: KindVariation, + return syncdomain.SyncedResource{ + Kind: syncdomain.KindVariation, ProjectKey: file.ProjectKey, LookupKey: configKey + "/" + meta.Key, Payload: payload, - Fingerprint: Hash(payload), + Fingerprint: syncdomain.Hash(payload), Upsert: meta.Upsert, }, nil } @@ -114,13 +92,17 @@ func validateVariation(relPath string, meta variationFrontMatter) error { return errors.New("formatVersion is required") case meta.FormatVersion != 1: return fmt.Errorf("unsupported formatVersion %d", meta.FormatVersion) + case meta.Mode == "": + return errors.New("mode is required") + case !meta.Mode.Valid(): + return fmt.Errorf("unsupported mode %q", meta.Mode) case meta.Key == "": return errors.New("key is required") case meta.Name == "": return errors.New("name is required") } - stem := strings.TrimSuffix(path.Base(relPath), ".prompt") + stem := strings.TrimSuffix(path.Base(relPath), variationFileSuffix) if stem != meta.Key { return fmt.Errorf("key %q does not match filename %q", meta.Key, stem) } @@ -130,28 +112,28 @@ func validateVariation(relPath string, meta variationFrontMatter) error { func splitFrontMatter(data []byte) (front, body []byte, err error) { // Drop a leading BOM and blank lines so --- is the first real token. - s := bytes.TrimPrefix(data, []byte("\ufeff")) - s = bytes.TrimLeft(s, "\r\n") + source := bytes.TrimPrefix(data, []byte("\ufeff")) + source = bytes.TrimLeft(source, "\r\n") // Opening fence must be --- on its own line, not ---key: value. - if !bytes.HasPrefix(s, []byte("---")) { + if !bytes.HasPrefix(source, []byte("---")) { return nil, nil, errors.New("missing YAML front matter") } - rest, ok := consumeLineEnding(s[3:]) + rest, ok := consumeLineEnding(source[3:]) if !ok { return nil, nil, errors.New("missing YAML front matter") } // Closing fence is the first \n--- after the YAML block. - idx := bytes.Index(rest, []byte("\n---")) - if idx < 0 { + index := bytes.Index(rest, []byte("\n---")) + if index < 0 { return nil, nil, errors.New("unclosed YAML front matter") } - front = bytes.TrimSpace(rest[:idx]) + front = bytes.TrimSpace(rest[:index]) // Skip the line ending after the closing ---; leftover bytes are the prompt body. - after, ok := consumeLineEnding(rest[idx+4:]) + after, ok := consumeLineEnding(rest[index+4:]) if !ok { after = nil } @@ -159,40 +141,50 @@ func splitFrontMatter(data []byte) (front, body []byte, err error) { return front, bytes.TrimSpace(after), nil } -func consumeLineEnding(s []byte) ([]byte, bool) { +func consumeLineEnding(source []byte) ([]byte, bool) { // EOF after --- is a valid end of line (file ends on the fence). - if len(s) == 0 { - return s, true + if len(source) == 0 { + return source, true } - if s[0] == '\n' { - return s[1:], true + if source[0] == '\n' { + return source[1:], true } // Accept \r and \r\n so Windows and old Mac files parse the same way. - if s[0] == '\r' { - s = s[1:] - if len(s) > 0 && s[0] == '\n' { - s = s[1:] + if source[0] == '\r' { + source = source[1:] + if len(source) > 0 && source[0] == '\n' { + source = source[1:] } - return s, true + return source, true } // Next byte is content, so --- was not a fence on its own line. - return s, false + return source, false } var messageRoles = []string{"system", "user", "assistant"} -func parseMessages(body string) ([]message, error) { +func validMessageRole(role string) bool { + for _, allowed := range messageRoles { + if role == allowed { + return true + } + } + + return false +} + +func parseCompletionMessages(body string) ([]syncdomain.Message, error) { if strings.TrimSpace(body) == "" { return nil, nil } if _, _, _, ok := nextOpenTag(body, 0); !ok { - return []message{{Role: "system", Content: strings.TrimSpace(body)}}, nil + return []syncdomain.Message{{Role: "system", Content: strings.TrimSpace(body)}}, nil } - var messages []message + var messages []syncdomain.Message cursor := 0 for cursor < len(body) { @@ -212,7 +204,7 @@ func parseMessages(body string) ([]message, error) { return nil, fmt.Errorf("unclosed <%s> tag", role) } - messages = append(messages, message{ + messages = append(messages, syncdomain.Message{ Role: role, Content: strings.TrimSpace(body[contentStart:contentEnd]), }) @@ -227,16 +219,16 @@ func nextOpenTag(body string, from int) (start int, role string, contentStart in for _, candidate := range messageRoles { tag := "<" + candidate + ">" - i := strings.Index(body[from:], tag) - if i < 0 { + index := strings.Index(body[from:], tag) + if index < 0 { continue } - abs := from + i - if start < 0 || abs < start { - start = abs + absolute := from + index + if start < 0 || absolute < start { + start = absolute role = candidate - contentStart = abs + len(tag) + contentStart = absolute + len(tag) ok = true } } @@ -248,28 +240,28 @@ func matchingClose(body string, from int, role string) (contentEnd, closeEnd int open := "<" + role + ">" close := "" depth := 1 - i := from + index := from - for i < len(body) { - relOpen := strings.Index(body[i:], open) - relClose := strings.Index(body[i:], close) - if relClose < 0 { + for index < len(body) { + relativeOpen := strings.Index(body[index:], open) + relativeClose := strings.Index(body[index:], close) + if relativeClose < 0 { return 0, 0, false } - if relOpen >= 0 && relOpen < relClose { + if relativeOpen >= 0 && relativeOpen < relativeClose { depth++ - i += relOpen + len(open) + index += relativeOpen + len(open) continue } depth-- - closeAt := i + relClose + closeAt := index + relativeClose if depth == 0 { return closeAt, closeAt + len(close), true } - i = closeAt + len(close) + index = closeAt + len(close) } return 0, 0, false diff --git a/internal/sync/local/variation_test.go b/internal/sync/local/variation_test.go new file mode 100644 index 00000000..d2e35e4e --- /dev/null +++ b/internal/sync/local/variation_test.go @@ -0,0 +1,209 @@ +package local + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +func TestVariationParser_Accept(t *testing.T) { + parser := variationParser{} + + assert.True(t, parser.accept("my-config/my-variation.prompt.md")) + assert.False(t, parser.accept("my-variation.prompt.md")) + assert.False(t, parser.accept("my-config/nested/my-variation.prompt.md")) + assert.False(t, parser.accept("my-config/my-variation.prompt")) + assert.False(t, parser.accept("my-config/my-variation.md")) +} + +func TestVariationParser_UntaggedBodyIsSystemMessage(t *testing.T) { + file := file{ + ProjectKey: "proj", + RelPath: "cfg/plain.prompt.md", + Data: []byte(`--- +formatVersion: 1 +mode: completion +key: plain +name: Plain +--- + +Just say hello. +`), + } + + resource, err := variationParser{}.parse(file) + require.NoError(t, err) + + var payload syncdomain.Variation + require.NoError(t, unmarshalPayload(resource, &payload)) + require.Equal( + t, + []syncdomain.Message{{Role: "system", Content: "Just say hello."}}, + payload.Messages, + ) +} + +func TestVariationParser_AgentBodyIsInstructions(t *testing.T) { + file := file{ + ProjectKey: "proj", + RelPath: "cfg/agent.prompt.md", + Data: []byte(`--- +formatVersion: 1 +mode: agent +key: agent +name: Agent +--- + +Use the available tools. + +This tag is part of the instructions. +`), + } + + resource, err := variationParser{}.parse(file) + require.NoError(t, err) + + var payload syncdomain.Variation + require.NoError(t, unmarshalPayload(resource, &payload)) + assert.Equal( + t, + "Use the available tools.\n\nThis tag is part of the instructions.", + payload.Instructions, + ) + assert.Empty(t, payload.Messages) +} + +func TestVariationParser_RejectsInstructionsInFrontMatter(t *testing.T) { + file := file{ + ProjectKey: "proj", + RelPath: "cfg/agent.prompt.md", + Data: []byte(`--- +formatVersion: 1 +mode: agent +key: agent +name: Agent +instructions: Use the available tools. +--- +`), + } + + _, err := variationParser{}.parse(file) + require.ErrorContains(t, err, "field instructions not found") +} + +func TestVariationParser_MismatchedTags(t *testing.T) { + file := file{ + ProjectKey: "proj", + RelPath: "cfg/bad.prompt.md", + Data: []byte(`--- +formatVersion: 1 +mode: completion +key: bad +name: Bad +--- + + +oops + +`), + } + + _, err := variationParser{}.parse(file) + require.ErrorContains(t, err, "unclosed tag") +} + +func TestVariationParser_TextOutsideTags(t *testing.T) { + file := file{ + ProjectKey: "proj", + RelPath: "cfg/bad.prompt.md", + Data: []byte(`--- +formatVersion: 1 +mode: completion +key: bad +name: Bad +--- + +hello + +hi + +`), + } + + _, err := variationParser{}.parse(file) + require.ErrorContains(t, err, "unexpected text outside message tags") +} + +func TestVariationParser_RequiresFormatVersion(t *testing.T) { + file := file{ + ProjectKey: "proj", + RelPath: "cfg/v.prompt.md", + Data: []byte(`--- +key: v +name: V +--- +`), + } + + _, err := variationParser{}.parse(file) + require.ErrorContains(t, err, "formatVersion is required") +} + +func TestVariationParser_RequiresMode(t *testing.T) { + file := file{ + ProjectKey: "proj", + RelPath: "cfg/v.prompt.md", + Data: []byte(`--- +formatVersion: 1 +key: v +name: V +--- +`), + } + + _, err := variationParser{}.parse(file) + require.ErrorContains(t, err, "mode is required") +} + +func TestVariationParser_RejectsUnsupportedMode(t *testing.T) { + file := file{ + ProjectKey: "proj", + RelPath: "cfg/v.prompt.md", + Data: []byte(`--- +formatVersion: 1 +mode: other +key: v +name: V +--- +`), + } + + _, err := variationParser{}.parse(file) + require.ErrorContains(t, err, `unsupported mode "other"`) +} + +func TestVariationParser_RejectsUnknownFrontMatter(t *testing.T) { + file := file{ + ProjectKey: "proj", + RelPath: "cfg/v.prompt.md", + Data: []byte(`--- +formatVersion: 1 +mode: completion +key: v +name: V +mystery: true +--- +`), + } + + _, err := variationParser{}.parse(file) + require.ErrorContains(t, err, "invalid front matter") +} + +func unmarshalPayload(resource syncdomain.SyncedResource, destination any) error { + return json.Unmarshal(resource.Payload, destination) +} diff --git a/internal/sync/parser.go b/internal/sync/parser.go deleted file mode 100644 index 11cc09f7..00000000 --- a/internal/sync/parser.go +++ /dev/null @@ -1,20 +0,0 @@ -package sync - -type File struct { - ProjectKey string - RelPath string - Data []byte -} - -type Parser interface { - Dir() string - Accept(relPath string) bool - Parse(file File) (SyncedResource, error) -} - -func DefaultParsers() []Parser { - return []Parser{ - variationParser{}, - toolParser{}, - } -} diff --git a/internal/sync/git.go b/internal/sync/repository/git.go similarity index 99% rename from internal/sync/git.go rename to internal/sync/repository/git.go index 250eb71a..38935b8a 100644 --- a/internal/sync/git.go +++ b/internal/sync/repository/git.go @@ -1,4 +1,4 @@ -package sync +package repository import ( "errors" diff --git a/internal/sync/git_test.go b/internal/sync/repository/git_test.go similarity index 99% rename from internal/sync/git_test.go rename to internal/sync/repository/git_test.go index 70c061ee..f3a3e70d 100644 --- a/internal/sync/git_test.go +++ b/internal/sync/repository/git_test.go @@ -1,4 +1,4 @@ -package sync +package repository import ( "errors" diff --git a/internal/sync/resource.go b/internal/sync/resource.go index b186ed44..3a4adcd0 100644 --- a/internal/sync/resource.go +++ b/internal/sync/resource.go @@ -1,11 +1,9 @@ package sync import ( - "bytes" "crypto/sha256" "encoding/hex" "encoding/json" - "fmt" ) const RootDir = ".launchdarkly" @@ -34,14 +32,49 @@ type SyncedResource struct { Upsert bool } -func marshalPayload(v any) (json.RawMessage, error) { - var buf bytes.Buffer - enc := json.NewEncoder(&buf) - enc.SetEscapeHTML(false) +type VariationMode string - if err := enc.Encode(v); err != nil { - return nil, fmt.Errorf("marshal payload: %w", err) +const ( + VariationModeAgent VariationMode = "agent" + VariationModeCompletion VariationMode = "completion" +) + +func (m VariationMode) Valid() bool { + switch m { + case VariationModeAgent, VariationModeCompletion: + return true + default: + return false } +} + +type ToolRef struct { + Key string `json:"key" yaml:"key"` + Version int `json:"version" yaml:"version"` + CustomParameters map[string]any `json:"customParameters,omitempty" yaml:"customParameters,omitempty"` +} + +type SkillRef struct { + Key string `json:"key" yaml:"key"` + Version int `json:"version" yaml:"version"` +} + +type Message struct { + Role string `json:"role"` + Content string `json:"content"` +} - return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil +type Variation struct { + Mode VariationMode `json:"mode" yaml:"mode"` + Key string `json:"key" yaml:"key"` + Name string `json:"name" yaml:"name"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Instructions string `json:"instructions,omitempty" yaml:"-"` + ModelConfigKey string `json:"modelConfigKey,omitempty" yaml:"modelConfigKey,omitempty"` + ModelConfigVersion int `json:"modelConfigVersion,omitempty" yaml:"modelConfigVersion,omitempty"` + Model map[string]any `json:"model,omitempty" yaml:"model,omitempty"` + OutputFormat map[string]any `json:"outputFormat,omitempty" yaml:"outputFormat,omitempty"` + Tools []ToolRef `json:"tools,omitempty" yaml:"tools,omitempty"` + Skills []SkillRef `json:"skills,omitempty" yaml:"skills,omitempty"` + Messages []Message `json:"messages,omitempty" yaml:"-"` } diff --git a/internal/sync/variation_test.go b/internal/sync/variation_test.go deleted file mode 100644 index 2a10eb1e..00000000 --- a/internal/sync/variation_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package sync - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestVariationParser_Accept(t *testing.T) { - p := variationParser{} - - assert.True(t, p.Accept("my-config/my-variation.prompt")) - assert.False(t, p.Accept("my-variation.prompt")) - assert.False(t, p.Accept("my-config/nested/my-variation.prompt")) - assert.False(t, p.Accept("my-config/my-variation.md")) -} - -func TestVariationParser_UntaggedBodyIsSystemMessage(t *testing.T) { - file := File{ - ProjectKey: "proj", - RelPath: "cfg/plain.prompt", - Data: []byte(`--- -formatVersion: 1 -key: plain -name: Plain ---- - -Just say hello. -`), - } - - resource, err := variationParser{}.Parse(file) - require.NoError(t, err) - - var payload variationPayload - require.NoError(t, unmarshalPayload(resource, &payload)) - require.Equal(t, []message{{Role: "system", Content: "Just say hello."}}, payload.Messages) -} - -func TestVariationParser_MismatchedTags(t *testing.T) { - file := File{ - ProjectKey: "proj", - RelPath: "cfg/bad.prompt", - Data: []byte(`--- -formatVersion: 1 -key: bad -name: Bad ---- - - -oops - -`), - } - - _, err := variationParser{}.Parse(file) - require.ErrorContains(t, err, "unclosed tag") -} - -func TestVariationParser_TextOutsideTags(t *testing.T) { - file := File{ - ProjectKey: "proj", - RelPath: "cfg/bad.prompt", - Data: []byte(`--- -formatVersion: 1 -key: bad -name: Bad ---- - -hello - -hi - -`), - } - - _, err := variationParser{}.Parse(file) - require.ErrorContains(t, err, "unexpected text outside message tags") -} - -func TestVariationParser_RequiresFormatVersion(t *testing.T) { - file := File{ - ProjectKey: "proj", - RelPath: "cfg/v.prompt", - Data: []byte(`--- -key: v -name: V ---- -`), - } - - _, err := variationParser{}.Parse(file) - require.ErrorContains(t, err, "formatVersion is required") -} - -func TestVariationParser_RejectsUnknownFrontMatter(t *testing.T) { - file := File{ - ProjectKey: "proj", - RelPath: "cfg/v.prompt", - Data: []byte(`--- -formatVersion: 1 -key: v -name: V -mystery: true ---- -`), - } - - _, err := variationParser{}.Parse(file) - require.ErrorContains(t, err, "invalid front matter") -} - -func unmarshalPayload(resource SyncedResource, dest any) error { - return json.Unmarshal(resource.Payload, dest) -} From c1d916747d1025369f6b2e73f5e4264c7efafee5 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Fri, 11 Sep 2026 19:17:41 -0400 Subject: [PATCH 2/3] feat(sync): write local prompt variation files --- cmd/sync/bootstrap_test.go | 129 -------- internal/sync/bootstrap/bootstrap.go | 320 ------------------- internal/sync/bootstrap/bootstrap_test.go | 307 ------------------ internal/sync/bootstrap/update.go | 359 ---------------------- internal/sync/bootstrap/view.go | 149 --------- internal/sync/local/parser.go | 40 --- internal/sync/local/store.go | 12 +- internal/sync/local/store_test.go | 33 +- 8 files changed, 31 insertions(+), 1318 deletions(-) delete mode 100644 cmd/sync/bootstrap_test.go delete mode 100644 internal/sync/bootstrap/bootstrap.go delete mode 100644 internal/sync/bootstrap/bootstrap_test.go delete mode 100644 internal/sync/bootstrap/update.go delete mode 100644 internal/sync/bootstrap/view.go delete mode 100644 internal/sync/local/parser.go diff --git a/cmd/sync/bootstrap_test.go b/cmd/sync/bootstrap_test.go deleted file mode 100644 index 084bd691..00000000 --- a/cmd/sync/bootstrap_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package sync - -import ( - "bytes" - "net/url" - "os" - "os/exec" - "path/filepath" - "testing" - - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/launchdarkly/ldcli/cmd/cliflags" - "github.com/launchdarkly/ldcli/internal/resources" - syncdomain "github.com/launchdarkly/ldcli/internal/sync" - syncbootstrap "github.com/launchdarkly/ldcli/internal/sync/bootstrap" -) - -func TestRunPromptUsesSharedFlowForBootstrapAndAdd(t *testing.T) { - tests := map[string]struct { - createDirectory bool - add bool - wantInitial bool - }{ - "missing directory bootstraps": { - wantInitial: true, - }, - "add uses existing directory": { - createDirectory: true, - add: true, - wantInitial: false, - }, - } - - for name, test := range tests { - t.Run(name, func(t *testing.T) { - root := initBootstrapRepo(t) - if test.createDirectory { - require.NoError(t, os.Mkdir(filepath.Join(root, syncdomain.RootDir), 0o755)) - } - t.Chdir(root) - - var called bool - runner := func(options syncbootstrap.Options) error { - called = true - assert.Equal(t, test.wantInitial, options.Initial) - assert.NotNil(t, options.Input) - assert.NotNil(t, options.Output) - return nil - } - - viper.Set(cliflags.AccessTokenFlag, "token") - viper.Set(cliflags.BaseURIFlag, "https://example.com") - t.Cleanup(viper.Reset) - - command := newPromptCmd(noopResourceClient{}, runner) - require.NoError(t, command.Flags().Set(addFlag, boolString(test.add))) - require.NoError(t, command.RunE(command, nil)) - assert.True(t, called) - }) - } -} - -func TestWriteRequestDebugIncludesQuery(t *testing.T) { - var output bytes.Buffer - writeRequestDebug( - &output, - "GET", - "https://example.com/api/v2/projects", - url.Values{ - "sort": {"name"}, - "limit": {"25"}, - "offset": {"50"}, - }, - nil, - ) - - assert.Contains( - t, - output.String(), - "Path: /api/v2/projects?limit=25&offset=50&sort=name", - ) -} - -type noopResourceClient struct{} - -var _ resources.Client = noopResourceClient{} - -func (noopResourceClient) MakeRequest( - string, - string, - string, - string, - url.Values, - []byte, - bool, -) ([]byte, error) { - return nil, nil -} - -func (noopResourceClient) MakeUnauthenticatedRequest(string, string, []byte) ([]byte, error) { - return nil, nil -} - -func initBootstrapRepo(t *testing.T) string { - t.Helper() - - root := t.TempDir() - command := exec.Command("git", "init", "--quiet") - command.Dir = root - output, err := command.CombinedOutput() - require.NoError(t, err, string(output)) - - command = exec.Command("git", "remote", "add", "origin", "git@github.com:launchdarkly/ldcli.git") - command.Dir = root - output, err = command.CombinedOutput() - require.NoError(t, err, string(output)) - - return root -} - -func boolString(value bool) string { - if value { - return "true" - } - return "false" -} diff --git a/internal/sync/bootstrap/bootstrap.go b/internal/sync/bootstrap/bootstrap.go deleted file mode 100644 index 9e656ace..00000000 --- a/internal/sync/bootstrap/bootstrap.go +++ /dev/null @@ -1,320 +0,0 @@ -package bootstrap - -import ( - "cmp" - "fmt" - "io" - "os" - "slices" - "strings" - - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "golang.org/x/term" - - syncdomain "github.com/launchdarkly/ldcli/internal/sync" - syncapi "github.com/launchdarkly/ldcli/internal/sync/api" - synclocal "github.com/launchdarkly/ldcli/internal/sync/local" -) - -// Options contains the dependencies and streams required by the bootstrap -// wizard. -type Options struct { - API Client - Store synclocal.Store - Input io.Reader - Output io.Writer - Initial bool -} - -type Client interface { - Projects(search string) ([]syncapi.Project, error) - Configs(projectKey, search string) ([]syncapi.Config, error) - Config(projectKey, configKey string) (syncapi.Config, error) -} - -type step int - -const ( - selectProject step = iota - selectConfig - selectVariations -) - -type model struct { - api Client - store synclocal.Store - - step step - width int - height int - - projects remotePicker - configs remotePicker - variationList list.Model - variationsReady bool - - projectKey string - config syncapi.Config - - searching bool - notice string - err error - canceled bool -} - -type remotePicker struct { - list.Model - title string - query string - ready bool -} - -type projectItem struct { - project syncapi.Project -} - -func (i projectItem) Title() string { return i.project.Name } -func (i projectItem) Description() string { return i.project.Key } -func (i projectItem) FilterValue() string { return i.project.Name + " " + i.project.Key } - -type configItem struct { - config syncapi.Config -} - -func (i configItem) Title() string { return i.config.Name } -func (i configItem) Description() string { - return fmt.Sprintf("%s · %s", i.config.Key, i.config.Mode) -} -func (i configItem) FilterValue() string { return i.config.Name + " " + i.config.Key } - -type variationItem struct { - variation syncdomain.Variation - selected bool - existing bool -} - -func (i variationItem) Title() string { return i.variation.Name } -func (i variationItem) FilterValue() string { return i.variation.Name + " " + i.variation.Key } -func (i variationItem) Description() string { - if i.existing { - return i.variation.Key + " · already synced" - } - return i.variation.Key -} - -type remoteFetchedMsg struct { - step step - projectKey string - query string - items []list.Item -} - -type fetchFailedMsg struct { - step step - projectKey string - query string - err error -} - -type configFetchedMsg struct { - projectKey string - config syncapi.Config - existing map[string]bool -} - -type errMsg struct { - err error -} - -func newModel(options Options) model { - return model{ - api: options.API, - store: options.Store, - step: selectProject, - projects: remotePicker{title: "Select a LaunchDarkly project"}, - configs: remotePicker{title: "Select a Config"}, - } -} - -func (m model) Init() tea.Cmd { - return m.fetchProjects("") -} - -func (m model) fetchProjects(search string) tea.Cmd { - return func() tea.Msg { - projects, err := m.api.Projects(search) - if err != nil { - return fetchFailedMsg{step: selectProject, query: search, err: err} - } - - items := make([]list.Item, len(projects)) - for index, project := range projects { - items[index] = projectItem{project: project} - } - - return remoteFetchedMsg{ - step: selectProject, - query: search, - items: items, - } - } -} - -func (m model) fetchConfigs(search string) tea.Cmd { - projectKey := m.projectKey - - return func() tea.Msg { - configs, err := m.api.Configs( - projectKey, - search, - ) - if err != nil { - return fetchFailedMsg{ - step: selectConfig, - projectKey: projectKey, - query: search, - err: err, - } - } - - items := make([]list.Item, len(configs)) - for index, config := range configs { - items[index] = configItem{config: config} - } - - return remoteFetchedMsg{ - step: selectConfig, - projectKey: projectKey, - query: search, - items: items, - } - } -} - -func (m model) fetchConfig() tea.Cmd { - projectKey, configKey := m.projectKey, m.config.Key - - return func() tea.Msg { - config, err := m.api.Config( - projectKey, - configKey, - ) - if err != nil { - return errMsg{err: err} - } - - slices.SortFunc(config.Variations, func(a, b syncdomain.Variation) int { - return cmp.Or( - cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)), - cmp.Compare(a.Key, b.Key), - ) - }) - - existing := make(map[string]bool, len(config.Variations)) - for _, variation := range config.Variations { - found, err := m.store.VariationExists(projectKey, configKey, variation.Key) - if err != nil { - return errMsg{err: err} - } - existing[variation.Key] = found - } - - return configFetchedMsg{ - projectKey: projectKey, - config: config, - existing: existing, - } - } -} - -func (m model) selectedVariationFiles() []synclocal.VariationFile { - var resources []synclocal.VariationFile - - for _, raw := range m.variationList.Items() { - item, ok := raw.(variationItem) - if !ok || !item.selected { - continue - } - - resources = append(resources, synclocal.VariationFile{ - ProjectKey: m.projectKey, - ConfigKey: m.config.Key, - Upsert: true, - Variation: item.variation, - }) - } - - return resources -} - -// Run starts the interactive wizard and writes a completion summary. -func Run(options Options) error { - if !terminalStreams(options.Input, options.Output) { - return fmt.Errorf("interactive prompt selection requires a terminal; run this command in a terminal") - } - - program := tea.NewProgram( - newModel(options), - tea.WithAltScreen(), - tea.WithInput(options.Input), - tea.WithOutput(options.Output), - ) - final, err := program.Run() - if err != nil { - return err - } - - result, ok := final.(model) - if !ok { - return fmt.Errorf("bootstrap returned an unexpected model") - } - if result.err != nil || result.canceled { - return result.err - } - - files := result.selectedVariationFiles() - if len(files) == 0 { - writeSummary(options.Output, options.Initial, true, nil) - return nil - } - - var paths []string - if options.Initial { - paths, err = options.Store.Bootstrap(files) - } else { - paths, err = options.Store.Add(files) - } - if err != nil { - return err - } - - writeSummary(options.Output, options.Initial, false, paths) - return nil -} - -func terminalStreams(input io.Reader, output io.Writer) bool { - in, inOK := input.(*os.File) - out, outOK := output.(*os.File) - - return inOK && outOK && - term.IsTerminal(int(in.Fd())) && - term.IsTerminal(int(out.Fd())) -} - -func writeSummary(out io.Writer, initial, noChange bool, paths []string) { - if noChange { - fmt.Fprintln(out, "No variations added; every variation in that Config is already synced.") - return - } - - action := "Added" - if initial { - action = "Bootstrapped" - } - resource := "variation file" - if len(paths) != 1 { - resource += "s" - } - fmt.Fprintf(out, "%s %d %s in %s.\n", action, len(paths), resource, syncdomain.RootDir) -} diff --git a/internal/sync/bootstrap/bootstrap_test.go b/internal/sync/bootstrap/bootstrap_test.go deleted file mode 100644 index 4c042e44..00000000 --- a/internal/sync/bootstrap/bootstrap_test.go +++ /dev/null @@ -1,307 +0,0 @@ -package bootstrap - -import ( - "bytes" - "os" - "path/filepath" - "testing" - - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "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" -) - -func TestModelSelectsAndWritesVariations(t *testing.T) { - root := t.TempDir() - result := newModel(Options{ - API: testAPIClient(), - Store: synclocal.NewStore(root), - Initial: true, - }) - result = updateModel(t, result, tea.WindowSizeMsg{Width: 80, Height: 24}) - result = updateModel(t, result, projectResults( - "", - syncapi.Project{Key: "project", Name: "Project"}, - )) - - next, cmd := result.handleEnter() - result = next.(model) - assert.Equal(t, selectConfig, result.step) - assert.NotNil(t, cmd) - - result = updateModel(t, result, remoteFetchedMsg{ - step: selectConfig, - projectKey: "project", - items: []list.Item{configItem{config: syncapi.Config{ - Key: "assistant", Name: "Assistant", Mode: syncdomain.VariationModeAgent, - }}}, - }) - next, cmd = result.handleEnter() - result = next.(model) - assert.Equal(t, selectVariations, result.step) - assert.NotNil(t, cmd) - - result = updateModel(t, result, configFetchedMsg{ - projectKey: "project", - config: syncapi.Config{ - Key: "assistant", - Name: "Assistant", - Mode: syncdomain.VariationModeAgent, - Variations: []syncdomain.Variation{ - { - Mode: syncdomain.VariationModeAgent, - Key: "friendly", - Name: "Friendly", - }, - { - Mode: syncdomain.VariationModeAgent, - Key: "existing", - Name: "Existing", - }, - }, - }, - existing: map[string]bool{"existing": true}, - }) - - result.toggleVariation() - selected, _ := result.variationCounts() - assert.Equal(t, 1, selected) - result.selectAllVariations() - assert.False(t, result.variationList.Items()[1].(variationItem).selected) - - next, cmd = result.handleEnter() - result = next.(model) - require.NotNil(t, cmd) - - files := result.selectedVariationFiles() - require.Len(t, files, 1) - paths, err := result.store.Bootstrap(files) - require.NoError(t, err) - assert.Equal(t, []string{"project/configs/assistant/friendly.prompt.md"}, paths) - - contents, err := os.ReadFile(filepath.Join( - root, - syncdomain.RootDir, - "project", - "configs", - "assistant", - "friendly.prompt.md", - )) - require.NoError(t, err) - assert.Contains(t, string(contents), "mode: agent") - _, err = os.Stat(filepath.Join( - root, - syncdomain.RootDir, - "project", - "configs", - "assistant", - "existing.prompt.md", - )) - require.ErrorIs(t, err, os.ErrNotExist) -} - -func TestModelAllExistingIsNoOp(t *testing.T) { - result := newModel(Options{ - API: testAPIClient(), - Store: synclocal.NewStore(t.TempDir()), - }) - result.step = selectVariations - result.variationsReady = true - result.variationList = newVariationList([]list.Item{ - variationItem{ - variation: syncdomain.Variation{Key: "existing", Name: "Existing"}, - existing: true, - }, - }, 80, 20) - - next, cmd := result.handleEnter() - result = next.(model) - assert.Equal(t, selectVariations, result.step) - selected, _ := result.variationCounts() - assert.Zero(t, selected) - assert.NotNil(t, cmd) -} - -func TestModelRequiresSelection(t *testing.T) { - result := newModel(Options{ - API: testAPIClient(), - Store: synclocal.NewStore(t.TempDir()), - }) - result.step = selectVariations - result.variationsReady = true - result.variationList = newVariationList([]list.Item{ - variationItem{variation: syncdomain.Variation{Key: "available", Name: "Available"}}, - }, 80, 20) - - next, cmd := result.handleEnter() - result = next.(model) - assert.Equal(t, selectVariations, result.step) - assert.Equal(t, "Select at least one variation to continue.", result.notice) - assert.Nil(t, cmd) -} - -func TestModelIgnoresStaleSearchResultsAndErrors(t *testing.T) { - result := newModel(Options{ - API: testAPIClient(), - Store: synclocal.NewStore(t.TempDir()), - }) - result = updateModel(t, result, projectResults( - "", - syncapi.Project{Key: "original", Name: "Original"}, - )) - result.projects.SetFilterText("customer") - - result = updateModel(t, result, projectResults( - "old query", - syncapi.Project{Key: "stale", Name: "Stale"}, - )) - assert.Equal(t, "original", result.projects.Items()[0].(projectItem).project.Key) - result = updateModel(t, result, fetchFailedMsg{ - step: selectProject, - query: "old query", - err: assert.AnError, - }) - assert.NoError(t, result.err) - - result = updateModel(t, result, projectResults( - "customer", - syncapi.Project{Key: "current", Name: "Current"}, - )) - assert.Equal(t, "current", result.projects.Items()[0].(projectItem).project.Key) - require.Len(t, result.projects.VisibleItems(), 1) - assert.Equal(t, "customer", result.projects.query) -} - -func TestModelUsesConfigLabel(t *testing.T) { - result := newModel(Options{ - API: testAPIClient(), - Store: synclocal.NewStore(t.TempDir()), - }) - result.projectKey = "project" - result.step = selectConfig - result = updateModel(t, result, tea.WindowSizeMsg{Width: 80, Height: 24}) - result = updateModel(t, result, remoteFetchedMsg{ - step: selectConfig, - projectKey: "project", - items: []list.Item{configItem{config: syncapi.Config{ - Key: "config", Name: "Config", Mode: syncdomain.VariationModeCompletion, - }}}, - }) - - assert.Equal(t, "Select a Config", result.configs.Title) - assert.Contains(t, result.View(), "Select a Config") -} - -func TestFetchConfigsScopesSearchAndModes(t *testing.T) { - client := &fakeClient{} - result := newModel(Options{ - API: client, - Store: synclocal.NewStore(t.TempDir()), - }) - result.projectKey = "project" - - message, ok := result.fetchConfigs("support")().(remoteFetchedMsg) - require.True(t, ok) - assert.Equal(t, "project", message.projectKey) - assert.Equal(t, "support", message.query) - assert.Equal(t, "project", client.projectKey) - assert.Equal(t, "support", client.search) -} - -func TestModelFilterInputDoesNotQuit(t *testing.T) { - result := newModel(Options{ - API: testAPIClient(), - Store: synclocal.NewStore(t.TempDir()), - }) - result = updateModel(t, result, projectResults( - "", - syncapi.Project{Key: "project", Name: "Project"}, - )) - result = updateModel(t, result, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) - require.True(t, result.isFiltering()) - - result = updateModel(t, result, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) - assert.False(t, result.canceled) - assert.True(t, result.isFiltering()) -} - -func TestModelAPIErrorQuitsWithError(t *testing.T) { - result := newModel(Options{ - API: testAPIClient(), - Store: synclocal.NewStore(t.TempDir()), - }) - - updated, cmd := result.Update(errMsg{err: assert.AnError}) - result = updated.(model) - assert.ErrorIs(t, result.err, assert.AnError) - assert.NotNil(t, cmd) -} - -func TestRunRequiresTerminal(t *testing.T) { - err := Run(Options{ - API: testAPIClient(), - 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 TestWriteSummary(t *testing.T) { - var output bytes.Buffer - writeSummary(&output, true, false, []string{"a.prompt.md", "b.prompt.md"}) - assert.Equal(t, "Bootstrapped 2 variation files in .launchdarkly.\n", output.String()) - - output.Reset() - writeSummary(&output, false, true, nil) - assert.Contains(t, output.String(), "No variations added") -} - -func updateModel(t *testing.T, current model, message tea.Msg) model { - t.Helper() - updated, _ := current.Update(message) - result, ok := updated.(model) - require.True(t, ok) - return result -} - -func testAPIClient() Client { - return &fakeClient{} -} - -type fakeClient struct { - projectKey string - search string -} - -var _ Client = &fakeClient{} - -func (*fakeClient) Projects(string) ([]syncapi.Project, error) { - return nil, nil -} - -func (client *fakeClient) Configs( - projectKey, search string, -) ([]syncapi.Config, error) { - client.projectKey = projectKey - client.search = search - return nil, nil -} - -func (*fakeClient) Config(string, string) (syncapi.Config, error) { - return syncapi.Config{}, nil -} - -func projectResults(query string, projects ...syncapi.Project) remoteFetchedMsg { - items := make([]list.Item, len(projects)) - for index, project := range projects { - items[index] = projectItem{project: project} - } - return remoteFetchedMsg{step: selectProject, query: query, items: items} -} diff --git a/internal/sync/bootstrap/update.go b/internal/sync/bootstrap/update.go deleted file mode 100644 index ab0fe2ba..00000000 --- a/internal/sync/bootstrap/update.go +++ /dev/null @@ -1,359 +0,0 @@ -package bootstrap - -import ( - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" -) - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - m.resizeLists() - - case tea.KeyMsg: - switch msg.String() { - case "ctrl+c": - m.canceled = true - return m, tea.Quit - case "q": - if !m.isFiltering() { - m.canceled = true - return m, tea.Quit - } - case "b": - if !m.isFiltering() { - return m.handleBack() - } - case " ": - if m.step == selectVariations && !m.isFiltering() { - return m, m.toggleVariation() - } - case "a": - if m.step == selectVariations && !m.isFiltering() { - return m, m.selectAllVariations() - } - case "enter": - if m.isFiltering() { - switch m.step { - case selectProject, selectConfig: - return m.submitServerSearch(msg) - } - } - return m.handleEnter() - } - - case remoteFetchedMsg: - picker := m.picker(msg.step) - if picker == nil || - msg.step != m.step || - (msg.step == selectConfig && msg.projectKey != m.projectKey) || - (picker.ready && msg.query != picker.FilterValue()) { - return m, nil - } - m.searching = false - picker.query = msg.query - if !picker.ready { - picker.ready = true - picker.Model = newServerList(msg.items, picker.title, m.width, m.listHeight()) - return m, nil - } - replaceServerItems(&picker.Model, msg.items, msg.query) - return m, nil - - case fetchFailedMsg: - if msg.step != m.step || - (msg.step == selectConfig && msg.projectKey != m.projectKey) { - return m, nil - } - if remote := m.remoteList(); remote != nil && msg.query != remote.FilterValue() { - return m, nil - } - m.err = msg.err - return m, tea.Quit - - case list.FilterMatchesMsg: - if m.step == selectProject || m.step == selectConfig { - return m, nil - } - - case configFetchedMsg: - if msg.projectKey != m.projectKey || - msg.config.Key != m.config.Key || - m.step != selectVariations { - return m, nil - } - m.config = msg.config - m.variationsReady = true - items := make([]list.Item, len(msg.config.Variations)) - for index, variation := range msg.config.Variations { - items[index] = variationItem{ - variation: variation, - existing: msg.existing[variation.Key], - } - } - m.variationList = newVariationList(items, m.width, m.listHeight()) - return m, nil - - case errMsg: - m.err = msg.err - return m, tea.Quit - - } - - if remote := m.remoteList(); remote != nil { - before := remote.FilterValue() - updated, cmd := remote.Update(msg) - *remote = updated - if before != "" && remote.FilterValue() == "" { - m.searching = true - return m, tea.Batch(cmd, m.fetchRemote("")) - } - return m, cmd - } - if m.step == selectVariations && m.variationsReady { - var cmd tea.Cmd - m.variationList, cmd = m.variationList.Update(msg) - return m, cmd - } - return m, nil -} - -func newServerList(items []list.Item, title string, width, height int) list.Model { - delegate := themedDefaultDelegate() - result := list.New(items, delegate, width, height) - result.Title = title - result.Filter = serverFilter - configureList(&result) - - return result -} - -func newVariationList(items []list.Item, width, height int) list.Model { - result := list.New(items, variationDelegate{}, width, height) - result.Title = "Select variations" - configureList(&result) - result.AdditionalShortHelpKeys = variationListHints() - - return result -} - -func serverFilter(_ string, targets []string) []list.Rank { - ranks := make([]list.Rank, len(targets)) - for index := range targets { - ranks[index] = list.Rank{Index: index} - } - return ranks -} - -func replaceServerItems(model *list.Model, items []list.Item, query string) { - _ = model.SetItems(items) - if query != "" { - model.SetFilterText(query) - } -} - -func configureList(model *list.Model) { - applyListTheme(model) - model.KeyMap.Quit = key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")) - model.AdditionalShortHelpKeys = func() []key.Binding { - return []key.Binding{ - key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), - key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "back")), - } - } -} - -func (m *model) resizeLists() { - if m.projects.ready { - m.projects.SetSize(m.width, m.listHeight()) - } - if m.configs.ready { - m.configs.SetSize(m.width, m.listHeight()) - } - if m.variationsReady { - m.variationList.SetSize(m.width, m.listHeight()) - } -} - -func (m model) listHeight() int { - height := m.height - 2 - if height < 5 { - return 5 - } - return height -} - -func (m model) isFiltering() bool { - if remote := m.remoteList(); remote != nil { - return remote.FilterState() == list.Filtering - } - return m.step == selectVariations && - m.variationsReady && - m.variationList.FilterState() == list.Filtering -} - -func (m model) handleBack() (tea.Model, tea.Cmd) { - m.notice = "" - m.searching = false - switch m.step { - case selectConfig: - m.step = selectProject - m.configs = remotePicker{title: "Select a Config"} - case selectVariations: - m.step = selectConfig - m.variationsReady = false - m.variationList = list.Model{} - } - return m, nil -} - -func (m model) handleEnter() (tea.Model, tea.Cmd) { - m.notice = "" - switch m.step { - case selectProject: - if !m.projects.ready || - m.projects.FilterValue() != m.projects.query || - len(m.projects.Items()) == 0 { - return m, nil - } - selected, ok := m.projects.SelectedItem().(projectItem) - if !ok { - return m, nil - } - m.projectKey = selected.project.Key - m.configs = remotePicker{title: "Select a Config"} - m.step = selectConfig - m.searching = false - return m, m.fetchConfigs("") - - case selectConfig: - if !m.configs.ready || - m.configs.FilterValue() != m.configs.query || - len(m.configs.Items()) == 0 { - return m, nil - } - selected, ok := m.configs.SelectedItem().(configItem) - if !ok { - return m, nil - } - m.config = selected.config - m.variationsReady = false - m.variationList = list.Model{} - m.step = selectVariations - m.searching = false - return m, m.fetchConfig() - - case selectVariations: - if !m.variationsReady { - return m, nil - } - selected, selectable := m.variationCounts() - if selected == 0 { - if selectable == 0 { - return m, tea.Quit - } - m.notice = "Select at least one variation to continue." - return m, nil - } - return m, tea.Quit - } - return m, nil -} - -func (m *model) toggleVariation() tea.Cmd { - item, ok := m.variationList.SelectedItem().(variationItem) - if !ok || item.existing { - return nil - } - - item.selected = !item.selected - items := m.variationList.Items() - for index, raw := range items { - candidate, ok := raw.(variationItem) - if ok && candidate.variation.Key == item.variation.Key { - items[index] = item - break - } - } - m.notice = "" - return m.variationList.SetItems(items) -} - -func (m *model) selectAllVariations() tea.Cmd { - items := m.variationList.Items() - for index, raw := range items { - item, ok := raw.(variationItem) - if !ok || item.existing { - continue - } - item.selected = true - items[index] = item - } - m.notice = "" - return m.variationList.SetItems(items) -} - -func (m model) variationCounts() (selected, selectable int) { - for _, raw := range m.variationList.Items() { - item, ok := raw.(variationItem) - if !ok { - continue - } - if item.selected { - selected++ - } - if !item.existing { - selectable++ - } - } - return selected, selectable -} - -func (m model) submitServerSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - remote := m.remoteList() - if remote == nil { - return m, nil - } - updated, inputCmd := remote.Update(msg) - *remote = updated - finishSubmittedFilter(remote) - m.searching = true - return m, tea.Batch(inputCmd, m.fetchRemote(remote.FilterValue())) -} - -func finishSubmittedFilter(model *list.Model) { - if model.FilterValue() == "" { - model.SetFilterState(list.Unfiltered) - return - } - model.SetFilterState(list.FilterApplied) -} - -func (m *model) remoteList() *list.Model { - picker := m.picker(m.step) - if picker == nil || !picker.ready { - return nil - } - return &picker.Model -} - -func (m model) fetchRemote(search string) tea.Cmd { - if m.step == selectProject { - return m.fetchProjects(search) - } - return m.fetchConfigs(search) -} - -func (m *model) picker(target step) *remotePicker { - switch target { - case selectProject: - return &m.projects - case selectConfig: - return &m.configs - default: - return nil - } -} diff --git a/internal/sync/bootstrap/view.go b/internal/sync/bootstrap/view.go deleted file mode 100644 index 9b280f43..00000000 --- a/internal/sync/bootstrap/view.go +++ /dev/null @@ -1,149 +0,0 @@ -package bootstrap - -import ( - "fmt" - "io" - "strings" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -var ( - selectionColor = lipgloss.Color("2") - - selectedStyle = lipgloss.NewStyle().Foreground(selectionColor) - noticeStyle = lipgloss.NewStyle().Bold(true) - mutedStyle = lipgloss.NewStyle().Faint(true) -) - -type variationDelegate struct{} - -func (variationDelegate) Height() int { return 2 } -func (variationDelegate) Spacing() int { return 1 } -func (variationDelegate) Update(tea.Msg, *list.Model) tea.Cmd { - return nil -} - -func (variationDelegate) Render( - writer io.Writer, - model list.Model, - index int, - raw list.Item, -) { - item, ok := raw.(variationItem) - if !ok { - return - } - - cursor := " " - if index == model.Index() { - cursor = "> " - } - checkbox := "[ ]" - if item.selected { - checkbox = "[x]" - } - if item.existing { - checkbox = "[-]" - } - - title := fmt.Sprintf("%s%s %s", cursor, checkbox, item.Title()) - description := " " + item.Description() - switch { - case item.existing: - fmt.Fprintln(writer, mutedStyle.Render(title)) - fmt.Fprint(writer, mutedStyle.Render(description)) - case index == model.Index(): - fmt.Fprintln(writer, selectedStyle.Render(title)) - fmt.Fprint(writer, selectedStyle.Render(description)) - default: - fmt.Fprintln(writer, title) - fmt.Fprint(writer, description) - } -} - -func themedDefaultDelegate() list.DefaultDelegate { - delegate := list.NewDefaultDelegate() - delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle. - Foreground(selectionColor). - BorderForeground(selectionColor) - delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc. - Foreground(selectionColor). - BorderForeground(selectionColor) - delegate.Styles.FilterMatch = delegate.Styles.FilterMatch.Foreground(selectionColor) - return delegate -} - -func applyListTheme(model *list.Model) { - model.Styles.Title = lipgloss.NewStyle().Bold(true).Padding(0, 1) - model.Styles.FilterPrompt = lipgloss.NewStyle() - model.Styles.FilterCursor = lipgloss.NewStyle().Foreground(selectionColor) - model.Styles.StatusBarActiveFilter = lipgloss.NewStyle() - model.Styles.ActivePaginationDot = model.Styles.ActivePaginationDot.Foreground(selectionColor) - model.FilterInput.PromptStyle = model.Styles.FilterPrompt - model.FilterInput.Cursor.Style = model.Styles.FilterCursor - model.Paginator.ActiveDot = model.Styles.ActivePaginationDot.String() -} - -func variationListHints() func() []key.Binding { - return func() []key.Binding { - return []key.Binding{ - key.NewBinding(key.WithKeys("space"), key.WithHelp("space", "toggle")), - key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "select all")), - key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "write")), - key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "back")), - } - } -} - -func (m model) View() string { - if m.err != nil || m.canceled { - return "" - } - - switch m.step { - case selectProject: - if !m.projects.ready { - return m.loadingView("Loading projects") - } - return m.listView(m.projects.Model) - - case selectConfig: - if !m.configs.ready { - return m.loadingView("Loading Configs") - } - return m.listView(m.configs.Model) - - case selectVariations: - if !m.variationsReady { - return m.loadingView("Loading variations") - } - var view strings.Builder - view.WriteString(m.variationList.View()) - selected, _ := m.variationCounts() - fmt.Fprintf(&view, "\nSelected: %d", selected) - if m.notice != "" { - view.WriteString("\n") - view.WriteString(noticeStyle.Render(m.notice)) - } - return view.String() - - default: - return "" - } -} - -func (m model) loadingView(label string) string { - return fmt.Sprintf("\n %s...\n", label) -} - -func (m model) listView(items list.Model) string { - view := items.View() - if m.searching { - view += "\nSearching..." - } - return view -} diff --git a/internal/sync/local/parser.go b/internal/sync/local/parser.go deleted file mode 100644 index 5e4ea15e..00000000 --- a/internal/sync/local/parser.go +++ /dev/null @@ -1,40 +0,0 @@ -package local - -import ( - "bytes" - "encoding/json" - "fmt" - - syncdomain "github.com/launchdarkly/ldcli/internal/sync" -) - -type file struct { - ProjectKey string - RelPath string - Data []byte -} - -type parser interface { - dir() string - accept(relPath string) bool - parse(file file) (syncdomain.SyncedResource, error) -} - -func defaultParsers() []parser { - return []parser{ - variationParser{}, - toolParser{}, - } -} - -func marshalPayload(value any) (json.RawMessage, error) { - var buf bytes.Buffer - encoder := json.NewEncoder(&buf) - encoder.SetEscapeHTML(false) - - if err := encoder.Encode(value); err != nil { - return nil, fmt.Errorf("marshal payload: %w", err) - } - - return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil -} diff --git a/internal/sync/local/store.go b/internal/sync/local/store.go index 6208e0a1..efbba00a 100644 --- a/internal/sync/local/store.go +++ b/internal/sync/local/store.go @@ -72,7 +72,9 @@ func (s Store) Bootstrap(resources []VariationFile) ([]string, error) { if err != nil { return nil, fmt.Errorf("create bootstrap staging directory: %w", err) } - defer os.RemoveAll(stage) + defer func() { + _ = os.RemoveAll(stage) + }() stagedStore := Store{root: stage} paths, err := stagedStore.createVariations(resources) @@ -238,6 +240,10 @@ func marshalVariationFile(resource VariationFile) ([]byte, error) { return file.Bytes(), nil } +func validMessageRole(role string) bool { + return role == "system" || role == "user" || role == "assistant" +} + func createFile(path string, data []byte) error { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return fmt.Errorf("create variation directory: %w", err) @@ -248,7 +254,9 @@ func createFile(path string, data []byte) error { return fmt.Errorf("stage variation %s: %w", filepath.Base(path), err) } tempPath := temp.Name() - defer os.Remove(tempPath) + defer func() { + _ = os.Remove(tempPath) + }() if err := temp.Chmod(0o644); err != nil { _ = temp.Close() diff --git a/internal/sync/local/store_test.go b/internal/sync/local/store_test.go index c8960f41..f5e6f6e4 100644 --- a/internal/sync/local/store_test.go +++ b/internal/sync/local/store_test.go @@ -27,13 +27,6 @@ func TestStore_BootstrapRoundTripsSupportedModes(t *testing.T) { ModelConfigVersion: 3, Model: map[string]any{"modelName": "claude"}, OutputFormat: map[string]any{"type": "json_schema"}, - Tools: []syncdomain.ToolRef{{ - Key: "lookup", - Version: 2, - CustomParameters: map[string]any{ - "timeout": 5, - }, - }}, Messages: []syncdomain.Message{ {Role: "system", Content: "Be helpful."}, {Role: "user", Content: "Answer the question."}, @@ -50,7 +43,6 @@ func TestStore_BootstrapRoundTripsSupportedModes(t *testing.T) { Name: "Researcher", Description: "Researches a topic.", Instructions: "Check the available sources.", - Skills: []syncdomain.SkillRef{{Key: "research", Version: 4}}, }, }, } @@ -78,10 +70,9 @@ func TestStore_BootstrapRoundTripsSupportedModes(t *testing.T) { require.NoError(t, err) require.Len(t, compiled, len(resources)) for _, local := range resources { - resource := mustResource( + resource := requireVariationResource( t, compiled, - syncdomain.KindVariation, local.ConfigKey+"/"+local.Variation.Key, ) expected, err := marshalPayload(local.Variation) @@ -155,10 +146,10 @@ func TestStore_BootstrapFailureLeavesNoDirectory(t *testing.T) { func TestStore_RejectsUnsupportedMessageRole(t *testing.T) { root := t.TempDir() resource := localVariation("unsupported-role") - resource.Variation.Messages = []syncdomain.Message{{Role: "tool", Content: "result"}} + resource.Variation.Messages = []syncdomain.Message{{Role: "developer", Content: "result"}} _, err := NewStore(root).Bootstrap([]VariationFile{resource}) - require.ErrorContains(t, err, `unsupported message role "tool"`) + require.ErrorContains(t, err, `unsupported message role "developer"`) _, statErr := os.Stat(filepath.Join(root, syncdomain.RootDir)) require.ErrorIs(t, statErr, os.ErrNotExist) } @@ -184,3 +175,21 @@ func localVariation(key string) VariationFile { }, } } + +func requireVariationResource( + t *testing.T, + resources []syncdomain.SyncedResource, + lookupKey string, +) syncdomain.SyncedResource { + t.Helper() + + for _, resource := range resources { + if resource.Kind == syncdomain.KindVariation && resource.LookupKey == lookupKey { + return resource + } + } + + require.FailNow(t, "variation resource not found", lookupKey) + + return syncdomain.SyncedResource{} +} From a6cb8317e77cb88ab4a2dac7a4ed2d5d7bd633ff Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Mon, 14 Sep 2026 15:43:40 -0400 Subject: [PATCH 3/3] test(sync): omit variation descriptions from files --- internal/sync/local/store_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/sync/local/store_test.go b/internal/sync/local/store_test.go index f5e6f6e4..eb042f34 100644 --- a/internal/sync/local/store_test.go +++ b/internal/sync/local/store_test.go @@ -41,7 +41,6 @@ func TestStore_BootstrapRoundTripsSupportedModes(t *testing.T) { Mode: syncdomain.VariationModeAgent, Key: "researcher", Name: "Researcher", - Description: "Researches a topic.", Instructions: "Check the available sources.", }, },