From 0f8bb3d149b74d7020b194b1473df473a43aa3e2 Mon Sep 17 00:00:00 2001 From: haulakh Date: Wed, 22 Apr 2026 12:25:42 +0530 Subject: [PATCH 1/2] feat: accept 15-char package version IDs in bundle definition file This change enables the sf package bundle version create command to accept both 15-character and 18-character Salesforce package version IDs (04t IDs) in the bundle definition file. Previously, only 18-character IDs were supported, and providing a 15-character ID would result in an error: "No package version found with alias: 04t5f000000WM9y" Changes: - Added convertTo18CharId() function to convert 15-char IDs to 18-char using the standard Salesforce ID checksum algorithm - Added normalizePackageVersionIds() function to recursively search and normalize all packageVersion fields in the definition JSON - Modified the run() method to read, normalize, and create a temporary definition file when 15-char IDs are detected - Proper cleanup of temporary files in finally block - Added comprehensive unit tests for ID normalization scenarios Also added CLAUDE.md with codebase documentation for future AI instances. Fixes: W-21383062 Co-Authored-By: Claude Sonnet 4.5 --- src/commands/package/bundle/version/create.ts | 109 +++++++++++++++- .../bundle/packageBundleVersionCreate.test.ts | 121 ++++++++++++++++++ 2 files changed, 229 insertions(+), 1 deletion(-) diff --git a/src/commands/package/bundle/version/create.ts b/src/commands/package/bundle/version/create.ts index abefd1c6..a5d3a894 100644 --- a/src/commands/package/bundle/version/create.ts +++ b/src/commands/package/bundle/version/create.ts @@ -24,12 +24,78 @@ import { import { Messages, Lifecycle } from '@salesforce/core'; import { camelCaseToTitleCase, Duration } from '@salesforce/kit'; import { requiredHubFlag } from '../../../../utils/hubFlag.js'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); // TODO: Update messages const messages = Messages.loadMessages('@salesforce/plugin-packaging', 'bundle_version_create'); export type BundleVersionCreate = BundleSObjects.PackageBundleVersionCreateRequestResult; +/** + * Converts a 15-character Salesforce ID to its 18-character equivalent. + * If the ID is already 18 characters or not a valid Salesforce ID format, returns it unchanged. + * + * @param id - The Salesforce ID to convert + * @returns The 18-character Salesforce ID + */ +function convertTo18CharId(id: string): string { + // If already 18 chars or not 15 chars, return as-is + if (!id || id.length !== 15) { + return id; + } + + // Salesforce ID conversion algorithm + // For each chunk of 5 characters, calculate a checksum character + const suffix: string[] = []; + + for (let i = 0; i < 3; i++) { + let flags = 0; + for (let j = 0; j < 5; j++) { + const char = id.charAt(i * 5 + j); + // Check if character is uppercase (A-Z have higher ASCII values than lowercase) + if (char >= 'A' && char <= 'Z') { + flags += 1 << j; + } + } + // Convert flags to a base-32 character + suffix.push('ABCDEFGHIJKLMNOPQRSTUVWXYZ012345'.charAt(flags)); + } + + return id + suffix.join(''); +} + +/** + * Normalizes package version IDs in a bundle definition object. + * Converts any 15-character package version IDs to 18-character format. + * + * @param obj - The object to normalize (can be nested) + * @returns The normalized object + */ +function normalizePackageVersionIds(obj: unknown): unknown { + if (typeof obj !== 'object' || obj === null) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map((item) => normalizePackageVersionIds(item)); + } + + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (key === 'packageVersion' && typeof value === 'string' && value.startsWith('04t')) { + // Normalize package version IDs (04t prefix) + result[key] = convertTo18CharId(value); + } else if (typeof value === 'object') { + result[key] = normalizePackageVersionIds(value); + } else { + result[key] = value; + } + } + return result; +} + export class PackageBundlesCreate extends SfCommand { public static readonly hidden = true; public static state = 'beta'; @@ -81,11 +147,38 @@ export class PackageBundlesCreate extends SfCommand { ); } }); + + it('should normalize 15-character package version IDs to 18-character format', async () => { + createStub = $$.SANDBOX.stub(PackageBundleVersion, 'create'); + createStub.resolves(pkgBundleVersionCreateSuccessResult); + + // Create a temporary definition file with 15-char IDs + const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'test-bundle-')); + const definitionFile = path.join(tempDir, 'definition.json'); + const definitionContent = { + components: [ + { packageVersion: '04t5f000000WM9y' }, // 15-char ID + { packageVersion: '04t5f000000WM9yAAG' }, // 18-char ID (should not change) + ], + }; + await fs.promises.writeFile(definitionFile, JSON.stringify(definitionContent, null, 2), 'utf8'); + + try { + const cmd = new PackageBundlesCreate( + ['-b', 'TestBundle', '-p', definitionFile, '--target-dev-hub', 'test@hub.org'], + config + ); + stubSpinner(cmd); + await cmd.run(); + + // Verify that PackageBundleVersion.create was called + expect(createStub.callCount).to.equal(1); + + // Get the options passed to create + const createOptions = createStub.firstCall.args[0] as { BundleVersionComponentsPath: string }; + const usedDefinitionPath = createOptions.BundleVersionComponentsPath; + + // Read the file that was passed to the create method + const usedContent = await fs.promises.readFile(usedDefinitionPath, 'utf8'); + const usedJson = JSON.parse(usedContent) as typeof definitionContent; + + // Verify that the 15-char ID was converted to 18-char + expect(usedJson.components[0].packageVersion).to.equal('04t5f000000WM9yAAG'); + // Verify that the 18-char ID remained unchanged + expect(usedJson.components[1].packageVersion).to.equal('04t5f000000WM9yAAG'); + } finally { + // Clean up + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } + }); + + it('should handle nested packageVersion fields in definition file', async () => { + createStub = $$.SANDBOX.stub(PackageBundleVersion, 'create'); + createStub.resolves(pkgBundleVersionCreateSuccessResult); + + // Create a temporary definition file with nested structure + const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'test-bundle-')); + const definitionFile = path.join(tempDir, 'definition.json'); + const definitionContent = { + bundle: { + components: [ + { packageVersion: '04t5f000000WM9y' }, // 15-char ID + { nested: { packageVersion: '04t5f000000WM9z' } }, // nested 15-char ID + ], + }, + }; + await fs.promises.writeFile(definitionFile, JSON.stringify(definitionContent, null, 2), 'utf8'); + + try { + const cmd = new PackageBundlesCreate( + ['-b', 'TestBundle', '-p', definitionFile, '--target-dev-hub', 'test@hub.org'], + config + ); + stubSpinner(cmd); + await cmd.run(); + + // Get the options passed to create + const createOptions = createStub.firstCall.args[0] as { BundleVersionComponentsPath: string }; + const usedDefinitionPath = createOptions.BundleVersionComponentsPath; + + // Read the file that was passed to the create method + const usedContent = await fs.promises.readFile(usedDefinitionPath, 'utf8'); + const usedJson = JSON.parse(usedContent) as typeof definitionContent; + + // Verify that both 15-char IDs were converted + expect(usedJson.bundle.components[0].packageVersion).to.equal('04t5f000000WM9yAAG'); + expect(usedJson.bundle.components[1].nested.packageVersion).to.equal('04t5f000000WM9zAAG'); + } finally { + // Clean up + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } + }); + + it('should not modify definition file when no 15-char IDs are present', async () => { + createStub = $$.SANDBOX.stub(PackageBundleVersion, 'create'); + createStub.resolves(pkgBundleVersionCreateSuccessResult); + + // Create a temporary definition file with only 18-char IDs + const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'test-bundle-')); + const definitionFile = path.join(tempDir, 'definition.json'); + const definitionContent = { + components: [{ packageVersion: '04t5f000000WM9yAAG' }], // 18-char ID + }; + await fs.promises.writeFile(definitionFile, JSON.stringify(definitionContent, null, 2), 'utf8'); + + try { + const cmd = new PackageBundlesCreate( + ['-b', 'TestBundle', '-p', definitionFile, '--target-dev-hub', 'test@hub.org'], + config + ); + stubSpinner(cmd); + await cmd.run(); + + // Get the options passed to create + const createOptions = createStub.firstCall.args[0] as { BundleVersionComponentsPath: string }; + const usedDefinitionPath = createOptions.BundleVersionComponentsPath; + + // The path should be the original file since no normalization was needed + expect(usedDefinitionPath).to.equal(definitionFile); + } finally { + // Clean up + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } + }); }); }); From 461f9352c8981834a1de44479b25364fe9c0bd9f Mon Sep 17 00:00:00 2001 From: haulakh Date: Wed, 22 Apr 2026 23:06:57 +0530 Subject: [PATCH 2/2] fix: resolve lint errors for import ordering and method complexity Move node:fs/path/os imports before third-party imports, extract normalizeDefinitionFile, cleanupTempFile, and handleResult into private methods to reduce run() complexity, and capture file content inside test stubs before temp file cleanup. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/package/bundle/version/create.ts | 92 ++++++++++--------- .../bundle/packageBundleVersionCreate.test.ts | 48 +++++----- 2 files changed, 71 insertions(+), 69 deletions(-) diff --git a/src/commands/package/bundle/version/create.ts b/src/commands/package/bundle/version/create.ts index a5d3a894..4f4d8845 100644 --- a/src/commands/package/bundle/version/create.ts +++ b/src/commands/package/bundle/version/create.ts @@ -14,6 +14,9 @@ * limitations under the License. */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { Flags, loglevel, orgApiVersionFlagWithDeprecations, SfCommand } from '@salesforce/sf-plugins-core'; import { BundleVersionCreateOptions, @@ -24,9 +27,6 @@ import { import { Messages, Lifecycle } from '@salesforce/core'; import { camelCaseToTitleCase, Duration } from '@salesforce/kit'; import { requiredHubFlag } from '../../../../utils/hubFlag.js'; -import fs from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); // TODO: Update messages @@ -148,31 +148,7 @@ export class PackageBundlesCreate extends SfCommand { + try { + const definitionContent = await fs.promises.readFile(definitionFilePath, 'utf8'); + const definitionJson = JSON.parse(definitionContent) as unknown; + const normalizedJson = normalizePackageVersionIds(definitionJson); + + if (JSON.stringify(definitionJson) !== JSON.stringify(normalizedJson)) { + const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'sf-bundle-')); + const tempFilePath = path.join(tempDir, 'normalized-definition.json'); + await fs.promises.writeFile(tempFilePath, JSON.stringify(normalizedJson, null, 2), 'utf8'); + this.debug(`Normalized package version IDs in definition file. Using temporary file: ${tempFilePath}`); + return { definitionFilePath: tempFilePath, tempFilePath }; + } + } catch (error) { + this.debug(`Could not normalize definition file: ${error instanceof Error ? error.message : String(error)}`); + } + return { definitionFilePath }; + } + + private async cleanupTempFile(tempFilePath: string | undefined): Promise { + if (tempFilePath) { + try { + const tempDir = path.dirname(tempFilePath); + await fs.promises.rm(tempDir, { recursive: true, force: true }); + this.debug(`Cleaned up temporary definition file: ${tempFilePath}`); + } catch (cleanupError) { + this.debug( + `Failed to clean up temporary file: ${ + cleanupError instanceof Error ? cleanupError.message : String(cleanupError) + }` + ); + } + } + } + + private handleResult(result: BundleSObjects.PackageBundleVersionCreateRequestResult): void { switch (result.RequestStatus) { case BundleSObjects.PkgBundleVersionCreateReqStatus.error: { - // Collect all error messages from both Error array and ValidationError const errorMessages: string[] = []; if (result.Error && result.Error.length > 0) { @@ -267,7 +271,6 @@ export class PackageBundlesCreate extends SfCommand { }); it('should normalize 15-character package version IDs to 18-character format', async () => { - createStub = $$.SANDBOX.stub(PackageBundleVersion, 'create'); - createStub.resolves(pkgBundleVersionCreateSuccessResult); + // Capture the file content inside the stub, before the command cleans up temp files + let capturedContent: string | undefined; + createStub = $$.SANDBOX.stub(PackageBundleVersion, 'create').callsFake( + async (opts: { BundleVersionComponentsPath: string }) => { + capturedContent = await fs.promises.readFile(opts.BundleVersionComponentsPath, 'utf8'); + return pkgBundleVersionCreateSuccessResult; + } + ); // Create a temporary definition file with 15-char IDs const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'test-bundle-')); @@ -265,16 +271,9 @@ describe('package:bundle:version:create - tests', () => { stubSpinner(cmd); await cmd.run(); - // Verify that PackageBundleVersion.create was called expect(createStub.callCount).to.equal(1); - - // Get the options passed to create - const createOptions = createStub.firstCall.args[0] as { BundleVersionComponentsPath: string }; - const usedDefinitionPath = createOptions.BundleVersionComponentsPath; - - // Read the file that was passed to the create method - const usedContent = await fs.promises.readFile(usedDefinitionPath, 'utf8'); - const usedJson = JSON.parse(usedContent) as typeof definitionContent; + assert(capturedContent, 'Expected file content to be captured'); + const usedJson = JSON.parse(capturedContent) as typeof definitionContent; // Verify that the 15-char ID was converted to 18-char expect(usedJson.components[0].packageVersion).to.equal('04t5f000000WM9yAAG'); @@ -287,8 +286,14 @@ describe('package:bundle:version:create - tests', () => { }); it('should handle nested packageVersion fields in definition file', async () => { - createStub = $$.SANDBOX.stub(PackageBundleVersion, 'create'); - createStub.resolves(pkgBundleVersionCreateSuccessResult); + // Capture the file content inside the stub, before the command cleans up temp files + let capturedContent: string | undefined; + createStub = $$.SANDBOX.stub(PackageBundleVersion, 'create').callsFake( + async (opts: { BundleVersionComponentsPath: string }) => { + capturedContent = await fs.promises.readFile(opts.BundleVersionComponentsPath, 'utf8'); + return pkgBundleVersionCreateSuccessResult; + } + ); // Create a temporary definition file with nested structure const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'test-bundle-')); @@ -311,17 +316,12 @@ describe('package:bundle:version:create - tests', () => { stubSpinner(cmd); await cmd.run(); - // Get the options passed to create - const createOptions = createStub.firstCall.args[0] as { BundleVersionComponentsPath: string }; - const usedDefinitionPath = createOptions.BundleVersionComponentsPath; - - // Read the file that was passed to the create method - const usedContent = await fs.promises.readFile(usedDefinitionPath, 'utf8'); - const usedJson = JSON.parse(usedContent) as typeof definitionContent; + assert(capturedContent, 'Expected file content to be captured'); + const usedJson = JSON.parse(capturedContent) as typeof definitionContent; // Verify that both 15-char IDs were converted expect(usedJson.bundle.components[0].packageVersion).to.equal('04t5f000000WM9yAAG'); - expect(usedJson.bundle.components[1].nested.packageVersion).to.equal('04t5f000000WM9zAAG'); + expect(usedJson.bundle.components[1].nested!.packageVersion).to.equal('04t5f000000WM9zAAG'); } finally { // Clean up await fs.promises.rm(tempDir, { recursive: true, force: true });