Skip to content
Draft
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
51 changes: 51 additions & 0 deletions messages/lightningOut.md
Original file line number Diff line number Diff line change
@@ -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 <org>.

# error.definition-file-read

Unable to read definition file %s: %s

# error.definition-file-json

Definition file %s is not valid JSON: %s
92 changes: 92 additions & 0 deletions src/commands/template/generate/lightning-out/index.ts
Original file line number Diff line number Diff line change
@@ -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<LightningOutDefinition> {
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<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 = {
'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<CreateOutput> {
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),
});
}
}
165 changes: 165 additions & 0 deletions test/commands/template/generate/lightning-out/index.nut.ts
Original file line number Diff line number Diff line change
@@ -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, '<applicationName>MyLoApp</applicationName>');
assert.fileContent(app, '<runtime>LWR_CORE</runtime>');
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, '<url>https://app.example.com</url>');
assert.fileContent(iframe, '<url>https://portal.example.com</url>');
assert.fileContent(iframe, '<context>LightningOut</context>');
});

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);
});
});
});