Skip to content

Commit 0f8bb3d

Browse files
sf-haulakhclaude
andcommitted
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 <noreply@anthropic.com>
1 parent ba27757 commit 0f8bb3d

2 files changed

Lines changed: 229 additions & 1 deletion

File tree

src/commands/package/bundle/version/create.ts

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,78 @@ import {
2424
import { Messages, Lifecycle } from '@salesforce/core';
2525
import { camelCaseToTitleCase, Duration } from '@salesforce/kit';
2626
import { requiredHubFlag } from '../../../../utils/hubFlag.js';
27+
import fs from 'node:fs';
28+
import path from 'node:path';
29+
import os from 'node:os';
2730

2831
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
2932
// TODO: Update messages
3033
const messages = Messages.loadMessages('@salesforce/plugin-packaging', 'bundle_version_create');
3134
export type BundleVersionCreate = BundleSObjects.PackageBundleVersionCreateRequestResult;
3235

36+
/**
37+
* Converts a 15-character Salesforce ID to its 18-character equivalent.
38+
* If the ID is already 18 characters or not a valid Salesforce ID format, returns it unchanged.
39+
*
40+
* @param id - The Salesforce ID to convert
41+
* @returns The 18-character Salesforce ID
42+
*/
43+
function convertTo18CharId(id: string): string {
44+
// If already 18 chars or not 15 chars, return as-is
45+
if (!id || id.length !== 15) {
46+
return id;
47+
}
48+
49+
// Salesforce ID conversion algorithm
50+
// For each chunk of 5 characters, calculate a checksum character
51+
const suffix: string[] = [];
52+
53+
for (let i = 0; i < 3; i++) {
54+
let flags = 0;
55+
for (let j = 0; j < 5; j++) {
56+
const char = id.charAt(i * 5 + j);
57+
// Check if character is uppercase (A-Z have higher ASCII values than lowercase)
58+
if (char >= 'A' && char <= 'Z') {
59+
flags += 1 << j;
60+
}
61+
}
62+
// Convert flags to a base-32 character
63+
suffix.push('ABCDEFGHIJKLMNOPQRSTUVWXYZ012345'.charAt(flags));
64+
}
65+
66+
return id + suffix.join('');
67+
}
68+
69+
/**
70+
* Normalizes package version IDs in a bundle definition object.
71+
* Converts any 15-character package version IDs to 18-character format.
72+
*
73+
* @param obj - The object to normalize (can be nested)
74+
* @returns The normalized object
75+
*/
76+
function normalizePackageVersionIds(obj: unknown): unknown {
77+
if (typeof obj !== 'object' || obj === null) {
78+
return obj;
79+
}
80+
81+
if (Array.isArray(obj)) {
82+
return obj.map((item) => normalizePackageVersionIds(item));
83+
}
84+
85+
const result: Record<string, unknown> = {};
86+
for (const [key, value] of Object.entries(obj)) {
87+
if (key === 'packageVersion' && typeof value === 'string' && value.startsWith('04t')) {
88+
// Normalize package version IDs (04t prefix)
89+
result[key] = convertTo18CharId(value);
90+
} else if (typeof value === 'object') {
91+
result[key] = normalizePackageVersionIds(value);
92+
} else {
93+
result[key] = value;
94+
}
95+
}
96+
return result;
97+
}
98+
3399
export class PackageBundlesCreate extends SfCommand<BundleSObjects.PackageBundleVersionCreateRequestResult> {
34100
public static readonly hidden = true;
35101
public static state = 'beta';
@@ -81,11 +147,38 @@ export class PackageBundlesCreate extends SfCommand<BundleSObjects.PackageBundle
81147
minorVersion = versionParts[1] || '';
82148
}
83149

150+
// Read and normalize the definition file to handle 15-char package version IDs
151+
let definitionFilePath = flags['definition-file'];
152+
let tempFilePath: string | undefined;
153+
154+
try {
155+
// Read the definition file
156+
const definitionContent = await fs.promises.readFile(definitionFilePath, 'utf8');
157+
const definitionJson = JSON.parse(definitionContent) as unknown;
158+
159+
// Normalize any 15-character package version IDs to 18-character format
160+
const normalizedJson = normalizePackageVersionIds(definitionJson);
161+
162+
// Check if any normalization occurred by comparing stringified versions
163+
if (JSON.stringify(definitionJson) !== JSON.stringify(normalizedJson)) {
164+
// Create a temporary file with normalized content
165+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'sf-bundle-'));
166+
tempFilePath = path.join(tempDir, 'normalized-definition.json');
167+
await fs.promises.writeFile(tempFilePath, JSON.stringify(normalizedJson, null, 2), 'utf8');
168+
definitionFilePath = tempFilePath;
169+
this.debug(`Normalized package version IDs in definition file. Using temporary file: ${tempFilePath}`);
170+
}
171+
} catch (error) {
172+
// If reading/parsing fails, let the packaging library handle the error
173+
// This preserves the original error messages for invalid JSON, missing files, etc.
174+
this.debug(`Could not normalize definition file: ${error instanceof Error ? error.message : String(error)}`);
175+
}
176+
84177
const options: BundleVersionCreateOptions = {
85178
connection: flags['target-dev-hub'].getConnection(flags['api-version']),
86179
project: this.project!,
87180
PackageBundle: flags.bundle,
88-
BundleVersionComponentsPath: flags['definition-file'],
181+
BundleVersionComponentsPath: definitionFilePath,
89182
Description: flags.description,
90183
MajorVersion: majorVersion,
91184
MinorVersion: minorVersion,
@@ -134,6 +227,20 @@ export class PackageBundlesCreate extends SfCommand<BundleSObjects.PackageBundle
134227
this.spinner.stop();
135228
}
136229
throw error;
230+
} finally {
231+
// Clean up temporary file if it was created
232+
if (tempFilePath) {
233+
try {
234+
const tempDir = path.dirname(tempFilePath);
235+
await fs.promises.rm(tempDir, { recursive: true, force: true });
236+
this.debug(`Cleaned up temporary definition file: ${tempFilePath}`);
237+
} catch (cleanupError) {
238+
// Log but don't fail if cleanup fails
239+
this.debug(
240+
`Failed to clean up temporary file: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`
241+
);
242+
}
243+
}
137244
}
138245

