Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 27 additions & 17 deletions internal/sync/bootstrap/attachments.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,25 +75,9 @@ func promptForAttachment(options Options) ([]syncdomain.ResourceID, bool, error)
// selection that this repository already syncs, or that LaunchDarkly does not
// have, is an error. The local checks come first, so they need no read.
func readNewAttachments(options Options, selections []syncdomain.ResourceID) ([]syncdomain.SyncedAttachment, error) {
lock, err := syncmanifest.ReadLock(options.Store)
if err != nil {
if err := CheckNotSynced(options.Store, selections); err != nil {
return nil, err
}
for _, id := range selections {
exists, err := options.Store.AttachmentExists(id)
switch {
case err != nil:
return nil, err
case exists:
path, err := synclocal.AttachmentPath(id)
if err != nil {
return nil, err
}
return nil, fmt.Errorf("%s is already synced. Edit %s and run ldcli sync %ss.", id.Label(), path, id.Kind)
case slices.ContainsFunc(lock.Resources, func(resource syncmanifest.Resource) bool { return resource.ID() == id }):
return nil, fmt.Errorf("%s has a sync.lock entry but no local file. Run ldcli sync %ss to restore it.", id.Label(), id.Kind)
}
}

attachments := make([]syncdomain.SyncedAttachment, 0, len(selections))
for _, id := range selections {
Expand All @@ -109,6 +93,32 @@ func readNewAttachments(options Options, selections []syncdomain.ResourceID) ([]
return attachments, nil
}

// CheckNotSynced returns an error for a tool or skill that this repository
// already syncs, because it has a local file or a sync.lock entry. It reads
// only local files.
func CheckNotSynced(store synclocal.Store, ids []syncdomain.ResourceID) error {
lock, err := syncmanifest.ReadLock(store)
if err != nil {
return err
}
for _, id := range ids {
exists, err := store.AttachmentExists(id)
switch {
case err != nil:
return err
case exists:
path, err := synclocal.AttachmentPath(id)
if err != nil {
return err
}
return fmt.Errorf("%s is already synced. Edit %s and run ldcli sync %ss or ldcli sync all.", id.Label(), path, id.Kind)
case slices.ContainsFunc(lock.Resources, func(resource syncmanifest.Resource) bool { return resource.ID() == id }):
return fmt.Errorf("%s has a sync.lock entry but no local file. Run ldcli sync %ss or ldcli sync all to restore it.", id.Label(), id.Kind)
}
}
return nil
}

// writeAttachments writes the files and records their baseline. If the
// baseline save fails, it removes the files again.
func writeAttachments(options Options, attachments []syncdomain.SyncedAttachment) error {
Expand Down
4 changes: 2 additions & 2 deletions internal/sync/bootstrap/attachments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,11 @@ func TestRunRefusesAToolBeforeAnyWrite(t *testing.T) {
"missing in LaunchDarkly": {want: "tool project/search does not exist in LaunchDarkly."},
"already synced": {
local: true, launchDarkly: []syncdomain.Attachment{search},
want: "tool project/search is already synced. Edit .launchdarkly/project/tools/search.json and run ldcli sync tools.",
want: "tool project/search is already synced. Edit .launchdarkly/project/tools/search.json and run ldcli sync tools or ldcli sync all.",
},
"lock entry without a file": {
tracked: true, launchDarkly: []syncdomain.Attachment{search},
want: "tool project/search has a sync.lock entry but no local file. Run ldcli sync tools to restore it.",
want: "tool project/search has a sync.lock entry but no local file. Run ldcli sync tools or ldcli sync all to restore it.",
},
}

Expand Down
194 changes: 194 additions & 0 deletions internal/sync/create/attachment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package create

import (
"encoding/json"
"errors"
"fmt"
"io"
"slices"
"strings"

"github.com/charmbracelet/huh"

syncdomain "github.com/launchdarkly/ldcli/internal/sync"
syncinteractive "github.com/launchdarkly/ldcli/internal/sync/interactive"
)

// definitionKeys are top-level keys of a tool file or of an MCP tool
// definition. A --json-schema value with one of them is a pasted definition,
// not an input schema.
var definitionKeys = []string{"formatVersion", "inputSchema", "schema"}

// Check applies the content rules of a create of a tool or a skill. It reads
// nothing, so an add runs it before any request.
func (input Input) Check(kind syncdomain.AttachmentKind) error {
if err := input.checkContentFlag(kind); err != nil {
return err
}
if kind == syncdomain.AttachmentTool {
_, err := parseSchema(*input.JSONSchema)
return err
}
switch {
case strings.TrimSpace(*input.Content) == "":
return errors.New("--content cannot be blank")
case input.Name != nil && strings.TrimSpace(*input.Name) == "":
return errors.New("--name cannot be blank")
}
return nil
}

// checkContentFlag makes sure that the flag that starts a create is set when
// another create flag is.
func (input Input) checkContentFlag(kind syncdomain.AttachmentKind) error {
flag, content := "--content", input.Content
if kind == syncdomain.AttachmentTool {
flag, content = "--json-schema", input.JSONSchema
}
if content != nil {
return nil
}

var passed []string
if input.Name != nil {
passed = append(passed, "--name")
}
if input.Description != nil {
passed = append(passed, "--description")
}
switch len(passed) {
case 0:
return fmt.Errorf("add creates a %s only when %s is set", kind, flag)
case 1:
return fmt.Errorf("%s needs %s. add creates a %s only when %s is set.", passed[0], flag, kind, flag)
default:
return fmt.Errorf("%s need %s. add creates a %s only when %s is set.", joinFlags(passed), flag, kind, flag)
}
}

// parseSchema decodes the value of --json-schema, which must be one JSON
// object.
func parseSchema(value string) (map[string]any, error) {
decoder := json.NewDecoder(strings.NewReader(value))
var decoded any
if err := decoder.Decode(&decoded); err != nil {
return nil, fmt.Errorf("--json-schema must be one JSON object: %w", err)
}
schema, isObject := decoded.(map[string]any)
if !isObject || decoder.Decode(new(any)) != io.EOF {
return nil, errors.New("--json-schema must be one JSON object")
}
if slices.ContainsFunc(definitionKeys, func(key string) bool { _, ok := schema[key]; return ok }) {
return nil, errors.New(`--json-schema is the input schema only. Pass the value of "inputSchema", ` +
"and pass the description with --description.")
}
return schema, nil
}

// Ask returns the input with the name and the description of a new skill
// that the flags do not give. A tool has no questions. Without questions, the
// input does not change, so the name comes from the key and the description
// is empty. An answer that keeps the name of the key does not set a name. The
// bool result is true when the user cancels.
func (input Input) Ask(id syncdomain.ResourceID, questions Questions) (Input, bool, error) {
if id.Kind != syncdomain.KindSkill || !questions.Allowed {
return input, false, nil
}

keyName := syncdomain.DisplayName(id.LookupKey)
name, description := keyName, ""
var fields []huh.Field
if input.Name == nil {
fields = append(fields, huh.NewInput().Title("Skill name").Value(&name).Validate(RequiredValue("skill name")))
}
if input.Description == nil {
fields = append(fields, huh.NewInput().Title("Skill description").Value(&description))
}
if len(fields) == 0 {
return input, false, nil
}
if canceled, err := syncinteractive.RunForm(questions.Input, questions.Output, fields...); err != nil || canceled {
return input, canceled, err
}

if input.Name == nil && name != keyName {
input.Name = &name
}
if input.Description == nil {
input.Description = &description
}
return input, false, nil
}

// Attachment returns the tool or the skill that the input creates. It has
// upsert, so that its file can create it again. A skill without a name takes
// the name of its key, and its Markdown ends with a newline, so that content
// from "$(cat file)" has the fingerprint of the file.
func (input Input) Attachment(id syncdomain.ResourceID) (syncdomain.Attachment, error) {
kind := syncdomain.AttachmentKind(id.Kind)
if err := input.Check(kind); err != nil {
return syncdomain.Attachment{}, err
}

attachment := syncdomain.Attachment{Kind: kind, Upsert: true}
var name, description string
if input.Description != nil {
description = *input.Description
}
if kind == syncdomain.AttachmentTool {
schema, err := parseSchema(*input.JSONSchema)
if err != nil {
return syncdomain.Attachment{}, err
}
attachment.Tool = &syncdomain.Tool{Key: id.LookupKey, Schema: schema}
if description != "" {
attachment.Tool.Description = &description
}
} else {
skill := syncdomain.Skill{Key: id.LookupKey, Description: description, Markdown: withFinalNewline(*input.Content)}
if input.Name != nil {
skill.Name = *input.Name
}
skill.Name = skill.CreateName()
name, attachment.Skill = skill.Name, &skill
}
if err := syncdomain.ValidateCreate(id.Kind, id.LookupKey, name, description); err != nil {
return syncdomain.Attachment{}, err
}
return attachment, nil
}

// Matches reports whether LaunchDarkly already has the value of each flag
// that the user passed: the schema or the Markdown, and the description when
// it is set. created is the attachment of the input. The fields without a
// flag, such as tool tags and the name of a skill, come from LaunchDarkly.
func (input Input) Matches(created, existing syncdomain.Attachment) bool {
candidate := existing
switch {
case created.Tool != nil && existing.Tool != nil:
tool := *existing.Tool
tool.Schema = created.Tool.Schema
if input.Description != nil {
tool.Description = created.Tool.Description
}
candidate.Tool = &tool
case created.Skill != nil && existing.Skill != nil:
skill := *existing.Skill
skill.Markdown = created.Skill.Markdown
if input.Description != nil {
skill.Description = created.Skill.Description
}
candidate.Skill = &skill
default:
return false
}
return syncdomain.SameAttachmentContent(candidate, existing)
}

// withFinalNewline returns text that ends with a newline.
func withFinalNewline(text string) string {
if strings.HasSuffix(text, "\n") {
return text
}
return text + "\n"
}
116 changes: 116 additions & 0 deletions internal/sync/create/create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Package create collects and checks a resource that an add command creates
// from flags. It checks the flags before any read, and asks in a terminal for
// each value that the flags do not give.
package create

import (
"fmt"
"io"
"strings"

"github.com/charmbracelet/huh"

syncdomain "github.com/launchdarkly/ldcli/internal/sync"
syncinteractive "github.com/launchdarkly/ldcli/internal/sync/interactive"
)

// Input is the content that the create flags of an add give. A nil field is a
// flag that the command line did not set.
type Input struct {
// Content is the Markdown of a skill.
Content *string
// JSONSchema is the input schema of a tool.
JSONSchema *string
// Name is the name of a skill.
Name *string
Description *string
}

// Questions are the streams that ask the user for the values that the flags
// do not give. Allowed is false when the command cannot ask, because of
// --no-input or because the streams are not a terminal.
type Questions struct {
Allowed bool
Input io.Reader
Output io.Writer
Projects syncinteractive.ProjectSearcher
}

// Target returns the identity of the new resource. An empty projectKey or key
// comes from a question. Without questions, the error names every missing
// flag. The bool result is true when the user cancels.
func Target(kind syncdomain.Kind, projectKey, key string, questions Questions) (syncdomain.ResourceID, bool, error) {
id := syncdomain.ResourceID{Kind: kind, ProjectKey: projectKey, LookupKey: key}
var missing []string
if projectKey == "" {
missing = append(missing, "--project")
}
if key == "" {
missing = append(missing, "--key")
}
switch {
case len(missing) == 0:
return id, false, nil
case !questions.Allowed:
return id, false, fmt.Errorf("%s creation requires %s without interactive input", kind, joinFlags(missing))
}

if id.ProjectKey == "" {
project, canceled, err := syncinteractive.SelectProject(questions.Input, questions.Output, questions.Projects)
if err != nil || canceled {
return id, canceled, err
}
id.ProjectKey = project.Key
}
if id.LookupKey == "" {
field := huh.NewInput().Title(capitalize(string(kind)) + " key").Value(&id.LookupKey).Validate(keyValidator(kind))
if canceled, err := syncinteractive.RunForm(questions.Input, questions.Output, field); err != nil || canceled {
return id, canceled, err
}
}
return id, false, nil
}

// keyValidator returns a form validator that applies the create rules of
// LaunchDarkly to a new key of kind.
func keyValidator(kind syncdomain.Kind) func(string) error {
return func(key string) error {
if err := syncdomain.ValidateKey(key); err != nil {
return fmt.Errorf("%s key %w", kind, err)
}
return syncdomain.ValidateCreate(kind, key, "", "")
}
}

// RequiredValue returns a form validator that rejects a blank value.
func RequiredValue(label string) func(string) error {
return func(value string) error {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s is required", label)
}
return nil
}
}

// joinFlags joins flag names into a list, for example "--project and --key".
func joinFlags(flags []string) string {
last := len(flags) - 1
switch {
case last < 0:
return ""
case last == 0:
return flags[0]
case last == 1:
return flags[0] + " and " + flags[1]
default:
return strings.Join(flags[:last], ", ") + ", and " + flags[last]
}
}

// capitalize returns text with an uppercase first letter.
func capitalize(text string) string {
if text == "" {
return ""
}
return strings.ToUpper(text[:1]) + text[1:]
}
Loading
Loading