Skip to content

Commit

Permalink
Merge pull request #780 from salesforcecli/sm/refactor-npm-name
Browse files Browse the repository at this point in the history
fix: refactor npmName and export it
  • Loading branch information
WillieRuemmele authored Apr 5, 2024
2 parents de27b4d + 908b0ee commit b2c0a8d
Show file tree
Hide file tree
Showing 9 changed files with 373 additions and 364 deletions.
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"bugs": "https://github.com/forcedotcom/cli/issues",
"dependencies": {
"@oclif/core": "^3.26.0",
"@salesforce/core": "^6.5.5",
"@salesforce/core": "^6.7.6",
"@salesforce/kit": "^3.1.0",
"@salesforce/sf-plugins-core": "^8.0.2",
"got": "^13.0.0",
Expand Down Expand Up @@ -219,6 +219,9 @@
"output": []
}
},
"exports": "./lib/index.js",
"exports": {
".": "./lib/index.js",
"./npmName": "./lib/shared/npmName.js"
},
"type": "module"
}
4 changes: 2 additions & 2 deletions src/commands/plugins/trust/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
InstallationVerification,
VerificationConfig,
} from '../../../shared/installationVerification.js';
import { NpmName } from '../../../shared/NpmName.js';
import { type NpmName, parseNpmName } from '../../../shared/NpmName.js';
import { setErrorName } from '../../../shared/errors.js';

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
Expand Down Expand Up @@ -50,7 +50,7 @@ export class Verify extends SfCommand<VerifyResponse> {
const logger = await Logger.child('verify');
this.log('Checking for digital signature.');

const npmName: NpmName = NpmName.parse(flags.npm);
const npmName = parseNpmName(flags.npm);

logger.debug(`running verify command for npm: ${npmName.name}`);

Expand Down
4 changes: 2 additions & 2 deletions src/hooks/verifyInstallSignature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
isAllowListed,
} from '../shared/installationVerification.js';

import { NpmName } from '../shared/NpmName.js';
import { type NpmName, parseNpmName } from '../shared/NpmName.js';

