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
82 changes: 76 additions & 6 deletions cmd/sync/attachments.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
resourcescmd "github.com/launchdarkly/ldcli/cmd/resources"
"github.com/launchdarkly/ldcli/internal/resources"
syncdomain "github.com/launchdarkly/ldcli/internal/sync"
synccreate "github.com/launchdarkly/ldcli/internal/sync/create"
syncprompt "github.com/launchdarkly/ldcli/internal/sync/prompt"
)

Expand All @@ -18,6 +19,21 @@ var attachmentExamples = map[syncdomain.AttachmentKind][2]string{
syncdomain.AttachmentSkill: {"summarize", "triage"},
}

// createExamples are the examples of an add that creates a tool or a skill.
var createExamples = map[syncdomain.AttachmentKind]string{
syncdomain.AttachmentTool: ` # Create a tool without prompting
ldcli sync tools add --project production --key refund \
--description "Issue a refund" \
--json-schema '{"type":"object","properties":{"orderId":{"type":"string"}},"required":["orderId"]}' \
--yes --no-input`,
syncdomain.AttachmentSkill: ` # Create a skill from a Markdown file without prompting
ldcli sync skills add --project production --key handoff \
--name "Human handoff" \
--description "Hand a conversation to a person" \
--content "$(cat handoff.md)" \
--yes --no-input`,
}

// newAttachmentScopeCmd creates the command that syncs the tools or the
// skills of the repository, with its watch, add, and detach commands. They
// run the same sync as sync prompts, in the scope of kind.
Expand Down Expand Up @@ -60,35 +76,89 @@ func attachmentScopeLong(kind syncdomain.AttachmentKind) string {
name = " A skill that sync creates from a file takes its name from name: in the file, or from its key."
}
return fmt.Sprintf("Synchronize local %[1]s files with LaunchDarkly. A %[1]s syncs on its own, also when no prompt variation uses it. "+
"To create a %[1]s, %[2]s.%[3]s Sync rechecks state before every write. If the state changed, run sync again.", kind, upsert, name)
"To create a %[1]s, run sync %[1]ss add with %[4]s, or %[2]s.%[3]s "+
"Sync rechecks state before every write. If the state changed, run sync again.", kind, upsert, name, createFlag(kind))
}

// attachmentAddLong is the description of sync tools add or sync skills add.
func attachmentAddLong(kind syncdomain.AttachmentKind) string {
content, skill := "schema", ""
if kind == syncdomain.AttachmentSkill {
content, skill = "content", " --content is the skill Markdown. The name comes from --name, or from the key."
}
return fmt.Sprintf("Add %[1]ss from LaunchDarkly, or create a %[1]s. "+
"Without %[2]s, add starts syncing %[1]ss that exist in LaunchDarkly. "+
"With %[2]s, add creates the %[1]s if it does not exist.%[4]s "+
"If the %[1]s exists with the same %[3]s and description, add starts syncing it. "+
"If they differ, add stops and changes nothing.", kind, createFlag(kind), content, skill)
}

// createFlag is the flag that starts the create of a tool or a skill.
func createFlag(kind syncdomain.AttachmentKind) string {
if kind == syncdomain.AttachmentTool {
return "--" + jsonSchemaFlag
}
return "--" + contentFlag
}

