@pnp/cli-microsoft365
Version:
Manage Microsoft 365 and SharePoint Framework projects on any platform
493 lines (492 loc) • 22.1 kB
JavaScript
import fs from 'fs';
import os from 'os';
import path from 'path';
// uncomment to support upgrading to preview releases
// import { prerelease } from 'semver';
import { z } from 'zod';
import { CommandError, globalOptionsZod } from '../../../../Command.js';
import { fsUtil } from '../../../../utils/fsUtil.js';
import { packageManager } from '../../../../utils/packageManager.js';
import { zod } from '../../../../utils/zod.js';
import commands from '../../commands.js';
import { BaseProjectCommand } from './base-project-command.js';
import { FN017001_MISC_npm_dedupe } from './project-upgrade/rules/FN017001_MISC_npm_dedupe.js';
const options = globalOptionsZod
.extend({
packageManager: z.enum(['npm', 'pnpm', 'yarn']).default('npm'),
preview: z.boolean().optional(),
toVersion: zod.alias('v', z.string().optional()),
shell: z.enum(['bash', 'powershell', 'cmd']).default('powershell'),
output: z.enum(['json', 'text', 'md', 'tour', 'csv', 'none']).optional()
})
.strict();
class SpfxProjectUpgradeCommand extends BaseProjectCommand {
get name() {
return commands.PROJECT_UPGRADE;
}
get description() {
return 'Upgrades SharePoint Framework project to the specified version';
}
get allowedOutputs() {
return ['json', 'text', 'md', 'tour'];
}
get schema() {
return options;
}
constructor() {
super();
this.toVersion = '';
this.packageManager = '';
this.shell = '';
this.allFindings = [];
this.supportedVersions = [
'1.0.0',
'1.0.1',
'1.0.2',
'1.1.0',
'1.1.1',
'1.1.3',
'1.2.0',
'1.3.0',
'1.3.1',
'1.3.2',
'1.3.4',
'1.4.0',
'1.4.1',
'1.5.0',
'1.5.1',
'1.6.0',
'1.7.0',
'1.7.1',
'1.8.0',
'1.8.1',
'1.8.2',
'1.9.1',
'1.10.0',
'1.11.0',
'1.12.0',
'1.12.1',
'1.13.0',
'1.13.1',
'1.14.0',
'1.15.0',
'1.15.2',
'1.16.0',
'1.16.1',
'1.17.0',
'1.17.1',
'1.17.2',
'1.17.3',
'1.17.4',
'1.18.0',
'1.18.1',
'1.18.2',
'1.19.0',
'1.20.0',
'1.21.0',
'1.21.1',
'1.22.0',
'1.22.1'
];
}
async commandAction(logger, args) {
this.projectRootPath = this.getProjectRoot(process.cwd());
if (this.projectRootPath === null) {
throw new CommandError(`Couldn't find project root folder`, SpfxProjectUpgradeCommand.ERROR_NO_PROJECT_ROOT_FOLDER);
}
this.toVersion = args.options.toVersion ? args.options.toVersion : this.supportedVersions[this.supportedVersions.length - 1];
// uncomment to support upgrading to preview releases
// if (!args.options.toVersion &&
// !args.options.preview &&
// prerelease(this.toVersion)) {
// // no version and no preview specified while the current version to
// // upgrade to is a prerelease so let's grab the first non-preview version
// // since we're supporting only one preview version, it's sufficient for
// // us to take second to last version
// this.toVersion = this.supportedVersions[this.supportedVersions.length - 2];
// }
this.packageManager = args.options.packageManager || 'npm';
this.shell = args.options.shell || 'powershell';
if (this.supportedVersions.indexOf(this.toVersion) < 0) {
throw new CommandError(`CLI for Microsoft 365 doesn't support upgrading SharePoint Framework projects to version ${this.toVersion}. Supported versions are ${this.supportedVersions.join(', ')}`, SpfxProjectUpgradeCommand.ERROR_UNSUPPORTED_TO_VERSION);
}
this.projectVersion = this.getProjectVersion();
if (!this.projectVersion) {
throw new CommandError(`Unable to determine the version of the current SharePoint Framework project`, SpfxProjectUpgradeCommand.ERROR_NO_VERSION);
}
const pos = this.supportedVersions.indexOf(this.projectVersion);
if (pos < 0) {
throw new CommandError(`CLI for Microsoft 365 doesn't support upgrading projects built using SharePoint Framework v${this.projectVersion}`, SpfxProjectUpgradeCommand.ERROR_UNSUPPORTED_FROM_VERSION);
}
const posTo = this.supportedVersions.indexOf(this.toVersion);
if (pos > posTo) {
throw new CommandError('You cannot downgrade a project', SpfxProjectUpgradeCommand.ERROR_NO_DOWNGRADE);
}
if (pos === posTo) {
await logger.log(`Project doesn't need to be upgraded`);
return;
}
if (this.verbose) {
await logger.logToStderr('Collecting project...');
}
const project = this.getProject(this.projectRootPath);
if (this.debug) {
await logger.logToStderr('Collected project');
await logger.logToStderr(project);
}
// reverse the list of versions to upgrade to, so that most recent findings
// will end up on top already. Saves us reversing a larger array later
const versionsToUpgradeTo = this.supportedVersions.slice(pos + 1, posTo + 1).reverse();
try {
for (const v of versionsToUpgradeTo) {
const rules = (await import(`./project-upgrade/upgrade-${v}.js`)).default;
rules.forEach(r => {
r.visit(project, this.allFindings);
});
}
}
catch (e) {
throw new CommandError(e.message);
}
if (this.packageManager === 'npm') {
const npmDedupeRule = new FN017001_MISC_npm_dedupe();
npmDedupeRule.visit(project, this.allFindings);
}
// dedupe
const findings = this.allFindings.filter((f, i) => {
const firstFindingPos = this.allFindings.findIndex(f1 => f1.id === f.id);
return i === firstFindingPos;
});
// remove superseded findings
findings
// get findings that supersede other findings
.filter(f => f.supersedes.length > 0)
.forEach(f => {
f.supersedes.forEach(s => {
// find the superseded finding
const i = findings.findIndex(f1 => f1.id === s);
if (i > -1) {
// ...and remove it from findings
findings.splice(i, 1);
}
});
});
// remove findings without title
findings
.forEach(f => {
if (!f.title) {
// find the finding
const i = findings.findIndex(f1 => f1.id === f.id);
if (i > -1) {
// ...and remove it from findings
findings.splice(i, 1);
}
}
});
// flatten
const findingsToReport = [].concat.apply([], findings.map(f => {
return f.occurrences.map(o => {
return {
description: f.description,
id: f.id,
file: o.file,
position: o.position,
resolution: o.resolution,
resolutionType: f.resolutionType,
severity: f.severity,
title: f.title
};
});
}));
// replace package operation tokens with command for the specific package manager
findingsToReport.forEach(f => {
// matches must be in this particular order to avoid false matches, eg.
// uninstallDev contains install
if (f.resolution.startsWith('uninstallDev')) {
f.resolution = f.resolution.replace('uninstallDev', packageManager.getPackageManagerCommand('uninstallDev', this.packageManager));
return;
}
if (f.resolution.startsWith('installDev')) {
f.resolution = f.resolution.replace('installDev', packageManager.getPackageManagerCommand('installDev', this.packageManager));
return;
}
if (f.resolution.startsWith('uninstall')) {
f.resolution = f.resolution.replace('uninstall', packageManager.getPackageManagerCommand('uninstall', this.packageManager));
return;
}
if (f.resolution.startsWith('install')) {
f.resolution = f.resolution.replace('install', packageManager.getPackageManagerCommand('install', this.packageManager));
return;
}
// copy support for multiple shells
if (f.resolution.startsWith('copy_cmd')) {
f.resolution = f.resolution.replace('copy_cmd', fsUtil.getCopyCommand('copyCommand', this.shell));
f.resolution = f.resolution.replace('DestinationParam', fsUtil.getCopyCommand('copyDestinationParam', this.shell));
return;
}
// createdir support for multiple shells
if (f.resolution.startsWith('create_dir_cmd')) {
f.resolution = f.resolution.replace('create_dir_cmd', fsUtil.getDirectoryCommand('createDirectoryCommand', this.shell));
f.resolution = f.resolution.replace('NameParam', fsUtil.getDirectoryCommand('createDirectoryNameParam', this.shell));
f.resolution = f.resolution.replace('PathParam', fsUtil.getDirectoryCommand('createDirectoryPathParam', this.shell));
f.resolution = f.resolution.replace('ItemTypeParam', fsUtil.getDirectoryCommand('createDirectoryItemTypeParam', this.shell));
return;
}
// 'Add' support for multiple shells
if (f.resolution.startsWith('add_cmd')) {
const pathStart = f.resolution.indexOf('[BEFOREPATH]') + '[BEFOREPATH]'.length;
const pathEnd = f.resolution.indexOf('[AFTERPATH]');
const filePath = f.resolution.substring(pathStart, pathEnd);
const contentStart = f.resolution.indexOf('[BEFORECONTENT]') + '[BEFORECONTENT]'.length;
const contentEnd = f.resolution.indexOf('[AFTERCONTENT]');
const fileContent = f.resolution.substring(contentStart, contentEnd);
f.resolution = fsUtil.getAddCommand('addFileCommand', this.shell);
f.resolution = f.resolution.replace('[FILECONTENT]', fileContent);
f.resolution = f.resolution.replace('[FILEPATH]', filePath);
f.resolution = f.resolution.replace('[BEFOREPATH]', ' ');
f.resolution = f.resolution.replace('[AFTERPATH]', ' ');
f.resolution = f.resolution.replace('[BEFORECONTENT]', ' ');
f.resolution = f.resolution.replace('[AFTERCONTENT]', ' ');
return;
}
// 'Remove' support for multiple shells
if (f.resolution.startsWith('remove_cmd')) {
f.resolution = f.resolution.replace('remove_cmd', fsUtil.getRemoveCommand('removeFileCommand', this.shell));
return;
}
});
switch (args.options.output) {
case 'text':
await logger.log(this.getTextReport(findingsToReport));
break;
case 'json':
await logger.log(findingsToReport);
break;
case 'tour':
this.writeReportTourFolder(this.getTourReport(findingsToReport, project));
break;
case 'md':
await logger.log(this.getMdReport(findingsToReport));
break;
default:
await logger.log(findingsToReport);
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getMdOutput(logStatement, command, options) {
// overwrite markdown output to return the output as-is
// because the command already implements its own logic to format the output
return logStatement;
}
writeReportTourFolder(findingsToReport) {
const toursFolder = path.join(this.projectRootPath, '.tours');
if (!fs.existsSync(toursFolder)) {
fs.mkdirSync(toursFolder, { recursive: false });
}
const tourFilePath = path.join(this.projectRootPath, '.tours', 'upgrade.tour');
fs.writeFileSync(path.resolve(tourFilePath), findingsToReport, 'utf-8');
}
getTextReport(findings) {
const reportData = this.getReportData(findings);
const s = [
'Execute in ' + this.shell, os.EOL,
'-----------------------', os.EOL,
(reportData.packageManagerCommands
.concat(reportData.commandsToExecute
.filter((command) => command.indexOf(packageManager.getPackageManagerCommand('install', this.packageManager)) === -1 &&
command.indexOf(packageManager.getPackageManagerCommand('installDev', this.packageManager)) === -1 &&
command.indexOf(packageManager.getPackageManagerCommand('uninstall', this.packageManager)) === -1 &&
command.indexOf(packageManager.getPackageManagerCommand('uninstallDev', this.packageManager)) === -1))).join(os.EOL), os.EOL,
os.EOL,
Object.keys(reportData.modificationPerFile).map(file => {
return [
file, os.EOL,
'-'.repeat(file.length), os.EOL,
reportData.modificationPerFile[file].map((m) => `${m.description}:${os.EOL}${m.modification}${os.EOL}`).join(os.EOL), os.EOL
].join('');
}).join(os.EOL),
os.EOL
];
return s.join('').trim();
}
getMdReport(findings) {
const projectName = this.getProject(this.projectRootPath).packageSolutionJson?.solution?.name;
const findingsToReport = [];
const reportData = this.getReportData(findings);
findings.forEach(f => {
let resolution = '';
switch (f.resolutionType) {
case 'cmd':
resolution = `Execute the following command:
\`\`\`sh
${f.resolution}
\`\`\`
`;
break;
case 'json':
case 'js':
case 'ts':
case 'scss':
resolution = `\`\`\`${f.resolutionType}
${f.resolution}
\`\`\`
`;
break;
}
findingsToReport.push(`### ${f.id} ${f.title} | ${f.severity}`, os.EOL, os.EOL, f.description, os.EOL, os.EOL, resolution, os.EOL, `File: [${f.file}${(f.position ? `:${f.position.line}:${f.position.character}` : '')}](${f.file})`, os.EOL, os.EOL);
});
const s = [
`# Upgrade project ${projectName} to v${this.toVersion}`, os.EOL,
os.EOL,
`Date: ${(new Date().toLocaleDateString())}`, os.EOL,
os.EOL,
'## Findings', os.EOL,
os.EOL,
`Following is the list of steps required to upgrade your project to SharePoint Framework version ${this.toVersion}. [Summary](#Summary) of the modifications is included at the end of the report.`, os.EOL,
os.EOL,
findingsToReport.join(''),
'## Summary', os.EOL,
os.EOL,
'### Execute script', os.EOL,
os.EOL,
'```sh', os.EOL,
(reportData.packageManagerCommands
.concat(reportData.commandsToExecute
.filter((command) => command.indexOf(packageManager.getPackageManagerCommand('install', this.packageManager)) === -1 &&
command.indexOf(packageManager.getPackageManagerCommand('installDev', this.packageManager)) === -1 &&
command.indexOf(packageManager.getPackageManagerCommand('uninstall', this.packageManager)) === -1 &&
command.indexOf(packageManager.getPackageManagerCommand('uninstallDev', this.packageManager)) === -1))).join(os.EOL), os.EOL,
'```', os.EOL,
os.EOL,
'### Modify files', os.EOL,
os.EOL,
Object.keys(reportData.modificationPerFile).map(file => {
return [
`#### [${file}](${file})`, os.EOL,
os.EOL,
reportData.modificationPerFile[file].map((m) => `${m.description}:${os.EOL}${os.EOL}\`\`\`${reportData.modificationTypePerFile[file]}${os.EOL}${m.modification}${os.EOL}\`\`\``).join(os.EOL + os.EOL), os.EOL
].join('');
}).join(os.EOL),
os.EOL
];
return s.join('').trim();
}
getTourReport(findings, project) {
const projectName = this.getProject(this.projectRootPath).packageSolutionJson?.solution?.name;
const tourFindings = {
title: `Upgrade project ${projectName} to v${this.toVersion}`,
steps: []
};
findings.forEach(f => {
const lineNumber = f.position && f.position.line ? f.position.line : 1;
let resolution = '';
switch (f.resolutionType) {
case 'cmd':
resolution = `Execute the following command:\r\n\r\n[\`${f.resolution}\`](command:codetour.sendTextToTerminal?["${f.resolution}"])`;
break;
case 'json':
case 'js':
case 'ts':
case 'scss':
resolution = `\r\n\`\`\`${f.resolutionType}\r\n${f.resolution}\r\n\`\`\``;
break;
}
// Make severity uppercase for the markdown
const sev = f.severity.toUpperCase();
// Clean up the file name
let file = fs.existsSync(path.join(project.path, f.file)) ? f.file : undefined;
if (file !== undefined) {
// CodeTour expects the files to be relative from root (i.e.: no './')
file = file.replace(/\.\//g, '');
// CodeTour also expects forward slashes as directory separators
file = file.replace(/\\/g, '/');
}
// Create a tour step entry
const step = {
title: `${sev}: ${f.title} (${f.id})`,
description: `### ${sev}\r\n\r\n${f.description}\r\n\r\n${resolution}`,
line: lineNumber
};
// Point to a directory if there is no file
if (file !== undefined) {
step.file = file;
}
else {
step.directory = "";
}
tourFindings.steps.push(step);
});
// Add the finale
tourFindings.steps.push({
file: ".tours/upgrade.tour",
title: "RECOMMENDED: Delete tour",
description: "### THAT'S IT!!!\r\nOnce you have tested that your upgrade is successful, you can delete the `.tour` folder and its contents. Otherwise, you'll be prompted to launch this CodeTour every time you open this project."
});
return JSON.stringify(tourFindings, null, 2);
}
getReportData(findings) {
const commandsToExecute = [];
const modificationPerFile = {};
const modificationTypePerFile = {};
const packagesDevExact = [];
const packagesDepExact = [];
const packagesDepUn = [];
const packagesDevUn = [];
findings.forEach(f => {
if (f.resolutionType === 'cmd') {
if (f.resolution.indexOf('npm') > -1 ||
f.resolution.indexOf('yarn') > -1) {
packageManager.mapPackageManagerCommand({
command: f.resolution,
packagesDevExact,
packagesDepExact,
packagesDepUn,
packagesDevUn,
packageMgr: this.packageManager
});
}
else {
commandsToExecute.push(f.resolution);
}
}
else {
if (!modificationPerFile[f.file]) {
modificationPerFile[f.file] = [];
}
if (!modificationTypePerFile[f.file]) {
modificationTypePerFile[f.file] = f.resolutionType;
}
modificationPerFile[f.file].push({
description: f.description,
modification: f.resolution
});
}
});
const packageManagerCommands = packageManager.reducePackageManagerCommand({
packagesDepExact,
packagesDevExact,
packagesDepUn,
packagesDevUn,
packageMgr: this.packageManager
});
if (this.packageManager === 'npm') {
const dedupeFinding = findings.filter(f => f.id === 'FN017001');
if (dedupeFinding.length > 0) {
packageManagerCommands.push(dedupeFinding[0].resolution);
}
}
return {
commandsToExecute: commandsToExecute,
packageManagerCommands: packageManagerCommands,
modificationPerFile: modificationPerFile,
modificationTypePerFile: modificationTypePerFile
};
}
}
SpfxProjectUpgradeCommand.ERROR_NO_PROJECT_ROOT_FOLDER = 1;
SpfxProjectUpgradeCommand.ERROR_UNSUPPORTED_TO_VERSION = 2;
SpfxProjectUpgradeCommand.ERROR_NO_VERSION = 3;
SpfxProjectUpgradeCommand.ERROR_UNSUPPORTED_FROM_VERSION = 4;
SpfxProjectUpgradeCommand.ERROR_NO_DOWNGRADE = 5;
export default new SpfxProjectUpgradeCommand();
//# sourceMappingURL=project-upgrade.js.map