export const hook: Hook.PluginsPreinstall = async function (options) {
if (options.plugin && options.plugin.type === 'npm') {
Expand All @@ -35,7 +35,7 @@ export const hook: Hook.PluginsPreinstall = async function (options) {
return;
}
logger.debug('parsing npm name');
const npmName = NpmName.parse(plugin.name);
const npmName = parseNpmName(plugin.name);
logger.debug(`npmName components: ${JSON.stringify(npmName, null, 4)}`);

npmName.tag = plugin.tag || 'latest';
Expand Down
184 changes: 43 additions & 141 deletions src/shared/NpmName.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,150 +7,52 @@
import { SfError } from '@salesforce/core';
import { setErrorName } from './errors.js';

interface NpmNameInfo {
scope: string;
const DEFAULT_TAG = 'latest';

export type NpmName = {
tag: string;
scope?: string;
name: string;
}
};

/**
* String representing the parsed components of an NpmName
* Parse an NPM package name into {scope, name, tag}. The tag is 'latest' by default and can be any semver string.
*
* @example
* const f: NpmName = NpmName.parse('@salesforce/jj@foo');
* console.log(f.tag === 'foo')
* @param {string} npmName - The npm name to parse.
* @return {NpmName} - An object with the parsed components.
*/
export class NpmName {
public static readonly DEFAULT_TAG = 'latest';
public tag: string;
// next 2 props won't exist until after parse is called
// TODO: make this more functional and deterministic
public scope!: string;
public name!: string;

/**
* Private ctor. Use static parse method.
*/
private constructor() {
this.tag = NpmName.DEFAULT_TAG;
}
/**
* Parse an NPM package name into {scope, name, tag}. The tag is 'latest' by default and can be any semver string.
*
* @param {string} npmName - The npm name to parse.
* @return {NpmName} - An object with the parsed components.
*/
public static parse(npmName: string): NpmName {
if (!npmName || npmName.length < 1) {
throw setErrorName(
new SfError('The npm name is missing or invalid.', 'MissingOrInvalidNpmName'),
'MissingOrInvalidNpmName'
);
}

const returnNpmName = new NpmName();

const components: string[] = npmName.split('@');

// salesforce/jj
if (components.length === 1) {
NpmName.setNameAndScope(components[0], returnNpmName);
} else if (components[0].includes('/')) {
NpmName.setNameAndScope(components[0], returnNpmName);
} else if (components[1].includes('/')) {
NpmName.setNameAndScope(components[1], returnNpmName);
} else {
// Allow something like salesforcedx/pre-release
NpmName.setNameAndScope(components[0], returnNpmName);
returnNpmName.tag = components[1];
}

if (components.length > 2) {
returnNpmName.tag = components[2];
}
return returnNpmName;
}

/**
* Static helper to parse the name and scope.
*
* @param {string} name - The string to parse.
* @param {NpmNameInfo} returnNpmName - The object to update.
*/
private static setNameAndScope(name: string, returnNpmName: NpmNameInfo): void {
// There are at least 2 components. So there is likely a scope.
const subComponents: string[] = name.split('/');
if (subComponents.length === 2 && subComponents[0].trim().length > 0 && subComponents[1].trim().length > 0) {
returnNpmName.scope = NpmName.validateComponentString(subComponents[0]);
returnNpmName.name = NpmName.validateComponentString(subComponents[1]);
} else if (subComponents.length === 1) {
returnNpmName.name = NpmName.validateComponentString(subComponents[0]);
} else {
throw setErrorName(new SfError('The npm name is invalid.', 'InvalidNpmName'), 'InvalidNpmName');
}
}

/**
* Validate a component part that it's not empty and return it trimmed.
*
* @param {string} name The component to validate.
* @return {string} A whitespace trimmed version of the component.
*/
private static validateComponentString(name: string): string {
const trimmedName = name.trim();
if (trimmedName && trimmedName.length > 0) {
return trimmedName;
} else {
throw setErrorName(
new SfError('The npm name is missing or invalid.', 'MissingOrInvalidNpmName'),
'MissingOrInvalidNpmName'
);
}
}

/**
* Produce a string that can be used by npm. @salesforce/[email protected] becomes "salesforce-jj-1.2.3.tgz
*
* @param {string} [ext = tgz] The file extension to use.
* @param {boolean} includeLatestTag - True if the "latest" tag should be used. Generally you wouldn't do this.
* @return {string} Formatted npm string thats compatible with the npm utility
*/
public toFilename(ext = 'tgz', includeLatestTag?: boolean): string {
const nameComponents: string[] = [];

if (this.scope) {
nameComponents.push(this.scope);
}

nameComponents.push(this.name);

if (this.tag) {
if (this.tag !== NpmName.DEFAULT_TAG) {
nameComponents.push(this.tag);
} else if (includeLatestTag) {
nameComponents.push(this.tag);
}
}

return nameComponents.join('-').concat(ext.startsWith('.') ? ext : `.${ext}`);
}

/**
* Produces a formatted string version of the object.
*
* @return {string} A formatted string version of the object.
*/
public toString(includeTag = false): string {
const nameComponents: string[] = [];
if (this.scope && this.scope.length > 0) {
nameComponents.push(`@${this.scope}/`);
}

nameComponents.push(this.name);

if (includeTag && this.tag && this.tag.length > 0) {
nameComponents.push(`@${this.tag}`);
}

return nameComponents.join('');
export const parseNpmName = (npmName: string): NpmName => {
const nameWithoutAt = validateNpmNameAndRemoveLeadingAt(npmName);
const hasScope = nameWithoutAt.includes('/');
const hasTag = nameWithoutAt.includes('@');

return {
scope: hasScope ? nameWithoutAt.split('/')[0] : undefined,
tag: hasTag ? nameWithoutAt.split('@')[1] : DEFAULT_TAG,
name: hasScope ? nameWithoutAt.split('/')[1].split('@')[0] : nameWithoutAt.split('@')[0],
};
};

/** Produces a formatted string version of the object */
export const npmNameToString = (npmName: NpmName): string =>
`${npmName.scope ? `@${npmName.scope}/` : ''}${npmName.name}`;

const validateNpmNameAndRemoveLeadingAt = (input: string): string => {
const nameWithoutAt = input.startsWith('@') ? input.slice(1) : input;
if (
!nameWithoutAt.length || // empty
nameWithoutAt.includes(' ') ||
nameWithoutAt.startsWith('@') || // starts with @ after we already removed it
nameWithoutAt.endsWith('@') ||
nameWithoutAt.startsWith('/') || // starts with /
nameWithoutAt.endsWith('/') || // ends with /
(nameWithoutAt.match(/@/g) ?? []).length > 1 || // should only have 1 @ left (first was removed in parseNpmName)
(nameWithoutAt.match(/\//g) ?? []).length > 1 // can only have 1 slash
) {
throw setErrorName(
new SfError('The npm name is missing or invalid.', 'MissingOrInvalidNpmName'),
'MissingOrInvalidNpmName'
);
}
}
return nameWithoutAt;
};
1 change: 1 addition & 0 deletions src/shared/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { SfError } from '@salesforce/core';
export const setErrorName = (err: SfError, name: string): SfError => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore override readonly .name field
// eslint-disable-next-line no-param-reassign
err.name = name;
return err;
};
4 changes: 2 additions & 2 deletions src/shared/installationVerification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { ux } from '@oclif/core';
import { prompts } from '@salesforce/sf-plugins-core';
import { maxSatisfying } from 'semver';
import { NpmModule, NpmMeta } from './npmCommand.js';
import { NpmName } from './NpmName.js';
import { NpmName, npmNameToString } from './NpmName.js';
import { setErrorName } from './errors.js';

const CRYPTO_LEVEL = 'RSA-SHA256';
Expand Down Expand Up @@ -265,7 +265,7 @@ export class InstallationVerification implements Verifier {
return isAllowListed({
logger: await this.getLogger(),
configPath: this.getConfigPath() ?? '',
name: this.pluginNpmName?.toString(),
name: this.pluginNpmName ? npmNameToString(this.pluginNpmName) : undefined,
});
}

Expand Down
10 changes: 5 additions & 5 deletions test/shared/installationVerification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
Verifier,
} from '../../src/shared/installationVerification.js';
import { NpmMeta, NpmModule, NpmShowResults } from '../../src/shared/npmCommand.js';
import { NpmName } from '../../src/shared/NpmName.js';
import { NpmName, parseNpmName } from '../../src/shared/NpmName.js';
import { CERTIFICATE, TEST_DATA, TEST_DATA_SIGNATURE } from '../testCert.js';

const BLANK_PLUGIN = { plugin: '', tag: '' };
Expand Down Expand Up @@ -159,7 +159,7 @@ describe('InstallationVerification Tests', () => {

realpathSyncStub = stubMethod(sandbox, fs, 'realpathSync').returns('node.exe');
shelljsFindStub = stubMethod(sandbox, shelljs, 'find').returns(['node.exe']);
plugin = NpmName.parse('foo');
plugin = parseNpmName('foo');
pollForAvailabilityStub = stubMethod(sandbox, NpmModule.prototype, 'pollForAvailability').resolves();
gotStub = stubMethod(sandbox, got, 'get');
});
Expand Down Expand Up @@ -548,7 +548,7 @@ describe('InstallationVerification Tests', () => {
stubMethod(sandbox, fs.promises, 'rm').resolves();
stubMethod(sandbox, fs.promises, 'readFile').resolves(`["${TEST_VALUE1}"]`);
const verification1 = new InstallationVerification()
.setPluginNpmName(NpmName.parse(TEST_VALUE1))
.setPluginNpmName(parseNpmName(TEST_VALUE1))
.setConfig(config);
expect(await verification1.isAllowListed()).to.be.equal(true);
});
Expand All @@ -560,7 +560,7 @@ describe('InstallationVerification Tests', () => {
stubMethod(sandbox, fs.promises, 'readFile').resolves(`["${TEST_VALUE2}"]`);

const verification2 = new InstallationVerification()
.setPluginNpmName(NpmName.parse(TEST_VALUE2))
.setPluginNpmName(parseNpmName(TEST_VALUE2))
.setConfig(config);
expect(await verification2.isAllowListed()).to.be.equal(true);
});
Expand All @@ -572,7 +572,7 @@ describe('InstallationVerification Tests', () => {
stubMethod(sandbox, fs.promises, 'rm').resolves();
stubMethod(sandbox, fs.promises, 'readFile').rejects(error);

const verification = new InstallationVerification().setPluginNpmName(NpmName.parse('BAR')).setConfig(config);
const verification = new InstallationVerification().setPluginNpmName(parseNpmName('BAR')).setConfig(config);
expect(await verification.isAllowListed()).to.be.equal(false);
});
});
Expand Down
Loading

0 comments on commit b2c0a8d

Please sign in to comment.