diff --git a/command-snapshot.json b/command-snapshot.json index 768826f..547b53b 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -56,7 +56,18 @@ "command": "agent:generate:authoring-bundle", "flagAliases": [], "flagChars": ["d", "f", "n", "o"], - "flags": ["api-name", "api-version", "flags-dir", "json", "name", "no-spec", "output-dir", "spec", "target-org"], + "flags": [ + "api-name", + "api-version", + "flags-dir", + "force-overwrite", + "json", + "name", + "no-spec", + "output-dir", + "spec", + "target-org" + ], "plugin": "@salesforce/plugin-agent" }, { diff --git a/messages/agent.generate.authoring-bundle.md b/messages/agent.generate.authoring-bundle.md index 55e89f6..13f2764 100644 --- a/messages/agent.generate.authoring-bundle.md +++ b/messages/agent.generate.authoring-bundle.md @@ -124,6 +124,18 @@ Generating authoring bundle: %s Authoring bundle "%s" was generated successfully. +# flags.force-overwrite.summary + +Overwrite the existing authoring bundle if one with the same API name already exists locally. + +# prompt.overwrite + +An authoring bundle with the API name "%s" already exists. Overwrite it? + +# info.cancel + +Canceled authoring bundle generation. + # warning.noSpecDir No agent spec directory found at %s. diff --git a/package.json b/package.json index 518dee8..0354415 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "@inquirer/prompts": "^7.10.1", "@oclif/core": "^4", "@oclif/multi-stage-output": "^0.8.29", - "@salesforce/agents": "^0.22.6", + "@salesforce/agents": "^0.23.0", "@salesforce/core": "^8.25.1", "@salesforce/kit": "^3.2.4", "@salesforce/sf-plugins-core": "^12.2.6", diff --git a/src/commands/agent/generate/authoring-bundle.ts b/src/commands/agent/generate/authoring-bundle.ts index 8d2ef1f..8f97722 100644 --- a/src/commands/agent/generate/authoring-bundle.ts +++ b/src/commands/agent/generate/authoring-bundle.ts @@ -22,6 +22,7 @@ import { AgentJobSpec, ScriptAgent } from '@salesforce/agents'; import YAML from 'yaml'; import { select, input as inquirerInput } from '@inquirer/prompts'; import { theme } from '../../../inquirer-theme.js'; +import yesNoOrCancel from '../../../yes-no-cancel.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-agent', 'agent.generate.authoring-bundle'); @@ -32,6 +33,79 @@ export type AgentGenerateAuthoringBundleResult = { outputDir: string; }; +async function resolveUniqueBundle( + baseOutputDir: string, + forceOverwrite: boolean, + flagName?: string, + flagApiName?: string +): Promise<{ name: string; apiName: string } | undefined> { + const name = + flagName ?? + (await inquirerInput({ + message: messages.getMessage('wizard.name.prompt'), + validate: (d: string): boolean | string => { + if (d.length === 0) { + return messages.getMessage('wizard.name.validation.required'); + } + if (d.trim().length === 0) { + return messages.getMessage('wizard.name.validation.empty'); + } + return true; + }, + theme, + })); + + let apiName = flagApiName ?? ''; + if (!apiName) { + apiName = generateApiName(name); + const promptedValue = await inquirerInput({ + message: messages.getMessage('flags.api-name.prompt'), + validate: (d: string): boolean | string => { + if (d.length === 0) { + return true; + } + if (d.length > 80) { + return 'API name cannot be over 80 characters.'; + } + const regex = /^[A-Za-z][A-Za-z0-9_]*[A-Za-z0-9]+$/; + if (!regex.test(d)) { + return 'Invalid API name.'; + } + return true; + }, + default: apiName, + theme, + }); + if (promptedValue?.length) { + apiName = promptedValue; + } + } + + if (forceOverwrite) { + return { name, apiName }; + } + + const bundleDir = join(baseOutputDir, 'aiAuthoringBundles', apiName); + if (!existsSync(bundleDir)) { + return { name, apiName }; + } + + const confirmation = await yesNoOrCancel({ + message: messages.getMessage('prompt.overwrite', [apiName]), + default: false, + }); + + if (confirmation === 'cancel') { + return undefined; + } + if (confirmation) { + return { name, apiName }; + } + + // User chose "no" — restart from the beginning without flag values + return resolveUniqueBundle(baseOutputDir, false); +} + export default class AgentGenerateAuthoringBundle extends SfCommand { public static readonly summary = messages.getMessage('summary'); public static readonly description = messages.getMessage('description'); @@ -59,6 +133,9 @@ export default class AgentGenerateAuthoringBundle extends SfCommand { @@ -127,54 +204,25 @@ export default class AgentGenerateAuthoringBundle extends SfCommand { - if (d.length === 0) { - return messages.getMessage('wizard.name.validation.required'); - } - if (d.trim().length === 0) { - return messages.getMessage('wizard.name.validation.empty'); - } - return true; - }, - theme, - })); - - // Resolve API name: --api-name flag or auto-generate from name with prompt to confirm - let bundleApiName = flags['api-name']; - if (!bundleApiName) { - bundleApiName = generateApiName(name); - const promptedValue = await inquirerInput({ - message: messages.getMessage('flags.api-name.prompt'), - validate: (d: string): boolean | string => { - if (d.length === 0) { - return true; - } - if (d.length > 80) { - return 'API name cannot be over 80 characters.'; - } - const regex = /^[A-Za-z][A-Za-z0-9_]*[A-Za-z0-9]+$/; - if (!regex.test(d)) { - return 'Invalid API name.'; - } - return true; - }, - default: bundleApiName, - theme, - }); - if (promptedValue?.length) { - bundleApiName = promptedValue; - } + const defaultOutputDir = join(this.project!.getDefaultPackage().fullPath, 'main', 'default'); + const baseOutputDir = outputDir ?? defaultOutputDir; + + const resolved = await resolveUniqueBundle( + baseOutputDir, + flags['force-overwrite'] ?? false, + flags['name'], + flags['api-name'] + ); + + if (!resolved) { + this.log(messages.getMessage('info.cancel')); + return { agentPath: '', metaXmlPath: '', outputDir: '' }; } + const { name, apiName: bundleApiName } = resolved; + try { - // Get default output directory if not specified - const defaultOutputDir = join(this.project!.getDefaultPackage().fullPath, 'main', 'default'); - const targetOutputDir = join(outputDir ?? defaultOutputDir, 'aiAuthoringBundles', bundleApiName); + const targetOutputDir = join(baseOutputDir, 'aiAuthoringBundles', bundleApiName); // Generate file paths const agentPath = join(targetOutputDir, `${bundleApiName}.agent`); diff --git a/test/commands/agent/generate/authoring-bundle.test.ts b/test/commands/agent/generate/authoring-bundle.test.ts index 7ad27c4..b5dfc41 100644 --- a/test/commands/agent/generate/authoring-bundle.test.ts +++ b/test/commands/agent/generate/authoring-bundle.test.ts @@ -44,12 +44,14 @@ describe('agent generate authoring-bundle', () => { let selectStub: sinon.SinonStub; let inputStub: sinon.SinonStub; let createAuthoringBundleStub: sinon.SinonStub; + let yesNoOrCancelStub: sinon.SinonStub; let AgentGenerateAuthoringBundle: any; beforeEach(async () => { selectStub = $$.SANDBOX.stub(); inputStub = $$.SANDBOX.stub(); createAuthoringBundleStub = $$.SANDBOX.stub().resolves(); + yesNoOrCancelStub = $$.SANDBOX.stub(); // Use esmock to replace ESM module imports const mod = await esmock('../../../../src/commands/agent/generate/authoring-bundle.js', { @@ -62,6 +64,9 @@ describe('agent generate authoring-bundle', () => { createAuthoringBundle: createAuthoringBundleStub, }, }, + '../../../../src/yes-no-cancel.js': { + default: yesNoOrCancelStub, + }, }); AgentGenerateAuthoringBundle = mod.default; @@ -353,4 +358,105 @@ describe('agent generate authoring-bundle', () => { } }); }); + + describe('duplicate bundle detection', () => { + // Willie_Resort_Manager already exists in the mock project's aiAuthoringBundles directory + const EXISTING_BUNDLE_API_NAME = 'Willie_Resort_Manager'; + + it('should prompt when bundle with same API name already exists', async () => { + yesNoOrCancelStub.resolves(true); + + const result = await AgentGenerateAuthoringBundle.run([ + '--no-spec', + '--name', + 'Willie Resort Manager', + '--api-name', + EXISTING_BUNDLE_API_NAME, + '--target-org', + 'test@org.com', + ]); + + expect(yesNoOrCancelStub.calledOnce).to.be.true; + const promptCall = yesNoOrCancelStub.firstCall.args[0] as { message: string }; + expect(promptCall.message).to.include(EXISTING_BUNDLE_API_NAME); + expect(createAuthoringBundleStub.calledOnce).to.be.true; + expect(result.agentPath).to.include(`${EXISTING_BUNDLE_API_NAME}.agent`); + }); + + it('should cancel when user chooses cancel on duplicate prompt', async () => { + yesNoOrCancelStub.resolves('cancel'); + + const result = await AgentGenerateAuthoringBundle.run([ + '--no-spec', + '--name', + 'Willie Resort Manager', + '--api-name', + EXISTING_BUNDLE_API_NAME, + '--target-org', + 'test@org.com', + ]); + + expect(yesNoOrCancelStub.calledOnce).to.be.true; + expect(createAuthoringBundleStub.called).to.be.false; + expect(result.agentPath).to.equal(''); + }); + + it('should re-prompt for name and API name when user chooses no on duplicate prompt', async () => { + yesNoOrCancelStub.resolves(false); + // inputStub is called for both name and API name re-prompts + inputStub.onFirstCall().resolves('New Agent'); + inputStub.onSecondCall().resolves('NewAgent'); + + const result = await AgentGenerateAuthoringBundle.run([ + '--no-spec', + '--name', + 'Willie Resort Manager', + '--api-name', + EXISTING_BUNDLE_API_NAME, + '--target-org', + 'test@org.com', + ]); + + expect(yesNoOrCancelStub.calledOnce).to.be.true; + expect(inputStub.calledTwice).to.be.true; + expect(createAuthoringBundleStub.calledOnce).to.be.true; + const callArgs = createAuthoringBundleStub.firstCall.args[0] as CreateAuthoringBundleArgs; + expect(callArgs.bundleApiName).to.equal('NewAgent'); + expect(callArgs.agentSpec.name).to.equal('New Agent'); + expect(result.agentPath).to.include('NewAgent.agent'); + }); + + it('should skip duplicate check with --force-overwrite', async () => { + const result = await AgentGenerateAuthoringBundle.run([ + '--no-spec', + '--name', + 'Willie Resort Manager', + '--api-name', + EXISTING_BUNDLE_API_NAME, + '--force-overwrite', + '--target-org', + 'test@org.com', + ]); + + expect(yesNoOrCancelStub.called).to.be.false; + expect(createAuthoringBundleStub.calledOnce).to.be.true; + expect(result.agentPath).to.include(`${EXISTING_BUNDLE_API_NAME}.agent`); + }); + + it('should not prompt when API name does not exist locally', async () => { + const result = await AgentGenerateAuthoringBundle.run([ + '--no-spec', + '--name', + 'Brand New Agent', + '--api-name', + 'BrandNewAgent', + '--target-org', + 'test@org.com', + ]); + + expect(yesNoOrCancelStub.called).to.be.false; + expect(createAuthoringBundleStub.calledOnce).to.be.true; + expect(result.agentPath).to.include('BrandNewAgent.agent'); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index fc1be02..1996f43 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1743,10 +1743,10 @@ resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== -"@salesforce/agents@^0.22.6": - version "0.22.6" - resolved "https://registry.yarnpkg.com/@salesforce/agents/-/agents-0.22.6.tgz#35ea2ec80b3da6489b6ff18c63a9a7c4f2ca6e96" - integrity sha512-FOJFNVPsJeDEZ9TBk1YL0O0iwovDhXCdk+gw0+Ow1XB2h0eAY4FFOWWzexTXnYKflsVVvA+1lgEeFQS4bwgJSw== +"@salesforce/agents@^0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@salesforce/agents/-/agents-0.23.0.tgz#6e2738ca8cc8d1e29e77f91f93ed98c442184368" + integrity sha512-C2a/ndRk+Vpi8B09e1YbECXbd3N8BRVq1PNW563+I3iNYgls41Rpf2SU2T2Pbl61r6lijI+RXT1YQ4gOrGqi8A== dependencies: "@salesforce/core" "^8.25.1" "@salesforce/kit" "^3.2.4"