Skip to content

Commit

Permalink
feat: generate command without yeoman
Browse files Browse the repository at this point in the history
  • Loading branch information
mdonnalley committed Jul 25, 2024
1 parent 0df85ed commit da1b265
Show file tree
Hide file tree
Showing 6 changed files with 251 additions and 205 deletions.
2 changes: 1 addition & 1 deletion command-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"command": "dev:generate:command",
"flagAliases": [],
"flagChars": ["n"],
"flags": ["flags-dir", "force", "name", "nuts", "unit"],
"flags": ["dry-run", "flags-dir", "force", "name", "nuts", "unit"],
"plugin": "@salesforce/plugin-dev"
},
{
Expand Down
10 changes: 5 additions & 5 deletions messages/dev.generate.command.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Generate a new sf command.

You must run this command from within a plugin directory, such as the directory created with the "sf dev generate plugin" command.

The command generates basic source files, messages (\*.md), and test files for your new command. The Typescript files contain import statements for the minimum required Salesforce libraries, and scaffold some basic code. The new type names come from the value you passed to the --name flag.
The command generates basic source files, messages (\*.md), and test files for your new command. The Typescript files contain import statements for the minimum required Salesforce libraries, and scaffold some basic code. The new type names come from the value you passed to the --name flag.

The command updates the package.json file, so if it detects conflicts with the existing file, you're prompted whether you want to overwrite the file. There are a number of package.json updates required for a new command, so we recommend you answer "y" so the command takes care of them all. If you answer "n", you must update the package.json file manually.

Expand All @@ -26,12 +26,12 @@ Generate a unit test file for the command.

Name of the new command. Use colons to separate the topic and command names.

# flags.dry-run.summary

Output the generated files without writing them to disk.

# examples

- Generate the files for a new "sf my exciting command":

<%= config.bin %> <%= command.id %> --name my:exciting:command

# errors.InvalidDir

This command must be run inside a plugin directory.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@salesforce/sf-plugins-core": "^11.1.1",
"@salesforce/ts-types": "^2.0.10",
"change-case": "^5.4.2",
"ejs": "^3.1.10",
"fast-glob": "^3.3.2",
"graphology": "^0.25.4",
"graphology-types": "^0.24.7",
Expand Down
141 changes: 135 additions & 6 deletions src/commands/dev/generate/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,63 @@
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import { dirname, join, relative, sep } from 'node:path';
import { Messages } from '@salesforce/core';
import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
import { fileExists, generate } from '../../../util.js';
import defaultsDeep from 'lodash.defaultsdeep';
import { pascalCase } from 'change-case';
import { set } from '@salesforce/kit';
import { get } from '@salesforce/ts-types';
import { Generator } from '../../../generator.js';
import { Topic } from '../../../types.js';

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
const messages = Messages.loadMessages('@salesforce/plugin-dev', 'dev.generate.command');

/** returns the modifications that need to be made for the oclif pjson topics information. Returns an empty object for "don't change anything" */
export function addTopics(
newCmd: string,
existingTopics: Record<string, Topic>,
commands: string[] = []
): Record<string, Topic> {
const updated: Record<string, Topic> = {};

const paths = newCmd
.split(':')
// omit last word since it's not a topic, it's the command name
.slice(0, -1)
.map((_, index, array) => array.slice(0, index + 1).join('.'))
// reverse to build up the object from most specific to least
.reverse();

for (const p of paths) {
const pDepth = p.split('.').length;
// if new command if foo.bar and there are any commands in the foo topic, this should be marked external.
// if new command if foo.bar.baz and there are any commands in the foo.bar subtopic, it should be marked external.
const isExternal = commands.some((c) => c.split('.').slice(0, pDepth).join('.') === p);
const existing = get(updated, p);
if (existing) {
const merged = isExternal
? {
external: true,
subtopics: existing,
}
: {
description: get(existingTopics, `${p}.description`, `description for ${p}`),
subtopics: existing,
};
set(updated, p, merged);
} else {
const entry = isExternal
? { external: true }
: { description: get(existingTopics, `${p}.description`, `description for ${p}`) };
set(updated, p, entry);
}
}

return updated;
}

export default class GenerateCommand extends SfCommand<void> {
public static readonly enableJsonFlag = false;
public static readonly summary = messages.getMessage('summary');
Expand All @@ -27,6 +77,9 @@ export default class GenerateCommand extends SfCommand<void> {
force: Flags.boolean({
summary: messages.getMessage('flags.force.summary'),
}),
'dry-run': Flags.boolean({
summary: messages.getMessage('flags.dry-run.summary'),
}),
nuts: Flags.boolean({
summary: messages.getMessage('flags.nuts.summary'),
allowNo: true,
Expand All @@ -41,12 +94,88 @@ export default class GenerateCommand extends SfCommand<void> {

public async run(): Promise<void> {
const { flags } = await this.parse(GenerateCommand);
if (!(await fileExists('package.json'))) throw messages.createError('errors.InvalidDir');
await generate('command', {
name: flags.name,

const generator = await Generator.create({
force: flags.force,
nuts: flags.nuts,
unit: flags.unit,
dryRun: flags['dry-run'],
});
this.log(`Adding a command to ${generator.pjson.name}!`);

if (Object.keys(generator.pjson.devDependencies).includes('@salesforce/plugin-command-reference')) {
// Get a list of all commands in `sf`. We will use this to determine if a topic is internal or external.
const commands = this.config.commandIDs.map((command) => command.replace(/:/g, '.').replace(/ /g, '.'));
const newTopics = addTopics(flags.name, generator.pjson.oclif.topics, commands);
defaultsDeep(generator.pjson.oclif.topics, newTopics);
} else {
const newTopics = addTopics(flags.name, generator.pjson.oclif.topics);
defaultsDeep(generator.pjson.oclif.topics, newTopics);
}

await generator.writePjson();

const cmdPath = flags.name.split(':').join('/');
const commandPath = `src/commands/${cmdPath}.ts`;
const className = pascalCase(flags.name);
const opts = {
className,
returnType: `${className}Result`,
commandPath,
year: new Date().getFullYear(),
pluginName: generator.pjson.name,
messageFile: flags.name.replace(/:/g, '.'),
};

// generate the command file
await generator.render(
generator.pjson.type === 'module' ? 'src/esm-command.ts.ejs' : 'src/cjs-command.ts.ejs',
commandPath,
opts
);

// generate the message file
await generator.render('messages/message.md.ejs', `messages/${flags.name.replace(/:/g, '.')}.md`);

// generate the nuts file
if (flags.nuts) {
await generator.render('test/command.nut.ts.ejs', `test/commands/${cmdPath}.nut.ts`, {
cmd: flags.name.replace(/:/g, ' '),
year: new Date().getFullYear(),
pluginName: generator.pjson.name,
messageFile: flags.name.replace(/:/g, '.'),
});
}

// generate the unit test file
if (flags.unit) {
const unitPath = `test/commands/${cmdPath}.test.ts`;
const relativeCmdPath = relative(dirname(unitPath), commandPath).replace('.ts', '').replaceAll(sep, '/');
await generator.render(
generator.pjson.type === 'module' ? 'test/esm-command.test.ts.ejs' : 'test/cjs-command.test.ts.ejs',
`test/commands/${cmdPath}.test.ts`,
{
className,
commandPath,
relativeCmdPath,
name: flags.name.replace(/:/g, ' '),
year: new Date().getFullYear(),
pluginName: generator.pjson.name,
}
);
}

// run the format, lint, and compile scripts
generator.execute('yarn format');
generator.execute('yarn lint -- --fix');
generator.execute('yarn compile');

const localExecutable = process.platform === 'win32' ? join('bin', 'dev.cmd') : join('bin', 'dev.js');

if (generator.pjson.scripts['test:deprecation-policy']) {
generator.execute(`${localExecutable} snapshot:generate`);
}

if (generator.pjson.scripts['test:json-schema']) {
generator.execute(`${localExecutable} schema:generate`);
}
}
}
109 changes: 109 additions & 0 deletions src/generator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/*
* Copyright (c) 2024, 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 { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { renderFile } from 'ejs';
import { Ux } from '@salesforce/sf-plugins-core';
import { Logger, SfError } from '@salesforce/core';
import { AsyncOptionalCreatable } from '@salesforce/kit';
import { colorize } from '@oclif/core/ux';
import shelljs from 'shelljs';
import { fileExists } from './util.js';
import { PackageJson } from './types.js';

async function readPackageJson(): Promise<PackageJson> {
try {
return JSON.parse(await readFile('package.json', 'utf-8')) as PackageJson;
} catch {
throw new SfError('This command must be run inside a plugin directory');
}
}

export class Generator extends AsyncOptionalCreatable {
public pjson!: PackageJson;
private templatesDir!: string;
private force: boolean | undefined;
private dryRun: boolean | undefined;
private ux = new Ux();
private logger = Logger.childFromRoot('dev-generator');

public constructor(opts?: { dryRun?: boolean; force?: boolean }) {
super(opts);
this.dryRun = opts?.dryRun;
this.force = opts?.dryRun ?? opts?.force;
this.templatesDir = join(dirname(fileURLToPath(import.meta.url)), '../templates');
this.logger = Logger.childFromRoot('dev-generator');
this.logger.debug(`Templates directory: ${this.templatesDir}`);
}

public async render(source: string, destination: string, data?: Record<string, unknown>): Promise<void> {
const fullSource = join(this.templatesDir, source);
const dryRunMsg = this.dryRun ? '[DRY RUN] ' : '';
this.logger.debug(`${dryRunMsg}Rendering template ${fullSource} to ${destination}`);

const rendered = await new Promise<string>((resolve, reject) => {
renderFile(fullSource, data ?? {}, (err, str) => {
if (err) reject(err);
return resolve(str);
});
});

let verb = 'Creating';
if (rendered) {
const relativePath = relative(process.cwd(), destination);
if (await fileExists(destination)) {
const confirmation =
this.force ??
(await (
await import('@inquirer/confirm')
).default({
message: `Overwrite ${relativePath}?`,
}));
if (confirmation) {
verb = 'Overwriting';
} else {
this.ux.log(`${dryRunMsg}${colorize('yellow', 'Skipping')} ${relativePath}`);
return;
}
}

this.ux.log(`${dryRunMsg}${colorize('yellow', verb)} ${relativePath}`);

if (!this.dryRun) {
await mkdir(dirname(destination), { recursive: true });
await writeFile(destination, rendered);
}
}
}

public execute(cmd: string): void {
if (this.dryRun) {
this.ux.log(`[DRY RUN] ${cmd}`);
return;
}

this.logger.debug(`Executing command: ${cmd}`);
shelljs.exec(cmd);
}

public async writePjson(): Promise<void> {
const updating = colorize('yellow', 'Updating');
if (this.dryRun) {
this.ux.log(`[DRY RUN] ${updating} package.json`);
return;
}

this.ux.log(`${updating} package.json`);
await writeFile('package.json', JSON.stringify(this.pjson, null, 2));
}

protected async init(): Promise<void> {
this.pjson = await readPackageJson();
}
}
Loading

0 comments on commit da1b265

Please sign in to comment.