diff --git a/src/commands/package/bundle/version/create.ts b/src/commands/package/bundle/version/create.ts index abefd1c6..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, @@ -30,6 +33,69 @@ Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); 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,14 @@ 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) { @@ -160,7 +271,6 @@ export class PackageBundlesCreate extends SfCommand { ); } }); + + it('should normalize 15-character package version IDs to 18-character format', async () => { + // 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-')); + 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(); + + expect(createStub.callCount).to.equal(1); + 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'); + // 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 () => { + // 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-')); + 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(); + + 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'); + } 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 }); + } + }); }); });