diff --git a/messages/package.md b/messages/package.md index 976e30af9..c11f44f2e 100644 --- a/messages/package.md +++ b/messages/package.md @@ -85,3 +85,11 @@ Provide a valid subscriber package version (04t) for the recommended version. # unassociatedRecommendedVersionError The provided recommended version isn't associated with this package. + +# distributionTypeApiPriorTo68Error + +Setting a distribution type is only possible with API version 68.0 or higher. + +# invalidDistributionTypeError + +Invalid distribution type "%s". When setting a distribution type from the CLI, the value must be either "PublicSecure" or "Limited". diff --git a/package.json b/package.json index 8b137593f..969d47d1d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@salesforce/packaging", - "version": "5.0.9-spi.1", + "version": "5.0.9-spi.7", "description": "Packaging library for the Salesforce packaging platform", "main": "lib/exported", "types": "lib/exported.d.ts", diff --git a/src/interfaces/packagingInterfacesAndType.ts b/src/interfaces/packagingInterfacesAndType.ts index 552ec1a44..720e1cb3f 100644 --- a/src/interfaces/packagingInterfacesAndType.ts +++ b/src/interfaces/packagingInterfacesAndType.ts @@ -63,6 +63,7 @@ export type PackageUpdateOptions = { PackageErrorUsername?: string; AppAnalyticsEnabled?: boolean; RecommendedVersionId?: string; + DistributionType?: SettableDistributionType; }; export type PackageAuthorizationOptions = { @@ -335,6 +336,25 @@ export type Package1Display = { export type PackageType = 'Managed' | 'Unlocked'; +/** + * The distribution type of a package, controlling install-time security. + * + * The Tooling API `Package2.DistributionType` field can hold any of these values, but only + * `PublicSecure` and `Limited` may be set from the CLI on create/update — `Public` is a backend-only + * state (the legacy default the backend assigns). See {@link SettableDistributionType} for the + * CLI-settable subset. + */ +export type DistributionType = 'Public' | 'PublicSecure' | 'Limited'; + +/** The subset of {@link DistributionType} values a user may set via the CLI on create and update. */ +export type SettableDistributionType = Extract; + +/** The distribution type values a user may set via the CLI on create and update. */ +export const SETTABLE_DISTRIBUTION_TYPES: readonly SettableDistributionType[] = ['PublicSecure', 'Limited']; + +/** Minimum Tooling API version that supports the `Package2.DistributionType` field. */ +export const DISTRIBUTION_TYPE_MIN_API_VERSION = '68.0'; + export type PackageCreateOptions = { name: string; description: string; @@ -343,6 +363,7 @@ export type PackageCreateOptions = { packageType: PackageType; errorNotificationUsername: string; path: string; + distributionType?: SettableDistributionType; }; export type PackageDescriptorJson = Partial & diff --git a/src/interfaces/packagingSObjects.ts b/src/interfaces/packagingSObjects.ts index 44394f15d..0b55e74d2 100644 --- a/src/interfaces/packagingSObjects.ts +++ b/src/interfaces/packagingSObjects.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { Nullable } from '@salesforce/ts-types'; -import { CodeCoverage, CodeCoveragePercentages, PackageType } from './packagingInterfacesAndType'; +import { CodeCoverage, CodeCoveragePercentages, DistributionType, PackageType } from './packagingInterfacesAndType'; export namespace PackagingSObjects { export type Package2 = { @@ -36,6 +36,7 @@ export namespace PackagingSObjects { PackageErrorUsername: string; AppAnalyticsEnabled?: boolean; RecommendedVersionId?: string; + DistributionType?: DistributionType; }; export type Package2Version = { diff --git a/src/package/package.ts b/src/package/package.ts index 5714a5015..da5d5e7f9 100644 --- a/src/package/package.ts +++ b/src/package/package.ts @@ -18,6 +18,7 @@ import { Connection, Messages, SfError, SfProject } from '@salesforce/core'; import { DirectedGraph } from 'graphology'; import { ConvertPackageOptions, + DISTRIBUTION_TYPE_MIN_API_VERSION, PackageCreateOptions, PackageOptions, PackageSaveResult, @@ -31,7 +32,7 @@ import { PackagingSObjects, } from '../interfaces'; import { applyErrorAction, BY_LABEL, massageErrorMessage, validateId } from '../utils/packageUtils'; -import { createPackage } from './packageCreate'; +import { createPackage, validateDistributionType } from './packageCreate'; import { convertPackage } from './packageConvert'; import { retrievePackageVersionMetadata } from './packageVersionRetrieve'; import { listPackageVersions } from './packageVersionList'; @@ -71,6 +72,7 @@ export const Package2Fields = [ 'PackageErrorUsername', 'AppAnalyticsEnabled', 'RecommendedVersionId', + 'DistributionType', ]; /** @@ -252,9 +254,11 @@ export class Package { private static getPackage2Fields(connection: Connection): string[] { const apiVersion = connection.getApiVersion(); - return Package2Fields.filter((field) => (apiVersion >= '59.0' ? true : field !== 'AppAnalyticsEnabled')).filter( - (field) => (apiVersion >= '66.0' ? true : field !== 'RecommendedVersionId') - ); + return Package2Fields.filter((field) => (apiVersion >= '59.0' ? true : field !== 'AppAnalyticsEnabled')) + .filter((field) => (apiVersion >= '66.0' ? true : field !== 'RecommendedVersionId')) + .filter((field) => + apiVersion >= DISTRIBUTION_TYPE_MIN_API_VERSION ? true : field !== 'DistributionType' + ); } /** @@ -343,6 +347,10 @@ export class Package { throw messages.createError('recommendedVersionIdApiPriorTo66Error'); } + // Validates the API version and that the value is CLI-settable (PublicSecure/Limited). + // The backend enforces the allowed state transitions between distribution types on update. + validateDistributionType(this.options.connection, opts.DistributionType); + if (opts.RecommendedVersionId !== undefined) { const trimmedId = opts.RecommendedVersionId.trim(); diff --git a/src/package/packageCreate.ts b/src/package/packageCreate.ts index 5c07c3391..775b472c7 100644 --- a/src/package/packageCreate.ts +++ b/src/package/packageCreate.ts @@ -14,17 +14,32 @@ * limitations under the License. */ -import { Connection, SfError, SfProject } from '@salesforce/core'; +import { Connection, Messages, SfError, SfProject } from '@salesforce/core'; import { env } from '@salesforce/kit'; import { PackagePackageDir, PackageDir } from '@salesforce/schemas'; import { isPackagingDirectory } from '@salesforce/core/project'; import * as pkgUtils from '../utils/packageUtils'; import { applyErrorAction, massageErrorMessage } from '../utils/packageUtils'; -import { PackageCreateOptions, PackagingSObjects } from '../interfaces'; +import { + DISTRIBUTION_TYPE_MIN_API_VERSION, + PackageCreateOptions, + PackagingSObjects, + SETTABLE_DISTRIBUTION_TYPES, + SettableDistributionType, +} from '../interfaces'; + +Messages.importMessagesDirectory(__dirname); +const messages = Messages.loadMessages('@salesforce/packaging', 'package'); type Package2Request = Pick< PackagingSObjects.Package2, - 'Name' | 'Description' | 'NamespacePrefix' | 'ContainerOptions' | 'IsOrgDependent' | 'PackageErrorUsername' + | 'Name' + | 'Description' + | 'NamespacePrefix' + | 'ContainerOptions' + | 'IsOrgDependent' + | 'PackageErrorUsername' + | 'DistributionType' >; export function createPackageRequestFromContext(project: SfProject, options: PackageCreateOptions): Package2Request { @@ -36,6 +51,9 @@ export function createPackageRequestFromContext(project: SfProject, options: Pac ContainerOptions: options.packageType, IsOrgDependent: options.orgDependent, PackageErrorUsername: options.errorNotificationUsername, + // Only send DistributionType when the user provided one; otherwise the backend defaults it + // based on the package type (Managed -> PublicSecure, Unlocked -> Limited). + ...(options.distributionType ? { DistributionType: options.distributionType } : {}), }; } @@ -64,11 +82,34 @@ export function createPackageDirEntry(project: SfProject, options: PackageCreate }; } +/** + * Validate a user-supplied distribution type: it requires a minimum API version and must be one of + * the CLI-settable values (`PublicSecure` or `Limited`). `Public` and `Private` are backend-only. + * A no-op when `distributionType` is undefined (the backend then defaults it by package type). + * + * Shared by both create and update so the CLI surfaces the same errors before hitting the API. + */ +export function validateDistributionType( + connection: Connection, + distributionType: SettableDistributionType | undefined +): void { + if (distributionType === undefined) { + return; + } + if (connection.getApiVersion() < DISTRIBUTION_TYPE_MIN_API_VERSION) { + throw messages.createError('distributionTypeApiPriorTo68Error'); + } + if (!SETTABLE_DISTRIBUTION_TYPES.includes(distributionType)) { + throw messages.createError('invalidDistributionTypeError', [distributionType]); + } +} + export async function createPackage( connection: Connection, project: SfProject, options: PackageCreateOptions ): Promise<{ Id: string }> { + validateDistributionType(connection, options.distributionType); const cleanOptions = sanitizePackageCreateOptions(options); const request = createPackageRequestFromContext(project, cleanOptions); const createResult = await connection.tooling diff --git a/test/package/package.test.ts b/test/package/package.test.ts index 2456a15c4..94bafe3e0 100644 --- a/test/package/package.test.ts +++ b/test/package/package.test.ts @@ -275,6 +275,85 @@ describe('Package', () => { expect(e.message).to.include('recommended version').and.to.include('(04t)'); } }); + + it('should update package DistributionType', async () => { + $$.inProject(true); + project = await setupProject((p) => { + p.getSfProjectJson().set('packageAliases', { mypkgalias: pkgId }); + }); + + let objProvided = ''; + let optsProvided: PackageUpdateOptions = { Id: '' }; + const conn = { + tooling: { + update: (obj: string, opts: PackageUpdateOptions) => { + objProvided = obj; + optsProvided = opts; + return { success: true }; + }, + }, + getApiVersion: () => '68.0', + } as unknown as Connection; + + const pkg = new Package({ connection: conn, packageAliasOrId: pkgId, project }); + const result = await pkg.update({ + Id: pkgId, + DistributionType: 'Limited', + }); + assert(result.success); + expect(objProvided).to.equal('Package2'); + expect(optsProvided.Id).to.equal(pkgId); + expect(optsProvided.DistributionType).to.equal('Limited'); + }); + + it('should error if DistributionType is defined for api version < 68.0', async () => { + $$.inProject(true); + project = await setupProject((p) => { + p.getSfProjectJson().set('packageAliases', { mypkgalias: pkgId }); + }); + const conn = { + tooling: { + update: () => {}, + }, + getApiVersion: () => '67.0', + } as unknown as Connection; + const pkg = new Package({ connection: conn, packageAliasOrId: pkgId, project }); + try { + await pkg.update({ + Id: pkgId, + DistributionType: 'PublicSecure', + }); + expect.fail('The update did not throw an error when it should have'); + } catch (e) { + assert(e instanceof Error); + expect(e.message).to.include('distribution type').and.to.include('68.0'); + } + }); + + it('should error if DistributionType is not a CLI-settable value', async () => { + $$.inProject(true); + project = await setupProject((p) => { + p.getSfProjectJson().set('packageAliases', { mypkgalias: pkgId }); + }); + const conn = { + tooling: { + update: () => {}, + }, + getApiVersion: () => '68.0', + } as unknown as Connection; + const pkg = new Package({ connection: conn, packageAliasOrId: pkgId, project }); + try { + await pkg.update({ + Id: pkgId, + // 'Public' is a backend-only value and must be rejected by the CLI layer. + DistributionType: 'Public' as never, + }); + expect.fail('The update did not throw an error when it should have'); + } catch (e) { + assert(e instanceof Error); + expect(e.message).to.include('PublicSecure').and.to.include('Limited'); + } + }); }); describe('lazy load package data', () => { diff --git a/test/package/packageCreate.test.ts b/test/package/packageCreate.test.ts index 9409cb376..7b0534e57 100644 --- a/test/package/packageCreate.test.ts +++ b/test/package/packageCreate.test.ts @@ -15,10 +15,14 @@ */ import path from 'node:path'; import fs from 'node:fs'; -import { expect } from 'chai'; +import { assert, expect } from 'chai'; import { instantiateContext, restoreContext, stubContext } from '@salesforce/core/testSetup'; -import { SfProject } from '@salesforce/core'; -import { createPackageRequestFromContext, createPackageDirEntry } from '../../src/package/packageCreate'; +import { Connection, SfProject } from '@salesforce/core'; +import { + createPackageRequestFromContext, + createPackageDirEntry, + validateDistributionType, +} from '../../src/package/packageCreate'; async function setupProject(setup: (project: SfProject) => void = () => {}) { const project = await SfProject.resolve(); @@ -125,6 +129,74 @@ describe('packageCreate', () => { PackageErrorUsername: 'foo@bar.org', }); }); + it('should include DistributionType when provided', async () => { + $$.inProject(true); + const project = await setupProject(); + const request = createPackageRequestFromContext(project, { + name: 'test', + description: 'test description', + path: 'test/path', + packageType: 'Managed', + orgDependent: false, + errorNotificationUsername: 'foo@bar.org', + noNamespace: false, + distributionType: 'PublicSecure', + }); + expect(request).to.deep.equal({ + ContainerOptions: 'Managed', + Description: 'test description', + DistributionType: 'PublicSecure', + IsOrgDependent: false, + Name: 'test', + NamespacePrefix: '', + PackageErrorUsername: 'foo@bar.org', + }); + }); + it('should omit DistributionType when not provided so the backend can default it', async () => { + $$.inProject(true); + const project = await setupProject(); + const request = createPackageRequestFromContext(project, { + name: 'test', + description: 'test description', + path: 'test/path', + packageType: 'Managed', + orgDependent: false, + errorNotificationUsername: 'foo@bar.org', + noNamespace: false, + }); + expect(request).to.not.have.property('DistributionType'); + }); + describe('validateDistributionType', () => { + const connWithApi = (apiVersion: string): Connection => + ({ getApiVersion: () => apiVersion } as unknown as Connection); + + it('is a no-op when no distribution type is provided', () => { + expect(() => validateDistributionType(connWithApi('68.0'), undefined)).to.not.throw(); + }); + it('accepts CLI-settable values at API 68.0+', () => { + expect(() => validateDistributionType(connWithApi('68.0'), 'PublicSecure')).to.not.throw(); + expect(() => validateDistributionType(connWithApi('68.0'), 'Limited')).to.not.throw(); + }); + it('throws for API version < 68.0', () => { + try { + validateDistributionType(connWithApi('67.0'), 'Limited'); + expect.fail('validateDistributionType did not throw for api version < 68.0'); + } catch (e) { + assert(e instanceof Error); + expect(e.message).to.include('distribution type').and.to.include('68.0'); + } + }); + it('throws for a non-CLI-settable value (Public/Private)', () => { + try { + // 'Public' is a backend-only value; the CLI must reject it. + validateDistributionType(connWithApi('68.0'), 'Public' as never); + expect.fail('validateDistributionType did not throw for a backend-only value'); + } catch (e) { + assert(e instanceof Error); + expect(e.message).to.include('PublicSecure').and.to.include('Limited'); + } + }); + }); describe('createPackageDirEntry', () => { it('should return a valid new package directory entry - no existing entries', async () => { $$.inProject(true);