diff --git a/packages/api/src/cli/commands/install.ts b/packages/api/src/cli/commands/install.ts index 72e9febe..553c79cf 100644 --- a/packages/api/src/cli/commands/install.ts +++ b/packages/api/src/cli/commands/install.ts @@ -4,7 +4,6 @@ import { Command, Option } from 'commander'; import figures from 'figures'; import Oas from 'oas'; import ora from 'ora'; -import validateNPMPackageName from 'validate-npm-package-name'; import Fetcher from '../../fetcher'; import codegen from '../codegen'; @@ -18,6 +17,7 @@ cmd .name('install') .description('install an API SDK into your codebase') .argument('', 'an API to install') + .option('-i, --identifier ', 'API identifier (eg. `@api/petstore`)') .addOption( new Option('-l, --lang ', 'SDK language').choices([ 'js', // User generally wants JS, we'll prompt if they want CJS or ESM files. @@ -27,7 +27,7 @@ cmd ]) ) .addOption(new Option('-y, --yes', 'Automatically answer "yes" to any prompts printed')) - .action(async (uri: string, options: { lang: string; yes?: boolean }) => { + .action(async (uri: string, options: { identifier?: string; lang: string; yes?: boolean }) => { let language: SupportedLanguages; if (options.lang) { language = options.lang as SupportedLanguages; @@ -69,7 +69,12 @@ cmd } let identifier; - if (Fetcher.isAPIRegistryUUID(uri)) { + if (options.identifier) { + // `Storage.isIdentifierValid` will throw an exception if an identifier is invalid. + if (Storage.isIdentifierValid(options.identifier)) { + identifier = options.identifier; + } + } else if (Fetcher.isAPIRegistryUUID(uri)) { identifier = Fetcher.getProjectPrefixFromRegistryUUID(uri); } else { ({ value: identifier } = await promptTerminal({ @@ -82,19 +87,11 @@ cmd return false; } - // Is this identifier already in storage? - if (Storage.isInLockFile({ identifier: value })) { - return `"${value}" is already taken in your \`.api/\` directory. Please enter another identifier.`; - } - - const isValidForNPM = validateNPMPackageName(`@api/${value}`); - if (!isValidForNPM.validForNewPackages) { - // `prompts` doesn't support surfacing multiple errors in a `validate` call so we can - // only surface the first to the user. - return isValidForNPM.errors[0]; + try { + return Storage.isIdentifierValid(value, true); + } catch (err) { + return err.message; } - - return true; }, })); } diff --git a/packages/api/src/cli/storage.ts b/packages/api/src/cli/storage.ts index 0be205dd..c8993894 100644 --- a/packages/api/src/cli/storage.ts +++ b/packages/api/src/cli/storage.ts @@ -5,6 +5,7 @@ import path from 'path'; import makeDir from 'make-dir'; import ssri from 'ssri'; +import validateNPMPackageName from 'validate-npm-package-name'; import Fetcher from '../fetcher'; import { PACKAGE_VERSION } from '../packageInfo'; @@ -105,6 +106,22 @@ export default class Storage { return Storage.lockfile; } + static isIdentifierValid(identifier: string, prefixWithAPINamespace?: boolean) { + // Is this identifier already in storage? + if (Storage.isInLockFile({ identifier })) { + throw new Error(`"${identifier}" is already taken in your \`.api/\` directory. Please try another identifier.`); + } + + const isValidForNPM = validateNPMPackageName(prefixWithAPINamespace ? `@api/${identifier}` : identifier); + if (!isValidForNPM.validForNewPackages) { + // `prompts` doesn't support surfacing multiple errors in a `validate` call so we can only + // surface the first to the user. + throw new Error(`Identifier cannot be used for an NPM package: ${isValidForNPM.errors[0]}`); + } + + return true; + } + static isInLockFile(search: { identifier?: string; source?: string }) { // Because this method may run before we initialize a new storage object we should make sure // that we have a storage directory present. diff --git a/packages/api/test/cli/storage.test.ts b/packages/api/test/cli/storage.test.ts index c343568c..e0af901d 100644 --- a/packages/api/test/cli/storage.test.ts +++ b/packages/api/test/cli/storage.test.ts @@ -53,6 +53,39 @@ describe('storage', () => { }); }); + describe('#isIdentifierValid', () => { + it('should allow an identifier that is valid', () => { + expect(Storage.isIdentifierValid('buster')).toBe(true); + expect(Storage.isIdentifierValid('buster', true)).toBe(true); + }); + + it('should throw an error for when an identifier is already being used', async () => { + fetchMock.get('https://dash.readme.com/api/v1/api-registry/n6kvf10vakpemvplx', petstoreSimple); + + const source = '@petstore/v1.0#n6kvf10vakpemvplx'; + const storage = new Storage(source, 'petstore'); + + expect(Storage.isInLockFile({ source })).toBe(false); + + await storage.load(); + + expect(() => { + Storage.isIdentifierValid('petstore'); + }).toThrow('"petstore" is already taken in your `.api/` directory. Please try another identifier.'); + }); + + it("should throw an error when an identifier can't be used on npm", () => { + expect(() => { + Storage.isIdentifierValid('.buster'); + }).toThrow('Identifier cannot be used for an NPM package: name cannot start with a period'); + + expect(() => { + // `true` here will try to check it as `@api/ buster`, `@api/.buster` is valid apparently! + Storage.isIdentifierValid(' buster', true); + }).toThrow('Identifier cannot be used for an NPM package: name can only contain URL-friendly characters'); + }); + }); + describe('#isInLockFile', () => { it('should be able to look up in the lockfile by a given source', async () => { fetchMock.get('https://dash.readme.com/api/v1/api-registry/n6kvf10vakpemvplx', petstoreSimple);