diff --git a/messages/lightningOut.md b/messages/lightningOut.md new file mode 100644 index 00000000..ab35f28d --- /dev/null +++ b/messages/lightningOut.md @@ -0,0 +1,75 @@ +# examples + +- Generate a Lightning Out 2.0 scaffold from a definition file into the current directory: + + <%= config.bin %> <%= command.id %> --definition-file lo-def.json + +- Generate into a specific directory: + + <%= config.bin %> <%= command.id %> --definition-file lo-def.json --output-dir force-app/main/default + +- Overwrite files from a previous run: + + <%= config.bin %> <%= command.id %> --definition-file lo-def.json --force + +- Preserve the org's existing Trusted Domains for Inline Frames (retrieve-merge) instead of replacing them: + + <%= config.bin %> <%= command.id %> --definition-file lo-def.json --merge-iframe --target-org myOrg + +# summary + +Generate the metadata scaffold for a Lightning Out 2.0 application. + +# description + +Generates the seven metadata artifact types a Lightning Out 2.0 app requires: LightningOutApp, IframeWhiteListUrlSettings, MyDomain and Security settings, one CorsWhitelistOrigin per host domain, and the External Client Application OAuth trio (ExternalClientApplication, ExtlClntAppGlobalOauthSettings, ExtlClntAppOauthSettings). The command is generate-only; it does not deploy. + +IMPORTANT: Deploying the generated IframeWhiteListUrlSettings REPLACES your org's entire "Trusted Domains for Inline Frames" list (Setup > Security > Session Settings), across every IFrame Type. By default the generated file contains only this app's host domains. To preserve the org's existing entries, pass --merge-iframe with --target-org; the command then retrieves the current list and merges this app's domains into it. + +# flags.definition-file.summary + +Path to a JSON file describing the Lightning Out 2.0 app. + +# flags.definition-file.description + +The JSON must contain: name (a valid Metadata API name), runtime (LWR_CORE or CLWR), components (a non-empty array of Lightning web component names), hostDomains (a non-empty array of https origins), and eca (at least a contactEmail; optionally distributionState, callbackUrl, and oauthScopes). + +# flags.force.summary + +Overwrite existing files instead of erroring. + +# flags.force.description + +By default, generation fails if any target file already exists, so a re-run never silently overwrites your edits — notably the REPLACE-type IframeWhiteListUrlSettings file. Pass --force to overwrite. + +# flags.merge-iframe.summary + +Preserve the org's existing Trusted Domains for Inline Frames by merging into them. + +# flags.merge-iframe.description + +Retrieves the target org's current IframeWhiteListUrlSettings and re-emits every existing entry (across all IFrame Types) into the generated file, then adds this app's host domains. Requires --target-org. Without this flag, the generated file contains only this app's host domains and deploying it REPLACES the org's entire list. + +# flags.target-org.summary + +Org whose Trusted Domains for Inline Frames list is retrieved when --merge-iframe is set. + +# warning.iframe-replace + +The generated IframeWhiteListUrlSettings lists only this app's host domains. Deploying it REPLACES your org's entire "Trusted Domains for Inline Frames" list across all IFrame Types. To preserve existing entries, re-run with --merge-iframe --target-org . + +# info.merged-iframe-count + +Retrieved %s existing Trusted Domains for Inline Frames entries from the org; the generated file merges this app's host domains into them. + +# error.merge-iframe-requires-org + +--merge-iframe requires a target org. Pass --target-org . + +# error.definition-file-read + +Unable to read definition file %s: %s + +# error.definition-file-json + +Definition file %s is not valid JSON: %s diff --git a/src/commands/template/generate/lightning-out/index.ts b/src/commands/template/generate/lightning-out/index.ts new file mode 100644 index 00000000..1c6bfaca --- /dev/null +++ b/src/commands/template/generate/lightning-out/index.ts @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { readFile } from 'node:fs/promises'; +import { Flags, loglevel, orgApiVersionFlagWithDeprecations, SfCommand, Ux } from '@salesforce/sf-plugins-core'; +import { CreateOutput, IframeWhiteListEntry, LightningOutOptions, TemplateType } from '@salesforce/templates'; +import { Messages, SfError } from '@salesforce/core'; +import { getCustomTemplates, runGenerator } from '../../../../utils/templateCommand.js'; +import { outputDirFlagLightning } from '../../../../utils/flags.js'; +import { retrieveIframeEntries } from '../../../../utils/lightningOutIframe.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-templates', 'lightningOut'); + +/** Shape of the --definition-file JSON (spec §3.2). */ +type LightningOutDefinition = { + name?: string; + runtime?: LightningOutOptions['runtime']; + components?: string[]; + hostDomains?: string[]; + eca?: LightningOutOptions['eca']; +}; + +/** Parse the definition file as JSON, surfacing a clear error on malformed input. */ +async function readDefinition(file: string): Promise { + let raw: string; + try { + raw = await readFile(file, 'utf8'); + } catch (e) { + throw new SfError(messages.getMessage('error.definition-file-read', [file, (e as Error).message])); + } + try { + return JSON.parse(raw) as LightningOutDefinition; + } catch (e) { + throw new SfError(messages.getMessage('error.definition-file-json', [file, (e as Error).message])); + } +} + +export default class LightningOut extends SfCommand { + 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 = { + 'definition-file': Flags.file({ + char: 'f', + summary: messages.getMessage('flags.definition-file.summary'), + description: messages.getMessage('flags.definition-file.description'), + required: true, + exists: true, + }), + 'output-dir': outputDirFlagLightning, + force: Flags.boolean({ + summary: messages.getMessage('flags.force.summary'), + description: messages.getMessage('flags.force.description'), + default: false, + }), + 'merge-iframe': Flags.boolean({ + summary: messages.getMessage('flags.merge-iframe.summary'), + description: messages.getMessage('flags.merge-iframe.description'), + default: false, + }), + 'target-org': Flags.optionalOrg({ + summary: messages.getMessage('flags.target-org.summary'), + }), + 'api-version': orgApiVersionFlagWithDeprecations, + loglevel, + }; + + public async run(): Promise { + const { flags } = await this.parse(LightningOut); + + const def = await readDefinition(flags['definition-file']); + + // Option B (retrieve-merge): read the org's current Trusted Domains for + // Inline Frames so the generator preserves them instead of wiping the list. + let existingIframeEntries: IframeWhiteListEntry[] | undefined; + if (flags['merge-iframe']) { + const org = flags['target-org']; + if (!org) { + throw new SfError(messages.getMessage('error.merge-iframe-requires-org')); + } + existingIframeEntries = await retrieveIframeEntries(org.getConnection(flags['api-version'])); + this.info(messages.getMessage('info.merged-iframe-count', [existingIframeEntries.length])); + } else { + this.warn(messages.getMessage('warning.iframe-replace')); + } + + const flagsAsOptions: LightningOutOptions = { + name: def.name as string, + runtime: def.runtime as LightningOutOptions['runtime'], + components: def.components as string[], + hostDomains: def.hostDomains as string[], + eca: def.eca as LightningOutOptions['eca'], + outputdir: flags['output-dir'], + apiversion: flags['api-version'], + force: flags.force, + existingIframeEntries, + }; + + return runGenerator({ + templateType: TemplateType.LightningOut, + opts: flagsAsOptions, + ux: new Ux({ jsonEnabled: this.jsonEnabled() }), + templates: getCustomTemplates(this.configAggregator), + }); + } +} diff --git a/src/utils/lightningOutIframe.ts b/src/utils/lightningOutIframe.ts new file mode 100644 index 00000000..4c2190fe --- /dev/null +++ b/src/utils/lightningOutIframe.ts @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2026, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { Connection } from '@salesforce/core'; +import { IframeWhiteListEntry } from '@salesforce/templates'; + +/** Metadata API full name of the singleton IframeWhiteListUrlSettings record. */ +const IFRAME_SETTINGS_TYPE = 'IframeWhiteListUrlSettings'; + +/** Shape of one element as returned by the Metadata API read(). */ +type RawIframeUrl = { + url?: string; + context?: string; +}; + +/** Shape of the IframeWhiteListUrlSettings metadata record. */ +type RawIframeSettings = { + fullName?: string; + iframeWhiteListUrls?: RawIframeUrl | RawIframeUrl[]; +}; + +/** + * Normalize the Metadata API's read() result — which returns a single object + * for a scalar field and an array for a repeated field — into a plain array. + */ +function toArray(value: T | T[] | undefined): T[] { + if (value === undefined || value === null) { + return []; + } + return Array.isArray(value) ? value : [value]; +} + +/** + * Option B (retrieve-merge). Read the org's CURRENT "Trusted Domains for Inline + * Frames" list (IframeWhiteListUrlSettings) and return every entry as a + * {url, context} pair — across ALL IFrame Types, not just LightningOut — so the + * generator can re-emit them verbatim and avoid wiping the org's list when the + * REPLACE-type settings artifact is deployed. + */ +export async function retrieveIframeEntries(conn: Connection): Promise { + // `metadata.read` types the metadata type as a closed union that predates + // IframeWhiteListUrlSettings, so cast through the generic string overload. + const read = await conn.metadata.read( + IFRAME_SETTINGS_TYPE as Parameters[0], + IFRAME_SETTINGS_TYPE + ); + const record = (Array.isArray(read) ? read[0] : read) as RawIframeSettings | undefined; + + return toArray(record?.iframeWhiteListUrls) + .filter((u): u is RawIframeUrl & { url: string } => typeof u?.url === 'string' && u.url.length > 0) + .map((u) => ({ + url: u.url, + context: typeof u.context === 'string' && u.context.length > 0 ? u.context : 'LightningOut', + })); +} diff --git a/test/commands/template/generate/lightning-out/index.nut.ts b/test/commands/template/generate/lightning-out/index.nut.ts new file mode 100644 index 00000000..8999f984 --- /dev/null +++ b/test/commands/template/generate/lightning-out/index.nut.ts @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2026, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import path from 'node:path'; +import fs from 'node:fs'; +import { expect, config } from 'chai'; +import { TestSession, execCmd } from '@salesforce/cli-plugins-testkit'; +import assert from 'yeoman-assert'; + +config.truncateThreshold = 0; + +describe('template generate lightning-out:', () => { + let session: TestSession; + let defFile: string; + + before(async () => { + session = await TestSession.create({ + project: {}, + devhubAuthStrategy: 'NONE', + }); + defFile = path.join(session.project.dir, 'lo-def.json'); + fs.writeFileSync( + defFile, + JSON.stringify({ + name: 'MyLoApp', + runtime: 'LWR_CORE', + components: ['c-my-button', 'c-my-card'], + hostDomains: ['https://app.example.com', 'https://portal.example.com'], + eca: { contactEmail: 'dev@example.com', distributionState: 'Local', oauthScopes: ['Web', 'Api'] }, + }) + ); + }); + after(async () => { + await session?.clean(); + }); + + const outDir = (name: string): string => path.join(session.project.dir, name); + + const allArtifacts = (dir: string): string[] => [ + path.join(dir, 'lightningOutApps', 'MyLoApp.lightningOutApp-meta.xml'), + path.join(dir, 'iframeWhiteListUrlSettings', 'IframeWhiteListUrlSettings.iframeWhiteListUrlSettings-meta.xml'), + path.join(dir, 'settings', 'MyDomain.settings-meta.xml'), + path.join(dir, 'settings', 'Security.settings-meta.xml'), + path.join(dir, 'corsWhitelistOrigins', 'app_example_com.corsWhitelistOrigin-meta.xml'), + path.join(dir, 'corsWhitelistOrigins', 'portal_example_com.corsWhitelistOrigin-meta.xml'), + path.join(dir, 'externalClientApps', 'MyLoApp.eca-meta.xml'), + path.join(dir, 'extlClntAppGlobalOauthSets', 'MyLoApp.ecaGlblOauth-meta.xml'), + path.join(dir, 'extlClntAppOauthSettings', 'MyLoApp.ecaOauth-meta.xml'), + ]; + + describe('generation', () => { + it('should scaffold all nine metadata artifacts', () => { + const dir = outDir('gen-all'); + execCmd(`template generate lightning-out --definition-file ${defFile} --output-dir ${dir}`, { + ensureExitCode: 0, + }); + assert.file(allArtifacts(dir)); + }); + + it('should render app name, runtime, and components into the LightningOutApp', () => { + const dir = outDir('gen-app'); + execCmd(`template generate lightning-out --definition-file ${defFile} --output-dir ${dir}`, { + ensureExitCode: 0, + }); + const app = path.join(dir, 'lightningOutApps', 'MyLoApp.lightningOutApp-meta.xml'); + assert.fileContent(app, 'MyLoApp'); + assert.fileContent(app, 'LWR_CORE'); + assert.fileContent(app, 'c-my-button'); + assert.fileContent(app, 'c-my-card'); + }); + + it('should list this app host domains under LightningOut context in the iframe artifact', () => { + const dir = outDir('gen-iframe'); + execCmd(`template generate lightning-out --definition-file ${defFile} --output-dir ${dir}`, { + ensureExitCode: 0, + }); + const iframe = path.join( + dir, + 'iframeWhiteListUrlSettings', + 'IframeWhiteListUrlSettings.iframeWhiteListUrlSettings-meta.xml' + ); + assert.fileContent(iframe, 'https://app.example.com'); + assert.fileContent(iframe, 'https://portal.example.com'); + assert.fileContent(iframe, 'LightningOut'); + }); + + it('should warn about the REPLACE risk when not merging', () => { + const dir = outDir('gen-warn'); + const result = execCmd(`template generate lightning-out --definition-file ${defFile} --output-dir ${dir}`, { + ensureExitCode: 0, + }); + expect(result.shellOutput.stderr).to.match(/REPLACES your org's entire/i); + }); + }); + + describe('Option A — no silent overwrite', () => { + it('should fail on a second run without --force', () => { + const dir = outDir('gen-guard'); + execCmd(`template generate lightning-out --definition-file ${defFile} --output-dir ${dir}`, { + ensureExitCode: 0, + }); + const stderr = execCmd(`template generate lightning-out --definition-file ${defFile} --output-dir ${dir}`, { + ensureExitCode: 'nonZero', + }).shellOutput.stderr; + expect(stderr).to.match(/already exist/i); + }); + + it('should overwrite on a second run with --force', () => { + const dir = outDir('gen-force'); + execCmd(`template generate lightning-out --definition-file ${defFile} --output-dir ${dir}`, { + ensureExitCode: 0, + }); + execCmd(`template generate lightning-out --definition-file ${defFile} --output-dir ${dir} --force`, { + ensureExitCode: 0, + }); + assert.file(allArtifacts(dir)); + }); + }); + + describe('failures', () => { + it('should error when --definition-file is missing', () => { + const stderr = execCmd('template generate lightning-out').shellOutput.stderr; + expect(stderr).to.contain('Missing required flag'); + }); + + it('should error when --definition-file does not exist', () => { + const stderr = execCmd( + `template generate lightning-out --definition-file ${path.join(session.project.dir, 'nope.json')}` + ).shellOutput.stderr; + expect(stderr).to.match(/No file found|does not exist|cannot find/i); + }); + + it('should error on an invalid definition (bad runtime)', () => { + const bad = path.join(session.project.dir, 'bad-runtime.json'); + fs.writeFileSync( + bad, + JSON.stringify({ + name: 'BadApp', + runtime: 'NOPE', + components: ['c-x'], + hostDomains: ['https://app.example.com'], + eca: { contactEmail: 'dev@example.com' }, + }) + ); + const stderr = execCmd( + `template generate lightning-out --definition-file ${bad} --output-dir ${outDir('bad-runtime')}`, + { ensureExitCode: 'nonZero' } + ).shellOutput.stderr; + expect(stderr).to.match(/runtime/i); + }); + + it('should error on malformed JSON', () => { + const bad = path.join(session.project.dir, 'bad-json.json'); + fs.writeFileSync(bad, '{ not valid json '); + const stderr = execCmd( + `template generate lightning-out --definition-file ${bad} --output-dir ${outDir('bad-json')}`, + { ensureExitCode: 'nonZero' } + ).shellOutput.stderr; + expect(stderr).to.match(/not valid JSON/i); + }); + + it('should error when --merge-iframe is set without --target-org', () => { + const stderr = execCmd( + `template generate lightning-out --definition-file ${defFile} --output-dir ${outDir( + 'merge-noorg' + )} --merge-iframe`, + { ensureExitCode: 'nonZero' } + ).shellOutput.stderr; + expect(stderr).to.match(/requires a target org|target-org/i); + }); + }); +}); diff --git a/test/utils/lightningOutIframe.test.ts b/test/utils/lightningOutIframe.test.ts new file mode 100644 index 00000000..80f2d52c --- /dev/null +++ b/test/utils/lightningOutIframe.test.ts @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2026, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import { expect } from 'chai'; +import { Connection } from '@salesforce/core'; +import { retrieveIframeEntries } from '../../src/utils/lightningOutIframe.js'; + +/** Build a fake Connection whose metadata.read returns `readResult`. */ +function fakeConn(readResult: unknown): Connection { + return { + metadata: { + read: () => Promise.resolve(readResult), + }, + } as unknown as Connection; +} + +describe('retrieveIframeEntries', () => { + it('returns [] when the settings record has no entries', async () => { + const entries = await retrieveIframeEntries(fakeConn({ fullName: 'IframeWhiteListUrlSettings' })); + expect(entries).to.deep.equal([]); + }); + + it('normalizes a single (scalar) entry into a one-element array', async () => { + const entries = await retrieveIframeEntries( + fakeConn({ iframeWhiteListUrls: { url: 'https://vf.example.com', context: 'Visualforce' } }) + ); + expect(entries).to.deep.equal([{ url: 'https://vf.example.com', context: 'Visualforce' }]); + }); + + it('preserves the context of every entry across IFrame Types', async () => { + const entries = await retrieveIframeEntries( + fakeConn({ + iframeWhiteListUrls: [ + { url: 'https://vf.example.com', context: 'Visualforce' }, + { url: 'https://survey.example.com', context: 'Survey' }, + { url: 'https://lo.example.com', context: 'LightningOut' }, + ], + }) + ); + expect(entries).to.deep.equal([ + { url: 'https://vf.example.com', context: 'Visualforce' }, + { url: 'https://survey.example.com', context: 'Survey' }, + { url: 'https://lo.example.com', context: 'LightningOut' }, + ]); + }); + + it('defaults a missing context to LightningOut', async () => { + const entries = await retrieveIframeEntries(fakeConn({ iframeWhiteListUrls: { url: 'https://x.example.com' } })); + expect(entries).to.deep.equal([{ url: 'https://x.example.com', context: 'LightningOut' }]); + }); + + it('drops entries with no url', async () => { + const entries = await retrieveIframeEntries( + fakeConn({ + iframeWhiteListUrls: [{ url: 'https://ok.example.com', context: 'LightningOut' }, { context: 'Visualforce' }], + }) + ); + expect(entries).to.deep.equal([{ url: 'https://ok.example.com', context: 'LightningOut' }]); + }); + + it('handles read() returning an array of records', async () => { + const entries = await retrieveIframeEntries( + fakeConn([{ iframeWhiteListUrls: { url: 'https://a.example.com', context: 'LightningOut' } }]) + ); + expect(entries).to.deep.equal([{ url: 'https://a.example.com', context: 'LightningOut' }]); + }); +});