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
59 changes: 25 additions & 34 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,16 +115,15 @@ Provision Permission Set Licenses (PSL) into a target org.

```
USAGE
$ sf license provision -o <value> [-n <value>] [-l <value>] [-q <value>] [-s <value>] [-e <value>] [-f <value>] [--api-version <value>] [--json] [--flags-dir <value>]
$ sf license provision -o <value> [-l <value> -n <value> -q <value>] [-f <value>] [--api-version <value>] [--json] [--flags-dir <value>]

FLAGS
-e, --end-date=<value> License end date in YYYY-MM-DD format. Default is no expiration.
-f, --definition-file=<value> Path to a JSON file that contains the PSL provisioning request information.
-l, --license=<value> Permission Set License name.
-n, --namespace=<value> License package namespace.
Cannot be combined with --license, --namespace, or --quantity.
-l, --license=<value> Permission Set License name. Cannot be combined with --definition-file.
-n, --namespace=<value> License package namespace. Requires --license. Cannot be combined with --definition-file.
-o, --target-org=<value> (required) Username or alias of the target org.
-q, --quantity=<value> Number of licenses to provision.
-s, --start-date=<value> License start date in YYYY-MM-DD format. Defaults to today.
-q, --quantity=<value> Number of licenses to provision. Requires --license. Cannot be combined with --definition-file.
--api-version=<value> Override the api version used for api requests made by this command.

GLOBAL FLAGS
Expand All @@ -136,41 +135,33 @@ DESCRIPTION

There are two ways to run this command. You can provide the information to identify a single PSL via command line flags, or provision multiple PSLs in a single call by supplying a JSON formatted file.

See <Add URL Here> for the format and options contained within the JSON file.
The JSON definition file must contain a top-level `licenses` array. Each entry supports the following fields:

EXAMPLES
Provision a single Permission Set License into an org:

$ sf license provision --target-org myScratchOrg --namespace demo --license newLicense --quantity 5 --start-date '2026-03-30' --end-date '2027-03-30'
| Field | Type | Required | Description |
|---|---|---|---|
| `license` | string | Yes | Permission Set License name. |
| `namespace` | string | Yes | License package namespace. |
| `quantity` | integer | Yes | Number of licenses to provision. |

Use a JSON formatted input file to provision one or more Permission Set Licenses into an org:

$ sf license provision --target-org myScratchOrg --definition-file test/config/provisionPSLs.json
Example:

HUMAN READABLE OUTPUT

Success:
Provisioned 5 licenses for the license definition 'demo__newLicense'
json
{
"licenses": [
{ "namespace": "myNS", "license": "premiumLicense", "quantity": 10 },
{ "namespace": "myNS", "license": "starterLicense", "quantity": 5 }
]
}

Success:
Provisioned 5 licenses for the license definition 'demo__newLicense'
Provisioned 8 licenses for the license definition 'demo__premiumLicense'
EXAMPLES
Provision a single Permission Set License into an org:

Error: Failed to provision licenses.
License Definition not found for 'demo__badLicense'.
Quantity cannot be negative for 'demo__negativeLicense'.
$ sf license provision --target-org myScratchOrg --namespace demo --license newLicense --quantity 5

JSON OUTPUT
Use a JSON formatted input file to provision one or more Permission Set Licenses into an org:

{ "status": "success" }
$ sf license provision --target-org myScratchOrg --definition-file test/config/provisionPSLs.json

{
"status": "error",
"messages": [
{ "errorCode": "INVALID_LICENSE_DEFINITION", "message": "License definition not found for 'demo__badLicense'" },
{ "errorCode": "INVALID_QUANTITY", "message": "Quantity cannot be negative for 'demo__negativeLicense'" }
]
}
```

