From d81e9e38e9735c95ee1be77c61a12162ec2eb979 Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Fri, 13 Feb 2026 18:35:50 +0100 Subject: [PATCH 1/4] fix: validate unique API name before generating authoring bundle Prevent overwriting existing local agent script bundles by checking if an authoring bundle with the same API name already exists on disk. Prompts the user to overwrite, enter a new name, or cancel. Adds --force-overwrite flag to skip the check in non-interactive usage. --- messages/agent.generate.authoring-bundle.md | 12 ++ .../agent/generate/authoring-bundle.ts | 138 ++++++++++++------ .../agent/generate/authoring-bundle.test.ts | 106 ++++++++++++++ 3 files changed, 211 insertions(+), 45 deletions(-) diff --git a/messages/agent.generate.authoring-bundle.md b/messages/agent.generate.authoring-bundle.md index 55e89f6..23b5141 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 locally. Overwrite it? + +# info.cancel + +Canceled authoring bundle generation. + # warning.noSpecDir No agent spec directory found at %s. 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'); + }); + }); }); From 207c53e2a88639b86e06d8423672b9e18c5a7dcf Mon Sep 17 00:00:00 2001 From: Marcelino Llano Date: Fri, 13 Feb 2026 21:37:26 +0100 Subject: [PATCH 2/4] fix: update overwrite prompt copy and hint display --- messages/agent.generate.authoring-bundle.md | 2 +- src/yes-no-cancel.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/messages/agent.generate.authoring-bundle.md b/messages/agent.generate.authoring-bundle.md index 23b5141..13f2764 100644 --- a/messages/agent.generate.authoring-bundle.md +++ b/messages/agent.generate.authoring-bundle.md @@ -130,7 +130,7 @@ Overwrite the existing authoring bundle if one with the same API name already ex # prompt.overwrite -An authoring bundle with the API name "%s" already exists locally. Overwrite it? +An authoring bundle with the API name "%s" already exists. Overwrite it? # info.cancel diff --git a/src/yes-no-cancel.ts b/src/yes-no-cancel.ts index c4588c6..3ac5e4b 100644 --- a/src/yes-no-cancel.ts +++ b/src/yes-no-cancel.ts @@ -79,7 +79,7 @@ const yesNoOrCancel: ReturnType Date: Fri, 13 Feb 2026 21:41:19 +0100 Subject: [PATCH 3/4] fix: update command snapshot and revert yes-no-cancel hint --- command-snapshot.json | 13 ++++++++++++- src/yes-no-cancel.ts | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) 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/src/yes-no-cancel.ts b/src/yes-no-cancel.ts index 3ac5e4b..c4588c6 100644 --- a/src/yes-no-cancel.ts +++ b/src/yes-no-cancel.ts @@ -79,7 +79,7 @@ const yesNoOrCancel: ReturnType Date: Tue, 17 Feb 2026 11:07:51 -0700 Subject: [PATCH 4/4] chore: bump agents --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) 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/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"