Skip to content
Merged
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
8 changes: 8 additions & 0 deletions messages/package.md
Original file line number Diff line number Diff line change
Expand Up @@ -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".
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
21 changes: 21 additions & 0 deletions src/interfaces/packagingInterfacesAndType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export type PackageUpdateOptions = {
PackageErrorUsername?: string;
AppAnalyticsEnabled?: boolean;
RecommendedVersionId?: string;
DistributionType?: SettableDistributionType;
};

export type PackageAuthorizationOptions = {
Expand Down Expand Up @@ -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<DistributionType, 'PublicSecure' | 'Limited'>;

/** 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;
Expand All @@ -343,6 +363,7 @@ export type PackageCreateOptions = {
packageType: PackageType;
errorNotificationUsername: string;
path: string;
distributionType?: SettableDistributionType;
};

export type PackageDescriptorJson = Partial<NamedPackagingDir> &
Expand Down
3 changes: 2 additions & 1 deletion src/interfaces/packagingSObjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -36,6 +36,7 @@ export namespace PackagingSObjects {
PackageErrorUsername: string;
AppAnalyticsEnabled?: boolean;
RecommendedVersionId?: string;
DistributionType?: DistributionType;
};

export type Package2Version = {
Expand Down
16 changes: 12 additions & 4 deletions src/package/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -71,6 +72,7 @@ export const Package2Fields = [
'PackageErrorUsername',
'AppAnalyticsEnabled',
'RecommendedVersionId',
'DistributionType',
];

/**
Expand Down Expand Up @@ -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'
);
}

/**
Expand Down Expand Up @@ -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();

Expand Down
47 changes: 44 additions & 3 deletions src/package/packageCreate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 } : {}),
};
}

Expand Down Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions test/package/package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
78 changes: 75 additions & 3 deletions test/package/packageCreate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
Loading