-
Notifications
You must be signed in to change notification settings - Fork 21
feat(lightning-out): add template generate lightning-out command @W-24035498@
#1048
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b3f7d49
f56dc98
5b17066
4312e7d
04793ff
5413325
21f81bc
9050ac9
06d5bb0
5272b27
c8a4317
96f0fe1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| # examples | ||
|
|
||
| - Generate a Lightning Out 2.0 app using individual flags to specify the values: | ||
|
|
||
| <%= config.bin %> <%= command.id %> --app-name MyLoApp --runtime LWR_CORE --host-domains https://example.com --eca-name MyLoAppEca --eca-contact-email dev@example.com --eca-callback-url https://example.com/cb | ||
|
|
||
| - Generate an app using the values in a JSON definition file called lo-def.json: | ||
|
|
||
| <%= config.bin %> <%= command.id %> --definition-file lo-def.json | ||
|
|
||
| - Generate the app into a specific directory: | ||
|
|
||
| <%= config.bin %> <%= command.id %> --definition-file lo-def.json --output-dir force-app/main/default | ||
|
|
||
| - Generate the app using most of the values from a definition file, but the --host-domains value overrides its equivalent in the file: | ||
|
|
||
| <%= config.bin %> <%= command.id %> --definition-file lo-def.json --host-domains https://staging.example.com | ||
|
|
||
| # summary | ||
|
|
||
| Generate the required metadata to scaffold a Lightning Out 2.0 app. | ||
|
|
||
| # description | ||
|
|
||
| Lightning Out 2.0 lets you embed custom Lightning web components (LWCs) into your external, non-Salesforce apps. | ||
|
|
||
| This command gets you started by generating the seven metadata artifact types that a Lightning Out 2.0 app requires into your Salesforce DX project: LightningOutApp, MyDomain and Security settings, one CorsWhitelistOrigin per host domain, and the three External Client Application OAuth components (ExternalClientApplication, ExtlClntAppGlobalOauthSettings, ExtlClntAppOauthSettings). The command is generate-only; it doesn't deploy any metadata to an org. | ||
|
|
||
| Inputs may come from a --definition-file JSON, individual flags, or both. Flag values take precedence over the file on a per-key basis. The command validates the types of definition-file fields; the underlying generator performs the remaining structural validation, such as required fields and formats. | ||
|
|
||
| # flags.app-name.summary | ||
|
|
||
| Developer name of the new Lightning Out 2.0 app. | ||
|
|
||
| # flags.eca-name.summary | ||
|
|
||
| Developer name of the External Client Application associated with the app. | ||
|
|
||
| # flags.runtime.summary | ||
|
|
||
| Runtime the app targets. LWR_CORE serves from your Salesforce org for authenticated users; CLWR serves from an Experience Cloud site (guest access, extra site-deployment step). | ||
|
|
||
| # flags.host-domains.summary | ||
|
|
||
| HTTP or HTTPS origin of an external host page that embeds the app. Repeat the flag to specify more than one. Replaces, rather than merges with, any hostDomains in the --definition-file. | ||
|
|
||
| # flags.components.summary | ||
|
|
||
| Name of a Lightning web component exposed by the app. Repeat the flag to specify more than one. Replaces, rather than merges with, any components in the --definition-file. | ||
|
|
||
| # flags.eca-contact-email.summary | ||
|
|
||
| Contact email for the External Client Application. | ||
|
|
||
| # flags.eca-callback-url.summary | ||
|
|
||
| OAuth callback URL for the External Client Application. | ||
|
|
||
| # flags.definition-file.summary | ||
|
|
||
| Path to a JSON file describing the Lightning Out 2.0 app. Individual flags, when supplied, override the corresponding value in this file. | ||
|
|
||
| # error.definition-file-json | ||
|
|
||
| Definition file %s is not valid JSON: %s | ||
|
|
||
| # error.definition-file-not-object | ||
|
|
||
| Definition file %s must contain a single JSON object, not an array or scalar. | ||
|
|
||
| # error.definition-file-field-type | ||
|
|
||
| Definition file field "%s" must be %s. | ||
|
|
||
| # warning.unknown-definition-key | ||
|
|
||
| Ignoring unrecognized key "%s" in --definition-file. | ||
|
|
||
| # warning.source-api-version | ||
|
|
||
| Your local project's sourceApiVersion (%s) is below 68.0, the minimum API version this scaffold supports for deployment. Set sourceApiVersion to 68.0 or later in sfdx-project.json, or pass --api-version 68.0 or later when you deploy. | ||
|
|
||
| # success.next-step | ||
|
|
||
| Scaffold generated in %s. Deploy it with: sf project deploy start --source-dir %s%s | ||
|
|
||
| # success.app-id | ||
|
|
||
| After deploying, note the LightningOutApp's App ID from Setup — your host page needs it to embed the app. | ||
|
|
||
| # success.dont-delete | ||
|
|
||
| Don't delete the generated ExternalClientApplication after deploying — Lightning Out uses it for OAuth at runtime. | ||
|
|
||
| # success.eca-overwrite | ||
|
|
||
| Re-running this command overwrites the generated files for the "%s" External Client Application; back up local edits first. | ||
|
|
||
| # success.components-exist | ||
|
|
||
| Verify that every component you referenced already exists and is exposed for Lightning Out before deploying. | ||
|
|
||
| # success.frontdoor | ||
|
|
||
| For CLWR, host pages must complete a frontdoor.jsp handoff before the embedded app can authenticate. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| { | ||
| "$schema": "http://json-schema.org/draft-07/schema#", | ||
| "$ref": "#/definitions/CreateOutput", | ||
| "definitions": { | ||
| "CreateOutput": { | ||
| "type": "object", | ||
| "properties": { | ||
| "outputDir": { | ||
| "type": "string" | ||
| }, | ||
| "created": { | ||
| "type": "array", | ||
| "items": { | ||
| "type": "string" | ||
| } | ||
| }, | ||
| "rawOutput": { | ||
| "type": "string" | ||
| } | ||
| }, | ||
| "required": ["outputDir", "created", "rawOutput"], | ||
| "additionalProperties": false | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,210 @@ | ||
| /* | ||
| * Copyright 2025, Salesforce, Inc. | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| import * as fs from 'node:fs'; | ||
| import { Flags, loglevel, SfCommand, Ux } from '@salesforce/sf-plugins-core'; | ||
| import { CreateOutput, LightningOutOptions, TemplateType } from '@salesforce/templates'; | ||
| import { Messages, SfProject } from '@salesforce/core'; | ||
| import { getCustomTemplates, runGenerator } from '../../../../utils/templateCommand.js'; | ||
| import { outputDirFlagLightning } from '../../../../utils/flags.js'; | ||
|
|
||
| Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); | ||
| const messages = Messages.loadMessages('@salesforce/plugin-templates', 'lightningOut'); | ||
|
|
||
| /** Flat flag shape read by {@link mergeLightningOutInputs} — a structural subset of the parsed oclif flags. */ | ||
| type LightningOutFlags = { | ||
| 'app-name'?: string; | ||
| 'eca-name'?: string; | ||
| runtime?: 'LWR_CORE' | 'CLWR'; | ||
| 'host-domains'?: string[]; | ||
| components?: string[]; | ||
| 'eca-contact-email'?: string; | ||
| 'eca-callback-url'?: string; | ||
| 'output-dir'?: string; | ||
| }; | ||
|
|
||
| /** Parse the --definition-file JSON, surfacing a clear error on malformed or non-object input. */ | ||
| export function readDefinition(file: string): Record<string, unknown> { | ||
| let parsed: unknown; | ||
| try { | ||
| parsed = JSON.parse(fs.readFileSync(file, 'utf8')); | ||
| } catch (e) { | ||
| throw messages.createError('error.definition-file-json', [file, (e as Error).message]); | ||
| } | ||
| if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { | ||
| throw messages.createError('error.definition-file-not-object', [file]); | ||
| } | ||
| return parsed as Record<string, unknown>; | ||
| } | ||
|
|
||
| /** True when a value is an array whose every element is a string. */ | ||
| function isStringArray(v: unknown): boolean { | ||
| return Array.isArray(v) && v.every((x) => typeof x === 'string'); | ||
| } | ||
|
|
||
| /** | ||
| * Validate the types of --definition-file fields before they reach the generator, so wrong-typed | ||
| * JSON (e.g. `"appName": 123`) yields an actionable error here rather than an internal TypeError | ||
| * from the generator (which calls `.trim()` on string fields). A field is skipped when the matching | ||
| * flag overrides it, since flags always arrive as strings and win per-key (see mergeLightningOutInputs). | ||
| */ | ||
| export function validateDefinitionShape(defn: Record<string, unknown>, flags: LightningOutFlags): void { | ||
| const requireString = (overridden: boolean, val: unknown, key: string): void => { | ||
| if (!overridden && val !== undefined && typeof val !== 'string') { | ||
| throw messages.createError('error.definition-file-field-type', [key, 'a string']); | ||
| } | ||
| }; | ||
| const requireStringArray = (overridden: boolean, val: unknown, key: string): void => { | ||
| if (!overridden && val !== undefined && !isStringArray(val)) { | ||
| throw messages.createError('error.definition-file-field-type', [key, 'an array of strings']); | ||
| } | ||
| }; | ||
|
|
||
| requireString(flags['app-name'] !== undefined, defn.appName, 'appName'); | ||
| requireString(flags.runtime !== undefined, defn.runtime, 'runtime'); | ||
| requireStringArray(flags['host-domains'] !== undefined, defn.hostDomains, 'hostDomains'); | ||
| requireStringArray(flags.components !== undefined, defn.components, 'components'); | ||
|
|
||
| if (defn.eca !== undefined) { | ||
| if (typeof defn.eca !== 'object' || defn.eca === null || Array.isArray(defn.eca)) { | ||
| throw messages.createError('error.definition-file-field-type', ['eca', 'an object']); | ||
| } | ||
| const eca = defn.eca as Record<string, unknown>; | ||
| requireString(flags['eca-name'] !== undefined, eca.name, 'eca.name'); | ||
| requireString(flags['eca-contact-email'] !== undefined, eca.contactEmail, 'eca.contactEmail'); | ||
| requireString(flags['eca-callback-url'] !== undefined, eca.callbackUrl, 'eca.callbackUrl'); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Merge the --definition-file JSON with individual flags into one LightningOutOptions, with | ||
| * per-key precedence (flags win over the file) and wholesale list-replace semantics for | ||
| * hostDomains/components (never merge/concat). All structural validation (required-ness, shape, | ||
| * formats) is the generator's job — this function only resolves precedence. | ||
| */ | ||
| export function mergeLightningOutInputs( | ||
| defn: Record<string, unknown>, | ||
| flags: LightningOutFlags | ||
| ): { opts: LightningOutOptions; unknownKeys: string[] } { | ||
| const known = new Set(['appName', 'runtime', 'hostDomains', 'components', 'eca']); | ||
| const unknownKeys = Object.keys(defn).filter((k) => !known.has(k)); | ||
| const ecaDefn = (defn.eca ?? {}) as Record<string, unknown>; | ||
| const opts: LightningOutOptions = { | ||
| appName: flags['app-name'] ?? (defn.appName as string), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These casts allow valid JSON with incorrect value types to bypass this layer. For example,
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed. Added |
||
| runtime: (flags.runtime ?? defn.runtime) as LightningOutOptions['runtime'], | ||
| hostDomains: flags['host-domains'] ?? (defn.hostDomains as string[]) ?? [], | ||
| components: flags.components ?? (defn.components as string[]), | ||
| eca: { | ||
| name: flags['eca-name'] ?? (ecaDefn.name as string), | ||
| contactEmail: flags['eca-contact-email'] ?? (ecaDefn.contactEmail as string), | ||
| callbackUrl: flags['eca-callback-url'] ?? (ecaDefn.callbackUrl as string), | ||
| }, | ||
| outputdir: flags['output-dir'], | ||
| }; | ||
| return { opts, unknownKeys }; | ||
| } | ||
|
|
||
| /** | ||
| * Resolve the local DX project's `sourceApiVersion`, used only for a CLI-side advisory warning | ||
| * (the generator itself has no project context). Returns undefined when there's no DX project — | ||
| * generating outside a project must not error. | ||
| */ | ||
| async function getSourceApiVersion(): Promise<string | undefined> { | ||
| try { | ||
| const project = await SfProject.resolve(); | ||
| const projectJson = await project.resolveProjectConfig(); | ||
| return projectJson.sourceApiVersion as string | undefined; | ||
| } catch (e) { | ||
| return undefined; | ||
| } | ||
| } | ||
|
|
||
| /** True when a project sourceApiVersion is present and below the v68.0 deploy floor. Pure; testable. */ | ||
| export function isBelowApiFloor(projApi: string | undefined): boolean { | ||
| return !!projApi && Number(projApi) < 68; | ||
| } | ||
|
|
||
| /** | ||
| * Quote a path for safe copy-paste into a POSIX shell when it contains whitespace or quote chars, | ||
| * so the suggested deploy command survives output dirs such as "/tmp/Lightning Out". | ||
| */ | ||
| export function shellQuoteArg(p: string): string { | ||
| return /[\s"'\\]/.test(p) ? `'${p.replace(/'/g, "'\\''")}'` : p; | ||
| } | ||
|
|
||
| export default class LightningOut extends SfCommand<CreateOutput> { | ||
| public static readonly summary = messages.getMessage('summary'); | ||
| public static readonly description = messages.getMessage('description'); | ||
| public static readonly examples = messages.getMessages('examples'); | ||
| public static readonly state = 'beta'; | ||
| public static readonly hidden = true; | ||
|
|
||
| public static readonly flags = { | ||
| 'app-name': Flags.string({ summary: messages.getMessage('flags.app-name.summary'), required: false }), | ||
| 'eca-name': Flags.string({ summary: messages.getMessage('flags.eca-name.summary'), required: false }), | ||
| runtime: Flags.option({ | ||
| options: ['LWR_CORE', 'CLWR'] as const, | ||
| summary: messages.getMessage('flags.runtime.summary'), | ||
| })(), | ||
| 'host-domains': Flags.string({ summary: messages.getMessage('flags.host-domains.summary'), multiple: true }), | ||
| components: Flags.string({ summary: messages.getMessage('flags.components.summary'), multiple: true }), | ||
| 'eca-contact-email': Flags.string({ summary: messages.getMessage('flags.eca-contact-email.summary') }), | ||
| 'eca-callback-url': Flags.string({ summary: messages.getMessage('flags.eca-callback-url.summary') }), | ||
| 'definition-file': Flags.file({ exists: true, summary: messages.getMessage('flags.definition-file.summary') }), | ||
| 'output-dir': outputDirFlagLightning, | ||
| loglevel, | ||
| }; | ||
|
|
||
| public async run(): Promise<CreateOutput> { | ||
| const { flags } = await this.parse(LightningOut); | ||
| const defn = flags['definition-file'] ? readDefinition(flags['definition-file']) : {}; | ||
| validateDefinitionShape(defn, flags); | ||
| const { opts, unknownKeys } = mergeLightningOutInputs(defn, flags); | ||
|
|
||
| unknownKeys.forEach((k) => this.warn(messages.getMessage('warning.unknown-definition-key', [k]))); | ||
|
|
||
| const result = await runGenerator({ | ||
| templateType: TemplateType.LightningOut, | ||
| opts, | ||
| ux: new Ux({ jsonEnabled: this.jsonEnabled() }), | ||
| templates: getCustomTemplates(this.configAggregator), | ||
| }); | ||
|
|
||
| // CLI-side sourceApiVersion floor check (the generator has no project context). | ||
| const projApi = await getSourceApiVersion(); | ||
| const belowFloor = isBelowApiFloor(projApi); | ||
| if (belowFloor) { | ||
| this.warn(messages.getMessage('warning.source-api-version', [String(projApi)])); | ||
| } | ||
|
|
||
| // Success guidance (suppressed automatically under --json). | ||
| // Only pin --api-version to the 68.0 deploy floor when the project is below it; otherwise omit | ||
| // the optional flag so the user's own (>= floor) project default applies rather than being downgraded. | ||
| const outputDir = opts.outputdir ?? '.'; | ||
| const apiVersionSuffix = belowFloor ? ' --api-version 68.0' : ''; | ||
| this.log(messages.getMessage('success.next-step', [outputDir, shellQuoteArg(outputDir), apiVersionSuffix])); | ||
| this.info(messages.getMessage('success.app-id')); | ||
| this.info(messages.getMessage('success.dont-delete')); | ||
| this.info(messages.getMessage('success.eca-overwrite', [opts.eca.name ?? ''])); | ||
| this.info(messages.getMessage('success.components-exist')); | ||
| // frontdoor.jsp handoff is a CLWR-only concern; don't show it for LWR_CORE runs. | ||
| if (opts.runtime === 'CLWR') { | ||
| this.info(messages.getMessage('success.frontdoor')); | ||
| } | ||
|
|
||
| return result; // --json returns the full CreateOutput (created[]) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Terminology — confirm product name. Use the approved external name "Lightning Out" ("2.0" is internal shorthand for the GA-vs-Beta distinction). Please confirm before merge. The same change applies to lines 3, 33, and 61 (lines 25 and 27 are already covered by the suggestions there).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Discussed this naming with our PM (Sangeeta): we're keeping Lightning Out 2.0 until the beta version is completely sunset. Otherwise devs may take "Lightning Out" to be the beta, or be unsure whether it's the beta or the latest. Once beta is deprecated we'll drop the "2.0" and use the name as-is. Leaving it unchanged for now.