Skip to content

Commit

Permalink
feat: add prompts to flag generator
Browse files Browse the repository at this point in the history
  • Loading branch information
mdonnalley committed Sep 9, 2022
1 parent 5c3698c commit 8bef368
Show file tree
Hide file tree
Showing 5 changed files with 269 additions and 41 deletions.
12 changes: 2 additions & 10 deletions messages/dev.generate.flag.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,9 @@ Summary of a command.

Description of a command.

# flags.name.description
# flags.dry-run.summary

Name of the flag to create.

# flags.command.description

Command to add flag to.
Print new flag instead of adding it to the command file.

# examples

Expand All @@ -21,7 +17,3 @@ Command to add flag to.
# errors.InvalidDir

This command can only be run inside a plugin directory.

# errors.FlagExists

This command already has a flag named %s.
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
"author": "Salesforce",
"bugs": "https://github.com/forcedotcom/cli/issues",
"dependencies": {
"@oclif/core": "^1.9.5",
"@oclif/core": "^1.16.1",
"@salesforce/core": "^3.23.4",
"@salesforce/kit": "^1.6.0",
"@salesforce/sf-plugins-core": "^1.13.0",
"@salesforce/sf-plugins-core": "^1.14.1",
"change-case": "^4.1.2",
"fast-glob": "^3.2.12",
"got": "^11.8.5",
"replace-in-file": "^6.3.2",
"shelljs": "^0.8.5",
Expand Down
1 change: 1 addition & 0 deletions src/commands/dev/generate/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export default class GenerateCommand extends SfCommand<void> {
name: Flags.string({
required: true,
summary: messages.getMessage('flags.name.summary'),
char: 'n',
}),
force: Flags.boolean({
summary: messages.getMessage('flags.force.summary'),
Expand Down
231 changes: 204 additions & 27 deletions src/commands/dev/generate/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,62 +9,224 @@ import * as path from 'path';
import * as fs from 'fs/promises';
import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
import { Messages } from '@salesforce/core';
import { Nullable } from '@salesforce/ts-types';
import { Duration } from '@salesforce/kit';

import { Config } from '@oclif/core';
import { Config, toConfiguredId, toStandardizedId } from '@oclif/core';
import ModuleLoader from '@oclif/core/lib/module-loader';
import * as fg from 'fast-glob';
import { fileExists } from '../../../util';

Messages.importMessagesDirectory(__dirname);
const messages = Messages.load('@salesforce/plugin-dev', 'dev.generate.flag', [
'description',
'errors.InvalidDir',
'examples',
'flags.command.description',
'flags.name.description',
'flags.dry-run.summary',
'errors.FlagExists',
'summary',
]);

type Answers = {
char: Nullable<string>;
type: keyof typeof Flags;
name: string;
required: boolean;
multiple: boolean;
durationUnit: Lowercase<keyof typeof Duration.Unit>;
durationDefaultValue: number;
salesforceIdLength: '15' | '18' | 'None';
salesforceIdStartsWith: string;
fileOrDirExists: boolean;
integerMin: number;
integerMax: number;
};

const toLowerKebabCase = (str: string): string =>
str
.replace(/([a-z])([A-Z])/g, '$1-$2')
.replace(/[\s_]+/g, '-')
.toLowerCase();

export default class DevGenerateFlag extends SfCommand<void> {
public static enableJsonFlag = false;
public static summary = messages.getMessage('summary');
public static description = messages.getMessage('description');
public static examples = messages.getMessages('examples');

public static flags = {
name: Flags.string({
description: messages.getMessage('flags.name.description'),
char: 'n',
required: true,
}),
command: Flags.string({
description: messages.getMessage('flags.command.description'),
char: 'c',
required: true,
'dry-run': Flags.boolean({
summary: messages.getMessage('flags.dry-run.summary'),
char: 'd',
}),
};

public async run(): Promise<void> {
const { flags } = await this.parse(DevGenerateFlag);

if (!fileExists('package.json')) throw messages.createError('errors.InvalidDir');
const config = new Config({ root: process.cwd() });
config.root = config.options.root;

const commandFilePath = path.join('.', 'src', 'commands', ...flags.command.split(':'));
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const module = await ModuleLoader.load(config, commandFilePath);
const ids = (await fg('src/commands/**/*.ts')).map((file) => {
const p = path.parse(file.replace(path.join('.', 'src', 'commands'), ''));
const topics = p.dir.split('/');
const command = p.name !== 'index' && p.name;
const id = [...topics, command].filter((f) => f).join(':');
return id === '' ? '.' : id;
});

// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const existingFlags = module.default.flags;
const { command } = await this.prompt<{ command: string }>([
{
type: 'list',
name: 'command',
message: 'Which command is this for',
choices: ids.map((c) => toConfiguredId(c, this.config)),
},
]);

if (Object.keys(existingFlags).includes(flags.name)) {
throw messages.createError('errors.FlagExists', [flags.name]);
}
const commandFilePath = path.join('.', 'src', 'commands', ...toStandardizedId(command, this.config).split(':'));

const existingFlags = await this.loadExistingFlags(commandFilePath);

const charToFlag = Object.entries(existingFlags).reduce((acc, [key, value]) => {
return value.char ? { ...acc, [value.char]: key } : acc;
}, {} as Record<string, string>);

const durationUnits = (Object.values(Duration.Unit).filter((unit) => typeof unit === 'string') as string[]).map(
(unit) => unit.toLowerCase()
);

const answers = await this.prompt<Answers>([
{
type: 'list',
name: 'type',
message: 'What type of flag is this',
choices: Object.keys(Flags).sort(),
},
{
type: 'input',
name: 'name',
message: 'What is the name of the flag',
validate: (input: string): string | boolean => {
if (toLowerKebabCase(input) !== input) {
return 'Flag name must be in kebab case';
}

if (Object.keys(existingFlags).includes(input)) {
return `The ${input} flag already exists`;
}

const newFlag = ` ${flags.name}: Flags.string({
summary: messages.getMessage('flags.${flags.name}.summary'),
return true;
},
},
{
type: 'input',
name: 'char',
message: 'Flag short character (optional)',
validate: (input: string): string | boolean => {
if (!input) return true;
if (charToFlag[input]) return `The ${input} character is already used by the ${charToFlag[input]} flag`;
if (!/[A-Z]|[a-z]/g.test(input)) return 'Flag short character must be a letter';
if (input.length > 1) return 'Must be a single character';
return true;
},
},
{
type: 'confirm',
name: 'required',
message: 'Is this flag required',
default: false,
},
{
type: 'confirm',
name: 'multiple',
message: 'Can this flag be specified multiple times',
default: false,
},
{
type: 'list',
name: 'durationUnit',
message: 'What unit should be used for duration',
choices: durationUnits,
when: (ans: Answers): boolean => ans.type === 'duration',
},
{
type: 'input',
name: 'durationDefaultValue',
message: 'Default value for this duration (optional)',
validate: (input: string): string | boolean => {
if (!input) return true;
return Number.isInteger(Number(input)) ? true : 'Must be an integer';
},
when: (ans: Answers): boolean => ans.type === 'duration',
},
{
type: 'list',
name: 'salesforceIdLength',
message: 'Required length for salesforceId',
choices: ['15', '18', 'None'],
when: (ans: Answers): boolean => ans.type === 'salesforceId',
},
{
type: 'input',
name: 'salesforceIdStartsWith',
message: 'Required prefix for salesforceId',
when: (ans: Answers): boolean => ans.type === 'salesforceId',
validate: (input: string): string | boolean => {
return input.length === 3 ? true : 'Must be 3 characters';
},
},
{
type: 'confirm',
name: 'fileOrDirExists',
message: 'Does this flag require the file or directory to exist',
default: false,
when: (ans: Answers): boolean => ['file', 'directory'].includes(ans.type),
},
{
type: 'input',
name: 'integerMin',
message: 'Minimum required value for integer flag (optional)',
when: (ans: Answers): boolean => ans.type === 'integer',
validate: (input: string): string | boolean => {
if (!input) return true;
return Number.isInteger(Number(input)) ? true : 'Must be an integer';
},
},
{
type: 'input',
name: 'integerMax',
message: 'Maximum required value for integer flag (optional)',
when: (ans: Answers): boolean => ans.type === 'integer',
validate: (input: string): string | boolean => {
if (!input) return true;
return Number.isInteger(Number(input)) ? true : 'Must be an integer';
},
},
]);

const flagOptions = [`summary: messages.getMessage('flags.${answers.name}.summary')`];

if (answers.char) flagOptions.push(`char: '${answers.char}'`);
if (answers.required) flagOptions.push('required: true');
if (answers.multiple) flagOptions.push('multiple: true');
if (answers.durationUnit) flagOptions.push(`unit: ${answers.durationUnit}`);
if (answers.durationDefaultValue) flagOptions.push(`defaultValue: ${answers.durationDefaultValue}`);
if (['15', '18'].includes(answers.salesforceIdLength)) flagOptions.push(`length: ${answers.salesforceIdLength}`);
if (answers.salesforceIdStartsWith) flagOptions.push(`startsWith: ${answers.salesforceIdStartsWith}`);
if (answers.fileOrDirExists) flagOptions.push('exists: true');
if (answers.integerMin) flagOptions.push(`min: ${answers.integerMin}`);
if (answers.integerMax) flagOptions.push(`max: ${answers.integerMax}`);

const newFlag = ` ${answers.name}: Flags.${answers.type}({
${flagOptions.join(',\n ')},
}),`.split('\n');

if (flags['dry-run']) {
this.styledHeader('New flag:');
this.log(newFlag.join('\n'));
return;
}

const lines = (await fs.readFile(`${commandFilePath}.ts`, 'utf8')).split('\n');
const flagsStartIndex = lines.findIndex(
(line) => line.includes('public static flags') || line.includes('public static readonly flags')
Expand All @@ -76,10 +238,25 @@ export default class DevGenerateFlag extends SfCommand<void> {
if (messagesStartIndex) {
const messagesEndIndex =
lines.slice(messagesStartIndex).findIndex((line) => line.endsWith(';')) + messagesStartIndex;
lines.splice(messagesEndIndex, 0, ` 'flags.${flags.name}.summary,'`);
lines.splice(messagesEndIndex, 0, ` 'flags.${answers.name}.summary',`);
}

this.log('New file contents:');
this.log(lines.join('\n'));
await fs.writeFile(`${commandFilePath}.ts`, lines.join('\n'));

this.log(`Added ${answers.name} flag to ${commandFilePath}.ts`);
}

private async loadExistingFlags(
commandFilePath: string
): Promise<Record<string, { type: 'boolean' | 'option'; char?: string }>> {
const config = new Config({ root: process.cwd() });
config.root = config.options.root;
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const module = await ModuleLoader.load(config, commandFilePath);

// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const existingFlags = module.default.flags as Record<string, { type: 'boolean' | 'option'; char?: string }>;

return existingFlags;
}
}
Loading

0 comments on commit 8bef368

Please sign in to comment.