diff --git a/messages/lightningOut.md b/messages/lightningOut.md new file mode 100644 index 00000000..9fafa989 --- /dev/null +++ b/messages/lightningOut.md @@ -0,0 +1,51 @@ +# 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 + +# 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. + +# 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. + +# 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 . + +# 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..ab2b3307 --- /dev/null +++ b/src/commands/template/generate/lightning-out/index.ts @@ -0,0 +1,92 @@ +/* + * 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, LightningOutOptions, TemplateType } from '@salesforce/templates'; +import { Messages, SfError } 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'); + +/** 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, + }), + 'api-version': orgApiVersionFlagWithDeprecations, + loglevel, + }; + + public async run(): Promise { + const { flags } = await this.parse(LightningOut); + + const def = await readDefinition(flags['definition-file']); + + 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, + }; + + return runGenerator({ + templateType: TemplateType.LightningOut, + opts: flagsAsOptions, + ux: new Ux({ jsonEnabled: this.jsonEnabled() }), + templates: getCustomTemplates(this.configAggregator), + }); + } +} 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..1c5bc5cc --- /dev/null +++ b/test/commands/template/generate/lightning-out/index.nut.ts @@ -0,0 +1,165 @@ +/* + * 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); + }); + }); +});