_See code: [src/commands/license/provision.ts](https://github.com/salesforcecli/plugin-license-management/blob/1.0.0/src/commands/license/provision.ts)_
Expand All @@ -186,5 +177,5 @@ sf package install --package <package-id> --target-org <scratch-org-username>

sf package install report -i <install-request-id> -o <scratch-org-username>

sf license provision -o <scratch-org-username> --license premium --namespace demo --quantity 10 --start-date '2026-03-20' --end-date '2027-03-20'
sf license provision -o <scratch-org-username> --license premium --namespace demo --quantity 10
```
15 changes: 2 additions & 13 deletions command-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,8 @@
"alias": [],
"command": "license:provision",
"flagAliases": ["apiversion", "targetusername", "u"],
"flagChars": ["e", "f", "l", "n", "o", "q", "s"],
"flags": [
"api-version",
"definition-file",
"end-date",
"flags-dir",
"json",
"license",
"namespace",
"quantity",
"start-date",
"target-org"
],
"flagChars": ["f", "l", "n", "o", "q"],
"flags": ["api-version", "definition-file", "flags-dir", "json", "license", "namespace", "quantity", "target-org"],
"plugin": "@salesforce/plugin-license-management"
}
]
22 changes: 9 additions & 13 deletions messages/license.provision.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,6 @@ Permission Set License name.

Number of licenses to provision.

# flags.start-date.summary

License start date in YYYY-MM-DD format. Defaults to today.

# flags.end-date.summary

License end date in YYYY-MM-DD format. Default is no expiration.

# flags.definition-file.summary

Path to a JSON file that contains the PSL provisioning request information.
Expand All @@ -38,7 +30,7 @@ Path to a JSON file that contains the PSL provisioning request information.

- Provision a single Permission Set License into an org:

<%= config.bin %> <%= command.id %> --target-org myScratchOrg --namespace demo --license newLicense --quantity 5 --start-date '2026-03-30' --end-date '2027-03-30'
<%= config.bin %> <%= command.id %> --target-org myScratchOrg --namespace demo --license newLicense --quantity 5

- Use a JSON formatted input file to provision one or more Permission Set Licenses into an org:

Expand All @@ -52,14 +44,18 @@ Trace ID: %s

Either --license or --definition-file is required.

# error.mutuallyExclusiveFlags

The --definition-file flag cannot be used with --namespace, --license, --quantity, --start-date, or --end-date.

# error.emptyDefinitionFile

The definition file must contain at least one license entry.

# error.unsupportedDefinitionFileFields

Nonexistent fields: %s

# error.missingRequiredDefinitionFileFields

Missing required fields: %s

# error.provisionFailed

Failed to provision licenses. %s
Expand Down
89 changes: 52 additions & 37 deletions src/commands/license/provision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,25 @@ Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('@salesforce/plugin-license-management', 'license.provision');

type ProvisionLicenseSpec = {
namespace?: string;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a very minor discussion point.
@acardel
Do we want to try to "align" the field names between the CLI and the API?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, specially in the case of license which could refer to other types of licenses on the platform.
We should match license to permissionSetLicense
and namespace to namespacePrefix
What do you say @ukanoja-sf?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The one thing we should probably be mindful of is not locking the sf cli command into PSL specific naming, since it is possible that this could be used for non-PSL related provisioning in the future.

So realistically, the only thing we might want to converge on is namespace.
tbh, it looks like the existing packaging code uses "namespace" as the parameter name.
So if we want to align to that, we'd be changing the API not the CLI.
Or we just let it go. Perhaps we ask Rashmi the importance of the delivery timing.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think after discussion with platform team we have decided to use just license and our design doc also mentions the same params. I prefer current naming and also not sure if we update now, then we might need another design review with platform team. If want to change, then I will prefer to update it in api request. @trey-sf, @acardel let me know if you prefer to discuss it over call (or tomorrow's standup) and we can quickly resolve it.
https://salesforce.quip.com/ysrEAiCWQfak#temp:C:YWc448cff01faa74dc0b48f1cf93

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

saw @trey-sf's comment after posting my comment, I will bring this up in tomorrow's stand up and we can close this in parking lot.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool. If that's the case I lean towards letting it go. I like the API to be PSL specific. If a user goes straight to the endpoint they will be hitting the PermissionSetLicense resource.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd definitely leave PermissionSetLicense as-is.
It's whether we rename "namespacePrefix" to just "namespace" in the API.
Let's discuss in PL.

license?: string;
quantity?: number;
};

type ApiLicenseSpec = {
namespacePrefix?: string;
permissionSetLicense?: string;
quantity?: number;
startDate?: string;
endDate?: string;
};

type ProvisionPslRequest = {
type DefinitionFile = {
licenses: ProvisionLicenseSpec[];
};

type ProvisionPslRequest = {
licenses: ApiLicenseSpec[];
};

type ProvisionPslResponse = {
status: string;
licensesProvisioned?: number;
Expand All @@ -46,8 +54,16 @@ export type LicenseProvisionResult = {
};

function getLicenseDefinitionName(spec: ProvisionLicenseSpec): string {
const psl = spec.permissionSetLicense ?? '';
return spec.namespacePrefix ? `${spec.namespacePrefix}__${psl}` : psl;
const psl = spec.license ?? '';
return spec.namespace ? `${spec.namespace}__${psl}` : psl;
}

function toApiSpec(spec: ProvisionLicenseSpec): ApiLicenseSpec {
return {
namespacePrefix: spec.namespace,
permissionSetLicense: spec.license,
quantity: spec.quantity,
};
}

export default class LicenseProvision extends SfCommand<LicenseProvisionResult> {
Expand All @@ -61,54 +77,57 @@ export default class LicenseProvision extends SfCommand<LicenseProvisionResult>
namespace: Flags.string({
char: 'n',
summary: messages.getMessage('flags.namespace.summary'),
dependsOn: ['license'],
exclusive: ['definition-file'],
}),
license: Flags.string({
char: 'l',
summary: messages.getMessage('flags.license.summary'),
exclusive: ['definition-file'],
relationships: [{ type: 'all', flags: ['namespace', 'quantity'] }],
}),
quantity: Flags.integer({
char: 'q',
summary: messages.getMessage('flags.quantity.summary'),
min: 0,
max: Number.MAX_SAFE_INTEGER,
}),
'start-date': Flags.string({
char: 's',
summary: messages.getMessage('flags.start-date.summary'),
}),
'end-date': Flags.string({
char: 'e',
summary: messages.getMessage('flags.end-date.summary'),
dependsOn: ['license'],
exclusive: ['definition-file'],
}),
'definition-file': Flags.string({
char: 'f',
summary: messages.getMessage('flags.definition-file.summary'),
exclusive: ['license', 'namespace', 'quantity'],
}),
};

// Protected to allow stubbing in tests
protected static async loadSpecsFromFile(
filePath: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
flags: Record<string, any>
): Promise<ProvisionLicenseSpec[]> {
if (
flags['license'] ||
flags['namespace'] ||
flags['quantity'] !== undefined ||
flags['start-date'] ||
flags['end-date']
) {
throw messages.createError('error.mutuallyExclusiveFlags');
}

protected static async loadSpecsFromFile(filePath: string): Promise<ProvisionLicenseSpec[]> {
const fileContent = await readFile(filePath, 'utf-8');
const definition = JSON.parse(fileContent) as ProvisionPslRequest;
const definition = JSON.parse(fileContent) as DefinitionFile;

if (!Array.isArray(definition.licenses) || definition.licenses.length === 0) {
throw messages.createError('error.emptyDefinitionFile');
}

const allowedFields: ReadonlySet<string> = new Set(['namespace', 'license', 'quantity']);
const unknownFields = [
...new Set(definition.licenses.flatMap((entry) => Object.keys(entry).filter((key) => !allowedFields.has(key)))),
];
if (unknownFields.length > 0) {
throw messages.createError('error.unsupportedDefinitionFileFields', [unknownFields.join(', ')]);
}

const requiredFields = ['namespace', 'license', 'quantity'] as const;
const missingFields = definition.licenses.flatMap((entry, index) =>
requiredFields
.filter((field) => entry[field] === undefined || entry[field] === null)
.map((field) => `licenses[${index}].${field}`)
);
if (missingFields.length > 0) {
throw messages.createError('error.missingRequiredDefinitionFileFields', [missingFields.join(', ')]);
}

return definition.licenses;
}

Expand All @@ -118,15 +137,11 @@ export default class LicenseProvision extends SfCommand<LicenseProvisionResult>
throw messages.createError('error.missingLicenseFlag');
}

const startDate = (flags['start-date'] as string | undefined) ?? new Date().toISOString().slice(0, 10);

return [
{
namespacePrefix: flags['namespace'] as string | undefined,
permissionSetLicense: flags['license'] as string,
namespace: flags['namespace'] as string | undefined,
license: flags['license'] as string,
quantity: flags['quantity'] as number | undefined,
startDate,
endDate: flags['end-date'] as string | undefined,
},
];
}
Expand All @@ -137,11 +152,11 @@ export default class LicenseProvision extends SfCommand<LicenseProvisionResult>
const connection = flags['target-org'].getConnection(flags['api-version']);

const licenseSpecs = flags['definition-file']
? await LicenseProvision.loadSpecsFromFile(flags['definition-file'], flags)
? await LicenseProvision.loadSpecsFromFile(flags['definition-file'])
: LicenseProvision.buildSpecsFromFlags(flags);

const endpoint = `/services/data/v${connection.getApiVersion()}/partnerdevelopment/permissionsetlicenses`;
const requestBody: ProvisionPslRequest = { licenses: licenseSpecs };
const requestBody: ProvisionPslRequest = { licenses: licenseSpecs.map(toApiSpec) };

let response: ProvisionPslResponse;
try {
Expand Down
Loading
Loading