139246
// Stop spinner only if it was started - stop it cleanly without a message

test/commands/bundle/packageBundleVersionCreate.test.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ import { PackageBundleVersion, BundleSObjects } from '@salesforce/packaging';
2020
import sinon from 'sinon';
2121
import { SfCommand } from '@salesforce/sf-plugins-core';
2222
import { PackageBundlesCreate } from '../../../src/commands/package/bundle/version/create.js';
23+
import fs from 'node:fs';
24+
import path from 'node:path';
25+
import os from 'node:os';
2326

2427
const pkgBundleVersionCreateErrorResult: BundleSObjects.PackageBundleVersionCreateRequestResult = {
2528
Id: '08c3i000000fylXXXX',
@@ -238,5 +241,123 @@ describe('package:bundle:version:create - tests', () => {
238241
);
239242
}
240243
});
244+
245+
it('should normalize 15-character package version IDs to 18-character format', async () => {
246+
createStub = $$.SANDBOX.stub(PackageBundleVersion, 'create');
247+
createStub.resolves(pkgBundleVersionCreateSuccessResult);
248+
249+
// Create a temporary definition file with 15-char IDs
250+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'test-bundle-'));
251+
const definitionFile = path.join(tempDir, 'definition.json');
252+
const definitionContent = {
253+
components: [
254+
{ packageVersion: '04t5f000000WM9y' }, // 15-char ID
255+
{ packageVersion: '04t5f000000WM9yAAG' }, // 18-char ID (should not change)
256+
],
257+
};
258+
await fs.promises.writeFile(definitionFile, JSON.stringify(definitionContent, null, 2), 'utf8');
259+
260+
try {
261+
const cmd = new PackageBundlesCreate(
262+
['-b', 'TestBundle', '-p', definitionFile, '--target-dev-hub', 'test@hub.org'],
263+
config
264+
);
265+
stubSpinner(cmd);
266+
await cmd.run();
267+
268+
// Verify that PackageBundleVersion.create was called
269+
expect(createStub.callCount).to.equal(1);
270+
271+
// Get the options passed to create
272+
const createOptions = createStub.firstCall.args[0] as { BundleVersionComponentsPath: string };
273+
const usedDefinitionPath = createOptions.BundleVersionComponentsPath;
274+
275+
// Read the file that was passed to the create method
276+
const usedContent = await fs.promises.readFile(usedDefinitionPath, 'utf8');
277+
const usedJson = JSON.parse(usedContent) as typeof definitionContent;
278+
279+
// Verify that the 15-char ID was converted to 18-char
280+
expect(usedJson.components[0].packageVersion).to.equal('04t5f000000WM9yAAG');
281+
// Verify that the 18-char ID remained unchanged
282+
expect(usedJson.components[1].packageVersion).to.equal('04t5f000000WM9yAAG');
283+
} finally {
284+
// Clean up
285+
await fs.promises.rm(tempDir, { recursive: true, force: true });
286+
}
287+
});
288+
289+
it('should handle nested packageVersion fields in definition file', async () => {
290+
createStub = $$.SANDBOX.stub(PackageBundleVersion, 'create');
291+
createStub.resolves(pkgBundleVersionCreateSuccessResult);
292+
293+
// Create a temporary definition file with nested structure
294+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'test-bundle-'));
295+
const definitionFile = path.join(tempDir, 'definition.json');
296+
const definitionContent = {
297+
bundle: {
298+
components: [
299+
{ packageVersion: '04t5f000000WM9y' }, // 15-char ID
300+
{ nested: { packageVersion: '04t5f000000WM9z' } }, // nested 15-char ID
301+
],
302+
},
303+
};
304+
await fs.promises.writeFile(definitionFile, JSON.stringify(definitionContent, null, 2), 'utf8');
305+
306+
try {
307+
const cmd = new PackageBundlesCreate(
308+
['-b', 'TestBundle', '-p', definitionFile, '--target-dev-hub', 'test@hub.org'],
309+
config
310+
);
311+
stubSpinner(cmd);
312+
await cmd.run();
313+
314+
// Get the options passed to create
315+
const createOptions = createStub.firstCall.args[0] as { BundleVersionComponentsPath: string };
316+
const usedDefinitionPath = createOptions.BundleVersionComponentsPath;
317+
318+
// Read the file that was passed to the create method
319+
const usedContent = await fs.promises.readFile(usedDefinitionPath, 'utf8');
320+
const usedJson = JSON.parse(usedContent) as typeof definitionContent;
321+
322+
// Verify that both 15-char IDs were converted
323+
expect(usedJson.bundle.components[0].packageVersion).to.equal('04t5f000000WM9yAAG');
324+
expect(usedJson.bundle.components[1].nested.packageVersion).to.equal('04t5f000000WM9zAAG');
325+
} finally {
326+
// Clean up
327+
await fs.promises.rm(tempDir, { recursive: true, force: true });
328+
}
329+
});
330+
331+
it('should not modify definition file when no 15-char IDs are present', async () => {
332+
createStub = $$.SANDBOX.stub(PackageBundleVersion, 'create');
333+
createStub.resolves(pkgBundleVersionCreateSuccessResult);
334+
335+
// Create a temporary definition file with only 18-char IDs
336+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'test-bundle-'));
337+
const definitionFile = path.join(tempDir, 'definition.json');
338+
const definitionContent = {
339+
components: [{ packageVersion: '04t5f000000WM9yAAG' }], // 18-char ID
340+
};
341+
await fs.promises.writeFile(definitionFile, JSON.stringify(definitionContent, null, 2), 'utf8');
342+
343+
try {
344+
const cmd = new PackageBundlesCreate(
345+
['-b', 'TestBundle', '-p', definitionFile, '--target-dev-hub', 'test@hub.org'],
346+
config
347+
);
348+
stubSpinner(cmd);
349+
await cmd.run();
350+
351+
// Get the options passed to create
352+
const createOptions = createStub.firstCall.args[0] as { BundleVersionComponentsPath: string };
353+
const usedDefinitionPath = createOptions.BundleVersionComponentsPath;
354+
355+
// The path should be the original file since no normalization was needed
356+
expect(usedDefinitionPath).to.equal(definitionFile);
357+
} finally {
358+
// Clean up
359+
await fs.promises.rm(tempDir, { recursive: true, force: true });
360+
}
361+
});
241362
});
242363
});

0 commit comments

Comments
 (0)