diff --git a/cmd/sync/attachments.go b/cmd/sync/attachments.go index f95407e2..9e8b706a 100644 --- a/cmd/sync/attachments.go +++ b/cmd/sync/attachments.go @@ -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" ) @@ -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. @@ -60,7 +76,29 @@ 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 { @@ -68,27 +106,59 @@ func newAttachmentAddCmd(client resources.Client, kind syncdomain.AttachmentKind 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{ diff --git a/cmd/sync/prompt.go b/cmd/sync/prompt.go index 0346db2a..a7d6b037 100644 --- a/cmd/sync/prompt.go +++ b/cmd/sync/prompt.go @@ -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" diff --git a/cmd/sync/scope_test.go b/cmd/sync/scope_test.go index c17b358f..c2b036c2 100644 --- a/cmd/sync/scope_test.go +++ b/cmd/sync/scope_test.go @@ -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"}, } @@ -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") } @@ -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{ diff --git a/internal/sync/bootstrap/attachments.go b/internal/sync/bootstrap/attachments.go index 95738220..80aafaa9 100644 --- a/internal/sync/bootstrap/attachments.go +++ b/internal/sync/bootstrap/attachments.go @@ -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 @@ -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. diff --git a/internal/sync/bootstrap/attachments_test.go b/internal/sync/bootstrap/attachments_test.go index 603b7761..da4ff104 100644 --- a/internal/sync/bootstrap/attachments_test.go +++ b/internal/sync/bootstrap/attachments_test.go @@ -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.", @@ -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) diff --git a/internal/sync/prompt/acceptance_test.go b/internal/sync/prompt/acceptance_test.go index 539c6eef..c0b77f97 100644 --- a/internal/sync/prompt/acceptance_test.go +++ b/internal/sync/prompt/acceptance_test.go @@ -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") } diff --git a/internal/sync/prompt/attachment_versions.go b/internal/sync/prompt/attachment_versions.go index 0bebd829..37c264d5 100644 --- a/internal/sync/prompt/attachment_versions.go +++ b/internal/sync/prompt/attachment_versions.go @@ -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 } diff --git a/internal/sync/prompt/create.go b/internal/sync/prompt/create.go index f768a39b..128691f4 100644 --- a/internal/sync/prompt/create.go +++ b/internal/sync/prompt/create.go @@ -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 @@ -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 { @@ -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 @@ -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}}} } @@ -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 { diff --git a/internal/sync/prompt/create_test.go b/internal/sync/prompt/create_test.go index 31f53747..68e0bb7f 100644 --- a/internal/sync/prompt/create_test.go +++ b/internal/sync/prompt/create_test.go @@ -61,11 +61,12 @@ func TestCreateMakesAToolFromFlags(t *testing.T) { func TestCreateMakesASkillFromFlags(t *testing.T) { tests := map[string]struct { - name *string - wantName string + name *string + wantName string + wantSource string }{ - "name from the key": {wantName: "Incident triage"}, - "name from --name": {name: pointer("Triage"), wantName: "Triage"}, + "name from the key": {wantName: "Incident triage", wantSource: "key"}, + "name from --name": {name: pointer("Triage"), wantName: "Triage", wantSource: "flag"}, } for name, test := range tests { t.Run(name, func(t *testing.T) { @@ -79,8 +80,9 @@ func TestCreateMakesASkillFromFlags(t *testing.T) { stdout, notes, err := runScoped(t, root, api, syncprompt.Options{Scope: syncdomain.ScopeSkills, Action: action, Yes: true}) require.NoError(t, err) - assert.JSONEq(t, `{"resources": [{"resourceKind": "skill", "projectKey": "production", - "lookupKey": "incident-triage", "action": "create_server", "status": "succeeded", "version": 1}]}`, stdout) + assert.JSONEq(t, fmt.Sprintf(`{"resources": [{"resourceKind": "skill", "projectKey": "production", + "lookupKey": "incident-triage", "action": "create_server", "status": "succeeded", "version": 1, + "create": {"name": %q, "nameSource": %q}}]}`, test.wantName, test.wantSource), stdout) assert.Equal(t, "Note: Created .launchdarkly/production/skills/incident-triage.md.\n", notes) assert.Equal(t, "---\nkey: incident-triage\ndescription: Sort incidents\nname: "+test.wantName+"\nupsert: true\n---\n\n"+ "Sort incidents by severity.\n", readLocalFile(t, root, "skills/incident-triage.md")) @@ -95,6 +97,31 @@ func TestCreateMakesASkillFromFlags(t *testing.T) { } } +// A sync that creates a skill from a hand-written file reports the name in +// the same way as add. +func TestSyncReportsTheNameOfASkillThatItCreatesFromAFile(t *testing.T) { + tests := map[string]struct { + frontMatter string + want string + }{ + "name in the file": {frontMatter: "name: Human handoff\n", want: `{"name": "Human handoff", "nameSource": "frontMatter"}`}, + "name from the key": {want: `{"name": "Handoff", "nameSource": "key"}`}, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + root := initRepository(t) + writeLocalFile(t, root, "skills/handoff.md", + "---\nkey: handoff\ndescription: Hand off\n"+test.frontMatter+"upsert: true\n---\n\nAsk for a person.\n") + + stdout, err := runSyncAction(t, root, &directAPI{}, syncdomain.ScopeSkills, syncprompt.SyncAction{}, true) + + require.NoError(t, err) + assert.JSONEq(t, `{"resources": [{"resourceKind": "skill", "projectKey": "production", "lookupKey": "handoff", + "action": "create_server", "status": "succeeded", "version": 1, "create": `+test.want+`}]}`, stdout) + }) + } +} + func TestCreateLeavesOtherPendingChangesAsTheyAre(t *testing.T) { // The workspace has a tool file with upsert that LaunchDarkly does not // have, and a local edit of a synced tool. @@ -209,7 +236,8 @@ func TestAddWithoutAContentFlagNeverCreates(t *testing.T) { _, _, err := runScoped(t, root, api, syncprompt.Options{Scope: syncdomain.ScopeTools, Action: action, Yes: true}) - require.EqualError(t, err, "tool production/refund does not exist in LaunchDarkly.") + require.EqualError(t, err, "tool production/refund does not exist in LaunchDarkly. "+ + "To create it, run ldcli sync tools add --project production --key refund with --json-schema, and optionally --description.") requireOnlyReads(t, api.requests) assert.NoDirExists(t, filepath.Join(root, syncdomain.RootDir)) } diff --git a/internal/sync/prompt/execute_test.go b/internal/sync/prompt/execute_test.go index 66865331..7ea90169 100644 --- a/internal/sync/prompt/execute_test.go +++ b/internal/sync/prompt/execute_test.go @@ -341,13 +341,31 @@ func TestPublishReadsAgainAfterACreateConflict(t *testing.T) { } } -func TestPublishCreatesASkillOnlyWithUpsert(t *testing.T) { - skill := syncdomain.Attachment{Kind: syncdomain.AttachmentSkill, Skill: &syncdomain.Skill{Key: "handoff", Markdown: "# Handoff\n"}} - client := syncapi.NewClient(&createConflictAPI{}, "token", "https://example.com") +func TestPublishCreatesAnAttachmentOnlyWithUpsert(t *testing.T) { + tests := map[string]struct { + attachment syncdomain.Attachment + want string + }{ + "tool": { + attachment: syncdomain.Attachment{Kind: syncdomain.AttachmentTool, Tool: &syncdomain.Tool{Key: "refund", Schema: map[string]any{}}}, + want: `tool "refund" does not exist in LaunchDarkly. ` + + `To create it, add "upsert": true to its file and run ldcli sync tools --project project --key refund.`, + }, + "skill": { + attachment: syncdomain.Attachment{Kind: syncdomain.AttachmentSkill, Skill: &syncdomain.Skill{Key: "handoff", Markdown: "# Handoff\n"}}, + want: `skill "handoff" does not exist in LaunchDarkly. ` + + "To create it, add upsert: true to its front matter and run ldcli sync skills --project project --key handoff.", + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + client := syncapi.NewClient(&createConflictAPI{}, "token", "https://example.com") - _, err := newAttachmentResolver(client).resolve("project", skill, false) + _, err := newAttachmentResolver(client).resolve("project", test.attachment, false) - require.EqualError(t, err, `skill "handoff" does not exist in LaunchDarkly; add upsert: true to its front matter to create it during sync`) + require.EqualError(t, err, test.want) + }) + } } func TestApplyServerChangeReadsAgainAfterAVariationCreateConflict(t *testing.T) { diff --git a/internal/sync/prompt/output.go b/internal/sync/prompt/output.go index 20786661..40a45c1d 100644 --- a/internal/sync/prompt/output.go +++ b/internal/sync/prompt/output.go @@ -99,22 +99,25 @@ func writeOutcomeOutput(out io.Writer, outputKind string, plan Plan, outcomes [] if err != nil { return err } + planned := make(map[ResourceID]PlannedResource, len(plan.Resources)) + for _, resource := range plan.Resources { + planned[resource.ID] = resource + } if outputKind == outputJSON { type outcomeOutput struct { resourceOutput ResourceOutcome + Create *skillCreateOutput `json:"create,omitempty"` } resources := make([]outcomeOutput, 0, len(outcomes)) for _, outcome := range outcomes { - resources = append(resources, outcomeOutput{resourceOutput: newResourceOutput(outcome.ID), ResourceOutcome: outcome}) + resources = append(resources, outcomeOutput{ + resourceOutput: newResourceOutput(outcome.ID), ResourceOutcome: outcome, Create: skillCreate(planned[outcome.ID]), + }) } return writeJSON(out, map[string]any{"resources": resources}) } - planned := make(map[ResourceID]PlannedResource, len(plan.Resources)) - for _, resource := range plan.Resources { - planned[resource.ID] = resource - } var rows []reviewRow var failures []ResourceOutcome inSync := 0 @@ -145,6 +148,22 @@ func writeOutcomeOutput(out io.Writer, outputKind string, plan Plan, outcomes [] return nil } +// skillCreateOutput is the name that sync sends when it creates a skill, and +// where the name comes from. +type skillCreateOutput struct { + Name string `json:"name"` + NameSource string `json:"nameSource"` +} + +// skillCreate returns the name of the skill that a row creates, or nil for a +// row that creates no skill. +func skillCreate(resource PlannedResource) *skillCreateOutput { + if resource.Action != ActionCreateServer || resource.LocalAttachment == nil || resource.LocalAttachment.Skill == nil { + return nil + } + return &skillCreateOutput{Name: resource.LocalAttachment.Skill.CreateName(), NameSource: resource.nameSource} +} + // checkOutputKind returns the output kind. An empty kind is plain text. func checkOutputKind(outputKind string) (string, error) { switch outputKind { diff --git a/internal/sync/prompt/plan.go b/internal/sync/prompt/plan.go index 0520ff89..db9e9aee 100644 --- a/internal/sync/prompt/plan.go +++ b/internal/sync/prompt/plan.go @@ -78,10 +78,20 @@ type PlannedResource struct { conflictUses []conflictUse // usedBy are the local variations that use the tool or skill of this row. usedBy []ResourceID + // nameSource is where the name of a local skill comes from: a name in its + // file, its key, or a flag of the add that creates it. + nameSource string // restoreRef is the link that a restored variation file keeps. restoreRef *syncdomain.Reference } +// The sources of the name of a skill that sync creates. +const ( + nameFromFlag = "flag" + nameFromFrontMatter = "frontMatter" + nameFromKey = "key" +) + // hasLocal reports whether the resource has a local file. func (resource PlannedResource) hasLocal() bool { return resource.Local != nil || resource.LocalAttachment != nil @@ -288,6 +298,12 @@ func planAttachment( return fail(err.Error()) } } + if local != nil && local.Skill != nil { + resource.nameSource = nameFromKey + if local.Skill.Name != "" { + resource.nameSource = nameFromFrontMatter + } + } if server != nil { if resource.ServerFingerprint, err = syncdomain.FingerprintAttachment(id.ProjectKey, *server); err != nil { return fail(fmt.Sprintf("invalid server %s: %s", id.Kind, err)) @@ -311,12 +327,18 @@ func planAttachment( // missingAttachmentError explains how to create a tool or skill that exists // only in a local file, or how to stop syncing it. func missingAttachmentError(id ResourceID) string { + return fmt.Sprintf("%s To stop syncing it, run ldcli sync %ss detach --project %s --key %s.", + missingAttachment(id), id.Kind, id.ProjectKey, id.LookupKey) +} + +// missingAttachment says that LaunchDarkly does not have the tool or skill +// of a local file without upsert, and names the command that creates it. +func missingAttachment(id ResourceID) string { consent := `add "upsert": true to its file` if id.Kind == syncdomain.KindSkill { consent = "add upsert: true to its front matter" } - return fmt.Sprintf("%s %q does not exist in LaunchDarkly. To create it during sync, %s. "+ - "To stop syncing it, run ldcli sync %ss detach --project %s --key %s.", + return fmt.Sprintf("%s %q does not exist in LaunchDarkly. To create it, %s and run ldcli sync %ss --project %s --key %s.", id.Kind, id.LookupKey, consent, id.Kind, id.ProjectKey, id.LookupKey) } diff --git a/internal/sync/prompt/plan_test.go b/internal/sync/prompt/plan_test.go index b931d001..675752b6 100644 --- a/internal/sync/prompt/plan_test.go +++ b/internal/sync/prompt/plan_test.go @@ -291,10 +291,12 @@ func TestPlanAttachmentIgnoresTheSkillName(t *testing.T) { func TestPlanAttachmentExplainsHowToCreateOrStopSyncing(t *testing.T) { tool, skill := testAttachment(syncdomain.AttachmentTool, "local"), testAttachment(syncdomain.AttachmentSkill, "local") - assert.Equal(t, `tool "search" does not exist in LaunchDarkly. To create it during sync, add "upsert": true to its file. `+ + assert.Equal(t, `tool "search" does not exist in LaunchDarkly. `+ + `To create it, add "upsert": true to its file and run ldcli sync tools --project production --key search. `+ "To stop syncing it, run ldcli sync tools detach --project production --key search.", planAttachment(tool.ID("production"), &tool, nil, "", false).Error) - assert.Equal(t, `skill "summarize" does not exist in LaunchDarkly. To create it during sync, add upsert: true to its front matter. `+ + assert.Equal(t, `skill "summarize" does not exist in LaunchDarkly. `+ + "To create it, add upsert: true to its front matter and run ldcli sync skills --project production --key summarize. "+ "To stop syncing it, run ldcli sync skills detach --project production --key summarize.", planAttachment(skill.ID("production"), &skill, nil, "", false).Error) } diff --git a/internal/sync/prompt/state.go b/internal/sync/prompt/state.go index cf4ae4c9..45519b45 100644 --- a/internal/sync/prompt/state.go +++ b/internal/sync/prompt/state.go @@ -8,6 +8,7 @@ import ( syncdomain "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" syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" syncreference "github.com/launchdarkly/ldcli/internal/sync/reference" @@ -551,7 +552,7 @@ func checkFilter(scope syncdomain.Scope, filter syncdomain.Filter, synced []Reso } for _, id := range filter.IDs(scope.KeyKind()) { if !slices.Contains(synced, id) { - return fmt.Errorf("%s is not synced in this repository. To start syncing it, run %s.", id.Label(), addCommand(id)) + return fmt.Errorf("%s is not synced in this repository. To start syncing it, run %s.", id.Label(), syncbootstrap.AddCommand(id)) } } if !slices.ContainsFunc(synced, filter.Includes) { @@ -562,14 +563,6 @@ func checkFilter(scope syncdomain.Scope, filter syncdomain.Filter, synced []Reso return nil } -// addCommand is the command that starts syncing one resource. -func addCommand(id 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) -} - // compileWorkspace reads the local variations. A workspace without a managed // directory has no variations. func compileWorkspace(repositoryRoot string) ([]syncdomain.SyncedResource, error) {