Skip to content

Commit 5d3302b

Browse files
authored
Merge pull request #818 from Shinoni/feat/webapp-generate-command
@W-20178308 feat: add webapp generate command
2 parents b7cbb2c + 5997b53 commit 5d3302b

6 files changed

Lines changed: 283 additions & 12 deletions

File tree

command-snapshot.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,5 +106,13 @@
106106
"flagChars": ["d", "l", "n", "t"],
107107
"flags": ["api-version", "flags-dir", "json", "label", "loglevel", "name", "output-dir", "template"],
108108
"plugin": "@salesforce/plugin-templates"
109+
},
110+
{
111+
"alias": [],
112+
"command": "webapp:generate",
113+
"flagAliases": [],
114+
"flagChars": ["d", "l", "n", "t"],
115+
"flags": ["api-version", "flags-dir", "json", "label", "name", "output-dir", "template"],
116+
"plugin": "@salesforce/plugin-templates"
109117
}
110118
]

messages/webApplication.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# summary
2+
3+
Generate a web application.
4+
5+
# description
6+
7+
Generates a web application in the specified directory or the current working directory. The web application files are created in a folder with the designated name. Web application files must be contained in a parent directory called "webApplications" in your package directory. Either run this command from an existing directory of this name, or use the --output-dir flag to create one or point to an existing one.
8+
9+
# examples
10+
11+
- Generate a web application called MyWebApp in the current directory:
12+
13+
<%= config.bin %> <%= command.id %> --name MyWebApp
14+
15+
- Generate a React-based web application:
16+
17+
<%= config.bin %> <%= command.id %> --name MyReactApp --template reactbasic
18+
19+
- Generate the web application in the "force-app/main/default/webApplications" directory:
20+
21+
<%= config.bin %> <%= command.id %> --name MyWebApp --output-dir force-app/main/default/webApplications
22+
23+
# flags.name.summary
24+
25+
Name of the generated web application.
26+
27+
# flags.name.description
28+
29+
This name can contain only underscores and alphanumeric characters, and must be unique in your org. It must begin with a letter, not include spaces, not end with an underscore, and not contain two consecutive underscores.
30+
31+
# flags.template.summary
32+
33+
Template to use for file creation.
34+
35+
# flags.template.description
36+
37+
Supplied parameter values or default values are filled into a copy of the template.
38+
39+
# flags.label.summary
40+
41+
Master label for the web application.
42+
43+
# flags.label.description
44+
45+
If not specified, the label is derived from the name.
46+
47+
# flags.output-dir.summary
48+
49+
Directory for saving the created files.
50+
51+
# flags.output-dir.description
52+
53+
The location can be an absolute path or relative to the current working directory. If not specified, the command reads your sfdx-project.json and defaults to the webApplications directory within your default package directory. When running outside a Salesforce DX project, defaults to the current directory.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"dependencies": {
88
"@salesforce/core": "^8.24.0",
99
"@salesforce/sf-plugins-core": "^12",
10-
"@salesforce/templates": "^64.3.2"
10+
"@salesforce/templates": "^65.4.1"
1111
},
1212
"devDependencies": {
1313
"@oclif/plugin-command-snapshot": "^5.3.8",

src/commands/webapp/generate.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* Copyright (c) 2025, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import path from 'node:path';
9+
import { Flags, SfCommand, Ux } from '@salesforce/sf-plugins-core';
10+
import { CreateOutput, WebApplicationOptions, TemplateType } from '@salesforce/templates';
11+
import { Messages, SfProject } from '@salesforce/core';
12+
import { getCustomTemplates, runGenerator } from '../../utils/templateCommand.js';
13+
14+
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
15+
const messages = Messages.loadMessages('@salesforce/plugin-templates', 'webApplication');
16+
17+
export default class WebAppGenerate extends SfCommand<CreateOutput> {
18+
public static readonly summary = messages.getMessage('summary');
19+
public static readonly description = messages.getMessage('description');
20+
public static readonly examples = messages.getMessages('examples');
21+
public static readonly hidden = true; // Hide from external developers until GA
22+
public static readonly flags = {
23+
name: Flags.string({
24+
char: 'n',
25+
summary: messages.getMessage('flags.name.summary'),
26+
description: messages.getMessage('flags.name.description'),
27+
required: true,
28+
}),
29+
template: Flags.string({
30+
char: 't',
31+
summary: messages.getMessage('flags.template.summary'),
32+
description: messages.getMessage('flags.template.description'),
33+
default: 'default',
34+
options: ['default', 'reactbasic'],
35+
}),
36+
label: Flags.string({
37+
char: 'l',
38+
summary: messages.getMessage('flags.label.summary'),
39+
description: messages.getMessage('flags.label.description'),
40+
}),
41+
'output-dir': Flags.directory({
42+
char: 'd',
43+
summary: messages.getMessage('flags.output-dir.summary'),
44+
description: messages.getMessage('flags.output-dir.description'),
45+
}),
46+
'api-version': Flags.orgApiVersion(),
47+
};
48+
49+
/**
50+
* Resolves the default output directory by reading the project's sfdx-project.json.
51+
* Returns the path to webApplications under the default package directory,
52+
* or falls back to the current directory if not in a project context.
53+
*/
54+
private static async getDefaultOutputDir(): Promise<string> {
55+
try {
56+
const project = await SfProject.resolve();
57+
const defaultPackage = project.getDefaultPackage();
58+
return path.join(defaultPackage.path, 'main', 'default', 'webApplications');
59+
} catch {
60+
return '.';
61+
}
62+
}
63+
64+
public async run(): Promise<CreateOutput> {
65+
const { flags } = await this.parse(WebAppGenerate);
66+
67+
const outputDir = flags['output-dir'] ?? (await WebAppGenerate.getDefaultOutputDir());
68+
69+
const flagsAsOptions: WebApplicationOptions = {
70+
webappname: flags.name,
71+
template: flags.template,
72+
masterlabel: flags.label,
73+
outputdir: outputDir,
74+
apiversion: flags['api-version'],
75+
};
76+
77+
return runGenerator({
78+
templateType: TemplateType.WebApplication,
79+
opts: flagsAsOptions,
80+
ux: new Ux({ jsonEnabled: this.jsonEnabled() }),
81+
templates: getCustomTemplates(this.configAggregator),
82+
});
83+
}
84+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/*
2+
* Copyright (c) 2025, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
import path from 'node:path';
8+
import { expect } from 'chai';
9+
import { TestSession, execCmd } from '@salesforce/cli-plugins-testkit';
10+
import { nls } from '@salesforce/templates/lib/i18n/index.js';
11+
import assert from 'yeoman-assert';
12+
13+
describe('Web application creation tests:', () => {
14+
let session: TestSession;
15+
before(async () => {
16+
session = await TestSession.create({
17+
project: {},
18+
devhubAuthStrategy: 'NONE',
19+
});
20+
});
21+
after(async () => {
22+
await session?.clean();
23+
});
24+
25+
describe('Check webapp creation with default template', () => {
26+
it('should create webapp using default template in webApplications directory', () => {
27+
const outputDir = path.join(session.project.dir, 'force-app', 'main', 'default', 'webApplications');
28+
execCmd(`webapp generate --name MyWebApp --output-dir "${outputDir}"`, { ensureExitCode: 0 });
29+
assert.file([
30+
path.join(outputDir, 'MyWebApp', 'MyWebApp.webApplication-meta.xml'),
31+
path.join(outputDir, 'MyWebApp', 'index.html'),
32+
path.join(outputDir, 'MyWebApp', 'webapp.json'),
33+
]);
34+
assert.fileContent(path.join(outputDir, 'MyWebApp', 'index.html'), '<title>My Web App</title>');
35+
});
36+
37+
it('should default to project webApplications directory when --output-dir is omitted', () => {
38+
const expectedOutputDir = path.join(session.project.dir, 'force-app', 'main', 'default', 'webApplications');
39+
execCmd('webapp generate --name DefaultDirApp', { ensureExitCode: 0 });
40+
assert.file([
41+
path.join(expectedOutputDir, 'DefaultDirApp', 'DefaultDirApp.webApplication-meta.xml'),
42+
path.join(expectedOutputDir, 'DefaultDirApp', 'index.html'),
43+
path.join(expectedOutputDir, 'DefaultDirApp', 'webapp.json'),
44+
]);
45+
});
46+
47+
it('should create webapp with custom label', () => {
48+
const outputDir = path.join(session.project.dir, 'force-app', 'main', 'default', 'webApplications');
49+
execCmd(`webapp generate --name TestApp --label "Custom Label" --output-dir "${outputDir}"`, {
50+
ensureExitCode: 0,
51+
});
52+
assert.file([
53+
path.join(outputDir, 'TestApp', 'TestApp.webApplication-meta.xml'),
54+
path.join(outputDir, 'TestApp', 'index.html'),
55+
]);
56+
assert.fileContent(path.join(outputDir, 'TestApp', 'index.html'), '<title>Custom Label</title>');
57+
});
58+
});
59+
60+
describe('Check webapp creation with reactbasic template', () => {
61+
it('should create React webapp with all required files', () => {
62+
const outputDir = path.join(session.project.dir, 'force-app', 'main', 'default', 'webApplications');
63+
execCmd(`webapp generate --name MyReactApp --template reactbasic --output-dir "${outputDir}"`, {
64+
ensureExitCode: 0,
65+
});
66+
assert.file([
67+
path.join(outputDir, 'MyReactApp', 'MyReactApp.webApplication-meta.xml'),
68+
path.join(outputDir, 'MyReactApp', 'index.html'),
69+
path.join(outputDir, 'MyReactApp', 'webapp.json'),
70+
path.join(outputDir, 'MyReactApp', 'package.json'),
71+
path.join(outputDir, 'MyReactApp', 'vite.config.ts'),
72+
path.join(outputDir, 'MyReactApp', 'tsconfig.json'),
73+
path.join(outputDir, 'MyReactApp', 'tsconfig.node.json'),
74+
path.join(outputDir, 'MyReactApp', 'tailwind.config.js'),
75+
path.join(outputDir, 'MyReactApp', 'postcss.config.js'),
76+
path.join(outputDir, 'MyReactApp', 'src', 'main.tsx'),
77+
path.join(outputDir, 'MyReactApp', 'src', 'App.tsx'),
78+
path.join(outputDir, 'MyReactApp', 'src', 'routes.ts'),
79+
path.join(outputDir, 'MyReactApp', 'src', 'vite-env.d.ts'),
80+
path.join(outputDir, 'MyReactApp', 'src', 'components', 'Navigation.tsx'),
81+
path.join(outputDir, 'MyReactApp', 'src', 'pages', 'Home.tsx'),
82+
path.join(outputDir, 'MyReactApp', 'src', 'pages', 'About.tsx'),
83+
path.join(outputDir, 'MyReactApp', 'src', 'pages', 'NotFound.tsx'),
84+
path.join(outputDir, 'MyReactApp', 'src', 'styles', 'global.css'),
85+
path.join(outputDir, 'MyReactApp', 'src', 'test-setup', 'setup.ts'),
86+
]);
87+
assert.fileContent(path.join(outputDir, 'MyReactApp', 'package.json'), '"name": "MyReactApp"');
88+
});
89+
});
90+
91+
describe('Check that all invalid name errors are thrown', () => {
92+
it('should throw a missing name error', () => {
93+
const stderr = execCmd('webapp generate').shellOutput.stderr;
94+
expect(stderr).to.contain('Missing required flag');
95+
});
96+
97+
it('should throw invalid non alphanumeric webapp name error', () => {
98+
const outputDir = path.join(session.project.dir, 'force-app', 'main', 'default', 'webApplications');
99+
const stderr = execCmd(`webapp generate --name /a --output-dir "${outputDir}"`).shellOutput.stderr;
100+
expect(stderr).to.contain(nls.localize('AlphaNumericNameError'));
101+
});
102+
103+
it('should throw invalid webapp name starting with numeric error', () => {
104+
const outputDir = path.join(session.project.dir, 'force-app', 'main', 'default', 'webApplications');
105+
const stderr = execCmd(`webapp generate --name 3aa --output-dir "${outputDir}"`).shellOutput.stderr;
106+
expect(stderr).to.contain(nls.localize('NameMustStartWithLetterError'));
107+
});
108+
109+
it('should throw invalid webapp name ending with underscore error', () => {
110+
const outputDir = path.join(session.project.dir, 'force-app', 'main', 'default', 'webApplications');
111+
const stderr = execCmd(`webapp generate --name a_ --output-dir "${outputDir}"`).shellOutput.stderr;
112+
expect(stderr).to.contain(nls.localize('EndWithUnderscoreError'));
113+
});
114+
115+
it('should throw invalid webapp name with double underscore error', () => {
116+
const outputDir = path.join(session.project.dir, 'force-app', 'main', 'default', 'webApplications');
117+
const stderr = execCmd(`webapp generate --name a__a --output-dir "${outputDir}"`).shellOutput.stderr;
118+
expect(stderr).to.contain(nls.localize('DoubleUnderscoreError'));
119+
});
120+
121+
it('should throw error when output dir is not webApplications folder', () => {
122+
const stderr = execCmd('webapp generate --name TestApp --output-dir /tmp/invalid').shellOutput.stderr;
123+
expect(stderr).to.contain(nls.localize('MissingWebApplicationsDir'));
124+
});
125+
});
126+
});

yarn.lock

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1651,18 +1651,18 @@
16511651
cli-progress "^3.12.0"
16521652
terminal-link "^3.0.0"
16531653

1654-
"@salesforce/templates@^64.3.2":
1655-
version "64.3.2"
1656-
resolved "https://registry.yarnpkg.com/@salesforce/templates/-/templates-64.3.2.tgz#bf7461438255470ba262a5be49912e2cab5f3779"
1657-
integrity sha512-j6T07E3w/7Dz4jG1wwuw//WKUomFYOvMOEQrXobkNO7H25dMnnfcfbnUd9KPt9G4b04C+GU1FKv3nEOt8umKww==
1654+
"@salesforce/templates@65.4.1":
1655+
version "65.4.1"
1656+
resolved "https://registry.yarnpkg.com/@salesforce/templates/-/templates-65.4.1.tgz#9f50d02a2fa59802a8b2657f69a26b72cd24c2c6"
1657+
integrity sha512-tFIsTMzG3PpnZxNozSAWBGLemdXfEhZEvqo3ClY9JcCTe5iCLCj7bRkAmyvxCozy+3p+FVc89G9gBKV6Xxes/w==
16581658
dependencies:
16591659
"@salesforce/kit" "^3.2.4"
16601660
ejs "^3.1.10"
16611661
got "^11.8.6"
16621662
hpagent "^1.2.0"
1663-
mime-types "^3.0.1"
1663+
mime-types "^3.0.2"
16641664
proxy-from-env "^1.1.0"
1665-
tar "^7.5.1"
1665+
tar "^7.5.2"
16661666
tslib "^2.8.1"
16671667

16681668
"@salesforce/ts-types@^2.0.11", "@salesforce/ts-types@^2.0.12":
@@ -5798,10 +5798,10 @@ mime-types@^2.1.12:
57985798
dependencies:
57995799
mime-db "1.52.0"
58005800

5801-
mime-types@^3.0.1:
5802-
version "3.0.1"
5803-
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-3.0.1.tgz#b1d94d6997a9b32fd69ebaed0db73de8acb519ce"
5804-
integrity sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==
5801+
mime-types@^3.0.2:
5802+
version "3.0.2"
5803+
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-3.0.2.tgz#39002d4182575d5af036ffa118100f2524b2e2ab"
5804+
integrity sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==
58055805
dependencies:
58065806
mime-db "^1.54.0"
58075807

@@ -7343,7 +7343,7 @@ supports-preserve-symlinks-flag@^1.0.0:
73437343
resolved "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz"
73447344
integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
73457345

7346-
tar@^7.5.1:
7346+
tar@^7.5.2:
73477347
version "7.5.2"
73487348
resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.2.tgz#115c061495ec51ff3c6745ff8f6d0871c5b1dedc"
73497349
integrity sha512-7NyxrTE4Anh8km8iEy7o0QYPs+0JKBTj5ZaqHg6B39erLg0qYXN3BijtShwbsNSvQ+LN75+KV+C4QR/f6Gwnpg==

0 commit comments

Comments
 (0)