func newAttachmentAddCmd(client resources.Client, kind syncdomain.AttachmentKind) *cobra.Command {
scope := syncdomain.Scope(kind + "s")
keys := attachmentExamples[kind]
cmd := &cobra.Command{
Use: "add",
Short: fmt.Sprintf("Add %ss from LaunchDarkly", kind),
Long: fmt.Sprintf("Add %[1]ss from LaunchDarkly. Add writes the file of each %[1]s and records it in sync.lock and "+
"in the LaunchDarkly manifest. Each %[1]s must exist in LaunchDarkly.", kind),
Short: fmt.Sprintf("Add %ss from LaunchDarkly, or create a %s", kind, kind),
Long: attachmentAddLong(kind),
Example: fmt.Sprintf(` # Choose a project and a %[1]s interactively
ldcli sync %[2]s add

# Add %[1]ss by key without prompting
ldcli sync %[2]s add --project production --key %[3]s --key %[4]s --no-input`, kind, scope, keys[0], keys[1]),
ldcli sync %[2]s add --project production --key %[3]s --key %[4]s --no-input

%[5]s`, kind, scope, keys[0], keys[1], createExamples[kind]),
Args: validatedArgs(cobra.NoArgs),
RunE: runCommand(client, scope, func(cmd *cobra.Command, _ []string) (syncprompt.CommandAction, error) {
dryRun, _ := cmd.Flags().GetBool(dryRunFlag)
return syncprompt.AddAction{Filter: targetFilter(cmd), DryRun: dryRun}, nil
return syncprompt.AddAction{Filter: targetFilter(cmd), DryRun: dryRun, Create: createInput(cmd)}, nil
}),
}
addTargetFlags(cmd, attachmentFlagHelp(kind, false))
if kind == syncdomain.AttachmentTool {
cmd.Flags().String(jsonSchemaFlag, "", "Input JSON Schema of a tool that add creates")
} else {
cmd.Flags().String(contentFlag, "", "Markdown of a skill that add creates")
cmd.Flags().String(nameFlag, "", "Name of a skill that add creates. The default comes from the key.")
}
cmd.Flags().String(descriptionFlag, "", fmt.Sprintf("Description of a %s that add creates", kind))
cmd.Flags().Bool(dryRunFlag, false, "Preview local files without creating them")
cmd.Flags().Bool(diffFlag, false, "Show full diffs and file content in the plan")
cmd.Flags().Bool(yesFlag, false, "Create without confirmation")
cmd.Flags().Bool(noInputFlag, false, "Fail instead of prompting for missing input")
return cmd
}

// createInput reads the create flags of add. It is nil when the command line
// sets none of them, so that add then adds only resources that LaunchDarkly
// has. A flag set to an empty value is set.
func createInput(cmd *cobra.Command) *synccreate.Input {
set := false
value := func(name string) *string {
flag := cmd.Flags().Lookup(name)
if flag == nil || !flag.Changed {
return nil
}
set = true
text := flag.Value.String()
return &text
}
input := synccreate.Input{
Content: value(contentFlag), JSONSchema: value(jsonSchemaFlag), Name: value(nameFlag), Description: value(descriptionFlag),
}
if !set {
return nil
}
return &input
}

func newAttachmentDetachCmd(client resources.Client, kind syncdomain.AttachmentKind) *cobra.Command {
scope := syncdomain.Scope(kind + "s")
cmd := &cobra.Command{
Expand Down
2 changes: 2 additions & 0 deletions cmd/sync/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ const (
configKeyFlag = "config-key"
conflictFlag = "conflict"
contentFlag = "content"
descriptionFlag = "description"
diffFlag = "diff"
dryRunFlag = "dry-run"
formatFlag = "format"
jsonSchemaFlag = "json-schema"
keyFlag = "key"
modelConfigKeyFlag = "model-config-key"
nameFlag = "name"
Expand Down
28 changes: 25 additions & 3 deletions cmd/sync/scope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ var syncCommandFlags = map[string][]string{
},
"sync tools": {"conflict", "diff", "dry-run", "key", "no-input", "project", "yes"},
"sync tools watch": {"conflict", "diff", "key", "no-input", "project", "yes"},
"sync tools add": {"diff", "dry-run", "key", "no-input", "project"},
"sync tools add": {"description", "diff", "dry-run", "json-schema", "key", "no-input", "project", "yes"},
"sync tools detach": {"key", "no-input", "project", "yes"},
"sync skills": {"conflict", "diff", "dry-run", "key", "no-input", "project", "yes"},
"sync skills watch": {"conflict", "diff", "key", "no-input", "project", "yes"},
"sync skills add": {"diff", "dry-run", "key", "no-input", "project"},
"sync skills add": {"content", "description", "diff", "dry-run", "key", "name", "no-input", "project", "yes"},
"sync skills detach": {"key", "no-input", "project", "yes"},
}

Expand Down Expand Up @@ -65,7 +65,9 @@ func TestSyncCommandsReadSyncFlagsOnlyFromTheCommandLine(t *testing.T) {
for _, name := range []string{"archive", "diff", "dry-run", "no-input", "yes"} {
t.Setenv("LD_"+strings.ToUpper(strings.ReplaceAll(name, "-", "_")), "true")
}
for _, name := range []string{"config-key", "conflict", "content", "format", "key", "model-config-key", "name", "resolve", "to"} {
for _, name := range []string{
"config-key", "conflict", "content", "description", "format", "json-schema", "key", "model-config-key", "name", "resolve", "to",
} {
t.Setenv("LD_"+strings.ToUpper(strings.ReplaceAll(name, "-", "_")), "from-environment")
}

Expand All @@ -82,6 +84,26 @@ func TestSyncCommandsReadSyncFlagsOnlyFromTheCommandLine(t *testing.T) {
}
}

// An add creates a resource only when the command line sets a create flag. A
// flag set to an empty value still counts.
func TestAddReadsTheCreateInputFromTheFlagsThatAreSet(t *testing.T) {
root := NewSyncCmd(nil, nil)
tools, _, err := root.Find([]string{"tools", "add"})
require.NoError(t, err)
require.NoError(t, tools.ParseFlags([]string{"--project", "demo"}))
assert.Nil(t, createInput(tools))

skills, _, err := root.Find([]string{"skills", "add"})
require.NoError(t, err)
require.NoError(t, skills.ParseFlags([]string{"--content", "Ask for a person.", "--description", ""}))
input := createInput(skills)
require.NotNil(t, input)
assert.Equal(t, "Ask for a person.", *input.Content)
assert.Equal(t, "", *input.Description)
assert.Nil(t, input.Name)
assert.Nil(t, input.JSONSchema)
}

func TestSyncCommandScopeNamesTheKindOfTheCommand(t *testing.T) {
root := NewSyncCmd(nil, nil)
for path, want := range map[string]string{
Expand Down
17 changes: 16 additions & 1 deletion internal/sync/bootstrap/attachments.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ func readNewAttachments(options Options, selections []syncdomain.ResourceID) ([]
for _, id := range selections {
attachment, err := options.Attachments.ReadAttachment(id.ProjectKey, syncdomain.AttachmentKind(id.Kind), id.LookupKey)
if syncapi.IsNotFound(err) {
return nil, fmt.Errorf("%s does not exist in LaunchDarkly.", id.Label())
return nil, fmt.Errorf("%s does not exist in LaunchDarkly. To create it, run %s with %s.",
id.Label(), AddCommand(id), createFlags[id.Kind])
}
if err != nil {
return nil, err
Expand All @@ -93,6 +94,20 @@ func readNewAttachments(options Options, selections []syncdomain.ResourceID) ([]
return attachments, nil
}

// createFlags names the flags of an add that creates a tool or a skill.
var createFlags = map[syncdomain.Kind]string{
syncdomain.KindTool: "--json-schema, and optionally --description",
syncdomain.KindSkill: "--content, and optionally --name and --description",
}

// AddCommand is the command that starts syncing one resource.
func AddCommand(id syncdomain.ResourceID) string {
if configKey, variationKey, err := id.VariationKeys(); err == nil {
return fmt.Sprintf("ldcli sync prompts add --project %s --config-key %s --key %s", id.ProjectKey, configKey, variationKey)
}
return fmt.Sprintf("ldcli sync %ss add --project %s --key %s", id.Kind, id.ProjectKey, id.LookupKey)
}

// 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.
Expand Down
17 changes: 16 additions & 1 deletion internal/sync/bootstrap/attachments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ func TestRunRefusesAToolBeforeAnyWrite(t *testing.T) {
launchDarkly []syncdomain.Attachment
want string
}{
"missing in LaunchDarkly": {want: "tool project/search does not exist in LaunchDarkly."},
"missing in LaunchDarkly": {
want: "tool project/search does not exist in LaunchDarkly. " +
"To create it, run ldcli sync tools add --project project --key search with --json-schema, and optionally --description.",
},
"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 or ldcli sync all.",
Expand Down Expand Up @@ -105,6 +108,18 @@ func TestRunRefusesAToolBeforeAnyWrite(t *testing.T) {
}
}

func TestRunNamesTheCreateFlagsOfAMissingSkill(t *testing.T) {
id := syncdomain.ResourceID{Kind: syncdomain.KindSkill, ProjectKey: "project", LookupKey: "handoff"}

err := Run(Options{
Kind: syncdomain.KindSkill, Attachments: &fakeAttachments{}, Store: synclocal.NewStore(t.TempDir()),
Baselines: &memoryManifestStore{manifest: syncmanifest.New()}, Output: &bytes.Buffer{}, Selections: []syncdomain.ResourceID{id},
})

require.EqualError(t, err, "skill project/handoff does not exist in LaunchDarkly. "+
"To create it, run ldcli sync skills add --project project --key handoff with --content, and optionally --name and --description.")
}

func TestRunPreviewsToolsAndSkillsInADryRun(t *testing.T) {
root := t.TempDir()
skill := testAttachment(syncdomain.KindSkill)
Expand Down
5 changes: 3 additions & 2 deletions internal/sync/prompt/acceptance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -545,8 +545,9 @@ func TestPromptExplainsUpsertForMissingTool(t *testing.T) {

output, _, err := runPrompt(t, root, api, "--yes")

require.ErrorContains(t, err, `add "upsert": true`)
assert.Contains(t, output, `add "upsert": true`)
fix := `To create it, add "upsert": true to its file and run ldcli sync tools --project production --key my-second-tool.`
require.ErrorContains(t, err, fix)
assert.Contains(t, output, fix)
assert.NotContains(t, err.Error(), "unknown error")
}

Expand Down
10 changes: 1 addition & 9 deletions internal/sync/prompt/attachment_versions.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,16 +137,8 @@ func (resolver *attachmentResolver) publish(projectKey string, local syncdomain.
writeErr = resolver.client.UpdateAttachment(projectKey, local)
case syncapi.IsNotFound(readErr) && local.Upsert:
writeErr = resolver.client.CreateAttachment(projectKey, local)
case syncapi.IsNotFound(readErr) && local.Kind == syncdomain.AttachmentTool:
return syncdomain.Attachment{}, fmt.Errorf(
"tool %q does not exist in LaunchDarkly; add \"upsert\": true to its local JSON file to create it during sync",
local.Key(),
)
case syncapi.IsNotFound(readErr):
return syncdomain.Attachment{}, fmt.Errorf(
"skill %q does not exist in LaunchDarkly; add upsert: true to its front matter to create it during sync",
local.Key(),
)
return syncdomain.Attachment{}, errors.New(missingAttachment(local.ID(projectKey)))
default:
return syncdomain.Attachment{}, readErr
}
Expand Down
22 changes: 17 additions & 5 deletions internal/sync/prompt/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ func (runner Runner) runCreate(options Options, workspace syncWorkspace, action
if created, err = input.Attachment(id); err != nil {
return err
}
return runner.createAttachment(options, workspace, client, syncdomain.SyncedAttachment{ProjectKey: id.ProjectKey, Attachment: created})
return runner.createAttachment(options, workspace, client, syncdomain.SyncedAttachment{
ProjectKey: id.ProjectKey, Attachment: created,
}, input.Name != nil)
}

// adoptAttachment starts syncing a tool or skill that LaunchDarkly already
Expand All @@ -72,7 +74,7 @@ func (runner Runner) adoptAttachment(
) error {
if !action.Create.Matches(created, existing) {
return fmt.Errorf("%s already exists in LaunchDarkly with different content, and add does not change it. "+
"To sync the LaunchDarkly version, run %s, then edit the file and run ldcli sync %ss or ldcli sync all.", id.Label(), addCommand(id), id.Kind)
"To sync the LaunchDarkly version, run %s, then edit the file and run ldcli sync %ss or ldcli sync all.", id.Label(), syncbootstrap.AddCommand(id), id.Kind)
}
adopt := AddAction{Filter: syncdomain.Filter{Project: id.ProjectKey, Keys: []string{id.LookupKey}}, DryRun: action.DryRun}
if err := runner.runAdd(options, workspace, adopt); err != nil {
Expand All @@ -94,18 +96,19 @@ func (runner Runner) adoptAttachment(
// createAttachment creates a tool or skill that LaunchDarkly does not have. It
// plans the create in memory and asks before it writes the file, so that a
// cancel leaves nothing and a watch cannot create the resource before the
// answer.
// answer. named is true when the user chose the name of a skill.
func (runner Runner) createAttachment(
options Options,
workspace syncWorkspace,
client syncapi.Client,
created syncdomain.SyncedAttachment,
named bool,
) error {
path, err := synclocal.AttachmentPath(created.ID())
if err != nil {
return err
}
reviewed := reviewedCreate(options.Scope, created)
reviewed := reviewedCreate(options.Scope, created, named)
if options.dryRun() {
if err := writePlanOutput(options.Output, options.OutputKind, reviewed.plan, options.Diff); err != nil {
return err
Expand All @@ -130,9 +133,15 @@ func (runner Runner) createAttachment(

// reviewedCreate is the state that the user reviews before a create: the
// create row of the new resource, with no file yet.
func reviewedCreate(scope syncdomain.Scope, created syncdomain.SyncedAttachment) workspaceState {
func reviewedCreate(scope syncdomain.Scope, created syncdomain.SyncedAttachment, named bool) workspaceState {
id := created.ID()
row := planAttachment(id, &created.Attachment, nil, "", false)
if id.Kind == syncdomain.KindSkill {
row.nameSource = nameFromKey
if named {
row.nameSource = nameFromFlag
}
}
return workspaceState{projectKeys: []string{id.ProjectKey}, plan: Plan{Scope: scope, Resources: []PlannedResource{row}}}
}

Expand Down Expand Up @@ -167,6 +176,9 @@ func (runner Runner) applyCreate(
if err != nil {
return remove(err)
}
// The new file has the name of the skill, but only the reviewed row knows
// where the name came from.
current.plan.Resources[0].nameSource = reviewed.plan.Resources[0].nameSource

outcomes, err := runner.applyPlan(options, workspace, client, current, nil)
switch {
Expand Down
Loading
Loading