Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 114 additions & 5 deletions src/commands/package/bundle/version/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string, unknown> = {};
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<BundleSObjects.PackageBundleVersionCreateRequestResult> {
public static readonly hidden = true;
public static state = 'beta';
Expand Down Expand Up @@ -81,11 +147,14 @@ export class PackageBundlesCreate extends SfCommand<BundleSObjects.PackageBundle
minorVersion = versionParts[1] || '';
}

// Read and normalize the definition file to handle 15-char package version IDs
const { definitionFilePath, tempFilePath } = await this.normalizeDefinitionFile(flags['definition-file']);

const options: BundleVersionCreateOptions = {
connection: flags['target-dev-hub'].getConnection(flags['api-version']),
project: this.project!,
PackageBundle: flags.bundle,
BundleVersionComponentsPath: flags['definition-file'],
BundleVersionComponentsPath: definitionFilePath,
Description: flags.description,
MajorVersion: majorVersion,
MinorVersion: minorVersion,
Expand Down Expand Up @@ -134,16 +203,58 @@ export class PackageBundlesCreate extends SfCommand<BundleSObjects.PackageBundle
this.spinner.stop();
}
throw error;
} finally {
await this.cleanupTempFile(tempFilePath);
}

// Stop spinner only if it was started - stop it cleanly without a message
if (isSpinnerRunning) {
this.spinner.stop();
}

this.handleResult(result);
return result;
}

private async normalizeDefinitionFile(
definitionFilePath: string
): Promise<{ definitionFilePath: string; tempFilePath?: string }> {
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<void> {
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) {
Expand All @@ -160,14 +271,12 @@ export class PackageBundlesCreate extends SfCommand<BundleSObjects.PackageBundle
throw messages.createError('multipleErrors', [errorText]);
}
case BundleSObjects.PkgBundleVersionCreateReqStatus.success: {
// Show the PackageBundleVersionId (1Q8) if available, otherwise show the request ID
const displayId = result.PackageBundleVersionId || result.Id;
this.log(`Successfully created bundle version with ID ${displayId}`);
break;
}
default:
this.log(messages.getMessage('InProgress', [camelCaseToTitleCase(result.RequestStatus as string), result.Id]));
}
return result;
}
}
121 changes: 121 additions & 0 deletions test/commands/bundle/packageBundleVersionCreate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { MockTestOrgData, TestContext } from '@salesforce/core/testSetup';
import { Config } from '@oclif/core';
import { assert, expect } from 'chai';
Expand Down Expand Up @@ -238,5 +241,123 @@ describe('package:bundle:version:create - tests', () => {
);
}
});

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 });
}
});
});
});
Loading