diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29bb2d..5d6443aeddc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1 @@ +- Migrates Firebase Extensions commands to use registry API. diff --git a/src/commands/ext-dev-extension-delete.ts b/src/commands/ext-dev-extension-delete.ts new file mode 100644 index 00000000000..b3528bcfbaa --- /dev/null +++ b/src/commands/ext-dev-extension-delete.ts @@ -0,0 +1,58 @@ +import * as utils from "../utils"; +import * as clc from "cli-color"; + +import { Command } from "../command"; +import { logPrefix } from "../extensions/extensionsHelper"; +import { parseRef, getExtension, deleteExtension } from "../extensions/extensionsApi"; +import { promptOnce } from "../prompt"; +import { requireAuth } from "../requireAuth"; +import { FirebaseError } from "../error"; +import { checkMinRequiredVersion } from "../checkMinRequiredVersion"; + +module.exports = new Command("ext:dev:delete ") + .description("delete an extension") + .help( + "use this command to delete an extension, and make it unavailable for developers to install or reconfigure. " + + "Specify the extension you want to delete using the format '/." + ) + .before(requireAuth) + .before(checkMinRequiredVersion, "extDevMinVersion") + .action(async (extensionRef: string) => { + const { publisherId, extensionId, version } = parseRef(extensionRef); + if (version) { + throw new FirebaseError( + `Deleting a single version is not currently supported. You can only delete ${clc.bold( + "ALL versions" + )} of an extension. To delete all versions, please remove the version from the reference.` + ); + } + utils.logLabeledWarning( + logPrefix, + "If you delete this extension, developers won't be able to install it. " + + "For developers who currently have this extension installed, " + + "it will continue to run and will appear as unpublished when " + + "listed in the Firebase console or Firebase CLI." + ); + utils.logLabeledWarning( + "This is a permanent action", + `Once deleted, you may never use the extension name '${clc.bold(extensionId)}' again.` + ); + await getExtension(extensionRef); + const consent = await confirmDelete(publisherId, extensionId); + if (!consent) { + throw new FirebaseError("deletion cancelled."); + } + await deleteExtension(extensionRef); + utils.logLabeledSuccess(logPrefix, "successfully deleted all versions of this extension."); + }); + +async function confirmDelete(publisherId: string, extensionId: string): Promise { + const message = `You are about to delete ALL versions of ${clc.green( + `${publisherId}/${extensionId}` + )}.\nDo you wish to continue? `; + return await promptOnce({ + type: "confirm", + message, + default: false, // Force users to explicitly type 'yes' + }); +} diff --git a/src/commands/ext-dev-publish.ts b/src/commands/ext-dev-publish.ts index af6d2ebeb58..a411ab6a5b2 100644 --- a/src/commands/ext-dev-publish.ts +++ b/src/commands/ext-dev-publish.ts @@ -1,11 +1,19 @@ +import * as clc from "cli-color"; +import * as marked from "marked"; +import TerminalRenderer = require("marked-terminal"); + import { Command } from "../command"; -import { publishExtensionVersionFromLocalSource } from "../extensions/extensionsHelper"; +import { publishExtensionVersionFromLocalSource, logPrefix } from "../extensions/extensionsHelper"; import { parseRef } from "../extensions/extensionsApi"; - import { findExtensionYaml } from "../extensions/localHelper"; +import { consoleInstallLink } from "../extensions/publishHelpers"; import { requireAuth } from "../requireAuth"; -import * as clc from "cli-color"; import { FirebaseError } from "../error"; +import * as utils from "../utils"; + +marked.setOptions({ + renderer: new TerminalRenderer(), +}); /** * Command for publishing an extension version. @@ -40,5 +48,8 @@ export default new Command("ext:dev:publish ") extensionId, extensionYamlDirectory ); + if (res) { + utils.logLabeledBullet(logPrefix, marked(`[Install Link](${consoleInstallLink(res.ref)})`)); + } return res; }); diff --git a/src/commands/ext-dev-register.ts b/src/commands/ext-dev-register.ts index 9ed68e9c05d..370e7a90212 100644 --- a/src/commands/ext-dev-register.ts +++ b/src/commands/ext-dev-register.ts @@ -25,7 +25,7 @@ export default new Command("ext:dev:register") const msg = "What would you like to register as your publisher ID? " + "This value identifies you in Firebase's registry of extensions as the author of your extensions. " + - "Examples: my-company-name, MyGitHubUsername. If you are a member of the Extensions EAP group, your published extensions will only be accessible to other members of the EAP group. \n\n" + + "Examples: my-company-name, MyGitHubUsername.\n\n" + "You can only do this once for each project."; const publisherId = await promptOnce({ name: "publisherId", diff --git a/src/commands/ext-dev-unpublish.ts b/src/commands/ext-dev-unpublish.ts index 745121d0f7b..d8e61a59ef0 100644 --- a/src/commands/ext-dev-unpublish.ts +++ b/src/commands/ext-dev-unpublish.ts @@ -6,6 +6,7 @@ import { promptOnce } from "../prompt"; import * as clc from "cli-color"; import { requireAuth } from "../requireAuth"; import { FirebaseError } from "../error"; +import { checkMinRequiredVersion } from "../checkMinRequiredVersion"; module.exports = new Command("ext:dev:unpublish ") .description("unpublish an extension") @@ -14,11 +15,17 @@ module.exports = new Command("ext:dev:unpublish ") "Specify the extension you want to unpublish using the format '/." ) .before(requireAuth) + .before(checkMinRequiredVersion, "extDevMinVersion") .action(async (extensionRef: string) => { const { publisherId, extensionId, version } = parseRef(extensionRef); - const message = - "If you unpublish this extension, developers won't be able to install it. For developers who currently have this extension installed, it will continue to run and will appear as unpublished when listed in the Firebase console or Firebase CLI."; - utils.logLabeledWarning(logPrefix, message); + utils.logLabeledWarning( + logPrefix, + "If you unpublish this extension, developers won't be able to install it. For developers who currently have this extension installed, it will continue to run and will appear as unpublished when listed in the Firebase console or Firebase CLI." + ); + utils.logLabeledWarning( + "This is a permanent action", + `Once unpublished, you may never use the extension name '${clc.bold(extensionId)}' again.` + ); if (version) { throw new FirebaseError( `Unpublishing a single version is not currently supported. You can only unpublish ${clc.bold( diff --git a/src/commands/ext-info.ts b/src/commands/ext-info.ts index 47c43aee114..eaeff779a06 100644 --- a/src/commands/ext-info.ts +++ b/src/commands/ext-info.ts @@ -3,7 +3,7 @@ import * as _ from "lodash"; import { checkMinRequiredVersion } from "../checkMinRequiredVersion"; import { Command } from "../command"; -import { resolveRegistryEntry, resolveSourceUrl } from "../extensions/resolveSource"; +import { resolveRegistryEntry } from "../extensions/resolveSource"; import * as extensionsApi from "../extensions/extensionsApi"; import { ensureExtensionsApiEnabled, logPrefix } from "../extensions/extensionsHelper"; import { isLocalExtension, getLocalExtensionSpec } from "../extensions/localHelper"; @@ -31,12 +31,18 @@ export default new Command("ext:info ") } else { await requirePermissions(options, ["firebaseextensions.sources.get"]); await ensureExtensionsApiEnabled(options); - - const [name, version] = extensionName.split("@"); - const registryEntry = await resolveRegistryEntry(name); - const sourceUrl = resolveSourceUrl(registryEntry, name, version); - const source = await extensionsApi.getSource(sourceUrl); - spec = source.spec; + const hasPublisherId = extensionName.split("/").length >= 2; + if (hasPublisherId) { + const nameAndVersion = extensionName.split("/")[1]; + if (nameAndVersion.split("@").length < 2) { + extensionName = extensionName + "@latest"; + } + } else { + const [name, version] = extensionName.split("@"); + extensionName = `firebase/${name}@${version || "latest"}`; + } + const version = await extensionsApi.getExtensionVersion(extensionName); + spec = version.spec; } if (!options.markdown) { diff --git a/src/commands/ext-install.ts b/src/commands/ext-install.ts index 65852dd84b0..330b84a38c7 100644 --- a/src/commands/ext-install.ts +++ b/src/commands/ext-install.ts @@ -13,29 +13,23 @@ import { Command } from "../command"; import { FirebaseError } from "../error"; import * as getProjectId from "../getProjectId"; import * as extensionsApi from "../extensions/extensionsApi"; -import { - promptForAudienceConsent, - resolveRegistryEntry, - resolveSourceUrl, -} from "../extensions/resolveSource"; +import { promptForLaunchStageConsent } from "../extensions/resolveSource"; import * as paramHelper from "../extensions/paramHelper"; import { confirmInstallInstance, createSourceFromLocation, ensureExtensionsApiEnabled, - getSourceOrigin, instanceIdExists, logPrefix, promptForOfficialExtension, promptForRepeatInstance, promptForValidInstanceId, - SourceOrigin, + isLocalOrURLPath, } from "../extensions/extensionsHelper"; import { getRandomString } from "../extensions/utils"; import { requirePermissions } from "../requirePermissions"; import * as utils from "../utils"; import { logger } from "../logger"; -import { promptOnce } from "../prompt"; import { previews } from "../previews"; marked.setOptions({ @@ -144,6 +138,64 @@ async function installExtension(options: InstallExtensionOptions): Promise } } +async function confirmInstallBySource( + projectId: string, + extensionName: string +): Promise { + // Create a one off source to use for the install flow. + let source; + try { + source = await createSourceFromLocation(projectId, extensionName); + } catch (err) { + throw new FirebaseError( + `Unable to find published extension '${clc.bold(extensionName)}', ` + + `and encountered the following error when trying to create an instance of extension '${clc.bold( + extensionName + )}':\n ${err.message}` + ); + } + displayExtInfo(extensionName, "", source.spec); + const confirm = await confirmInstallInstance(); + if (!confirm) { + throw new FirebaseError("Install cancelled."); + } + return source; +} + +async function confirmInstallByReference( + extensionName: string +): Promise { + // Infer firebase if publisher ID not provided. + if (extensionName.split("/").length < 2) { + const [extensionID, version] = extensionName.split("@"); + extensionName = `firebase/${extensionID}@${version || "latest"}`; + } + // Get the correct version for a given extension reference from the Registry API. + const ref = extensionsApi.parseRef(extensionName); + const extension = await extensionsApi.getExtension(`${ref.publisherId}/${ref.extensionId}`); + if (!ref.version) { + extensionName = `${extensionName}@latest`; + } + const extVersion = await extensionsApi.getExtensionVersion(extensionName); + displayExtInfo(extensionName, ref.publisherId, extVersion.spec, true); + const confirm = await confirmInstallInstance(); + if (!confirm) { + throw new FirebaseError("Install cancelled."); + } + const audienceConsent = await promptForLaunchStageConsent(extension.registryLaunchStage); + if (!audienceConsent) { + throw new FirebaseError("Install cancelled."); + } + const eapPublisherConsent = await askUserForConsent.checkAndPromptForEapPublisher( + ref.publisherId, + extVersion.spec?.sourceUrl + ); + if (!eapPublisherConsent) { + throw new FirebaseError("Install cancelled."); + } + return extVersion; +} + /** * Command for installing an extension */ @@ -170,80 +222,29 @@ export default new Command("ext:install [extensionName]") "Which official extension do you wish to install?\n" + " Select an extension, then press Enter to learn more." ); - } else { - throw new FirebaseError( - `Please provide an extension name, or run ${clc.bold( - "firebase ext:install -i" - )} to select from the list of all available official extensions.` - ); - } - } - - const [name, version] = extensionName.split("@"); - let source; - let extVersion; - try { - const registryEntry = await resolveRegistryEntry(name); - const sourceUrl = resolveSourceUrl(registryEntry, name, version); - source = await extensionsApi.getSource(sourceUrl); - displayExtInfo(extensionName, source.spec, true); - await confirmInstallInstance(); - const audienceConsent = await promptForAudienceConsent(registryEntry); - if (!audienceConsent) { - logger.info("Install cancelled."); - return; - } - } catch (err) { - if (previews.extdev) { - const sourceOrigin = await getSourceOrigin(extensionName); - switch (sourceOrigin) { - case SourceOrigin.LOCAL || SourceOrigin.URL: { - try { - source = await createSourceFromLocation(projectId, extensionName); - } catch (err) { - throw new FirebaseError( - `Unable to find published extension '${clc.bold(extensionName)}', ` + - `and encountered the following error when trying to create an instance of extension '${clc.bold( - extensionName - )}':\n ${err.message}` - ); - } - displayExtInfo(extensionName, source.spec); - await confirmInstallInstance(); - break; - } - case SourceOrigin.PUBLISHED_EXTENSION: { - await extensionsApi.getExtension(extensionName); - extVersion = await extensionsApi.getExtensionVersion(`${extensionName}@latest`); - displayExtInfo(extensionName, extVersion.spec, true); - await confirmInstallInstance(); - break; - } - case SourceOrigin.PUBLISHED_EXTENSION_VERSION: { - extVersion = await extensionsApi.getExtensionVersion(`${extensionName}`); - displayExtInfo(extensionName, extVersion.spec, true); - await confirmInstallInstance(); - break; - } - default: { - throw new FirebaseError( - `Could not determine source origin for extension '${extensionName}'. If this is a published extension, ` + - "please make sure the publisher and extension exist before trying again. If trying to create an extension, " + - "please ensure the path or URL given is valid." - ); - } - } } else { throw new FirebaseError( `Unable to find published extension '${clc.bold(extensionName)}'. ` + `Run ${clc.bold( "firebase ext:install -i" - )} to select from the list of all available published extensions.`, - { original: err } + )} to select from the list of all available published extensions.` ); } } - + let source; + let extVersion; + // If the user types in URL, or a local path (prefixed with ~/, ../, or ./), install from local/URL source. + // Otherwise, treat the input as an extension reference and proceed with reference-based installation. + if (isLocalOrURLPath(extensionName)) { + source = await confirmInstallBySource(projectId, extensionName); + } else { + extVersion = await confirmInstallByReference(extensionName); + } + if (!source && !extVersion) { + throw new FirebaseError( + "Could not find a source. Please specify a valid source to continue." + ); + } const spec = source?.spec || extVersion?.spec; if (!spec) { throw new FirebaseError( @@ -252,23 +253,19 @@ export default new Command("ext:install [extensionName]") )}'. Please make sure this is a valid extension and try again.` ); } - try { - if (learnMore) { - utils.logLabeledBullet( - logPrefix, - `You selected: ${clc.bold(spec.displayName)}.\n` + - `${spec.description}\n` + - `View details: https://firebase.google.com/products/extensions/${name}\n` - ); - const confirm = await promptOnce({ - type: "confirm", - default: true, - message: "Do you wish to install this extension?", - }); - if (!confirm) { - return; - } + if (learnMore) { + utils.logLabeledBullet( + logPrefix, + `You selected: ${clc.bold(spec.displayName)}.\n` + + `${spec.description}\n` + + `View details: https://firebase.google.com/products/extensions/${spec.name}\n` + ); + const confirm = await confirmInstallInstance(); + if (!confirm) { + return; } + } + try { return installExtension({ paramFilePath, projectId, diff --git a/src/commands/ext-update.ts b/src/commands/ext-update.ts index a179af7a716..b03b3c3d438 100644 --- a/src/commands/ext-update.ts +++ b/src/commands/ext-update.ts @@ -25,22 +25,51 @@ import { retryUpdate, updateFromLocalSource, updateFromUrlSource, - updateFromRegistry, - updateToVersionFromRegistry, + updateFromRegistryFile, + updateToVersionFromRegistryFile, updateToVersionFromPublisherSource, updateFromPublisherSource, getExistingSourceOrigin, + inferUpdateSource, } from "../extensions/updateHelper"; import * as getProjectId from "../getProjectId"; import { requirePermissions } from "../requirePermissions"; import * as utils from "../utils"; import { previews } from "../previews"; -import { displayExtInfo } from "../extensions/displayExtensionInfo"; marked.setOptions({ renderer: new TerminalRenderer(), }); +function isValidUpdate(existingSourceOrigin: SourceOrigin, newSourceOrigin: SourceOrigin): boolean { + let validUpdate = false; + if (existingSourceOrigin === SourceOrigin.OFFICIAL_EXTENSION) { + if ( + [SourceOrigin.OFFICIAL_EXTENSION, SourceOrigin.OFFICIAL_EXTENSION_VERSION].includes( + newSourceOrigin + ) + ) { + validUpdate = true; + } + } else if (existingSourceOrigin === SourceOrigin.PUBLISHED_EXTENSION) { + if ( + [SourceOrigin.PUBLISHED_EXTENSION, SourceOrigin.PUBLISHED_EXTENSION_VERSION].includes( + newSourceOrigin + ) + ) { + validUpdate = true; + } + } else if ( + existingSourceOrigin === SourceOrigin.LOCAL || + existingSourceOrigin === SourceOrigin.URL + ) { + if ([SourceOrigin.LOCAL, SourceOrigin.URL].includes(newSourceOrigin)) { + validUpdate = true; + } + } + return validUpdate; +} + /** * Command for updating an existing extension instance */ @@ -89,16 +118,12 @@ export default new Command("ext:update [updateSource]") const existingParams = _.get(existingInstance, "config.params"); const existingSource = _.get(existingInstance, "config.source.name"); - // Infer updateSource if instance is from the registry - if (existingInstance.config.extensionRef && !updateSource) { - updateSource = `${existingInstance.config.extensionRef}@latest`; - } else if (existingInstance.config.extensionRef && semver.valid(updateSource)) { - updateSource = `${existingInstance.config.extensionRef}@${updateSource}`; + if (existingInstance.config.extensionRef) { + // User may provide abbreviated syntax in the update command (for example, providing no update source or just a semver) + // Decipher the explicit update source from the abbreviated syntax. + updateSource = inferUpdateSource(updateSource, existingInstance.config.extensionRef); } - let newSourceName: string; - let published = false; - const existingSourceOrigin = await getExistingSourceOrigin( projectId, instanceId, @@ -106,53 +131,12 @@ export default new Command("ext:update [updateSource]") existingSource ); const newSourceOrigin = await getSourceOrigin(updateSource); - - // We only allow the following types of updates. - let validUpdate = false; - if (existingSourceOrigin === SourceOrigin.OFFICIAL_EXTENSION) { - if ( - [ - SourceOrigin.LOCAL, - SourceOrigin.URL, - SourceOrigin.OFFICIAL_EXTENSION, - SourceOrigin.OFFICIAL_EXTENSION_VERSION, - ].includes(newSourceOrigin) - ) { - validUpdate = true; - } - } else if (existingSourceOrigin === SourceOrigin.PUBLISHED_EXTENSION) { - if ( - [ - SourceOrigin.LOCAL, - SourceOrigin.URL, - SourceOrigin.PUBLISHED_EXTENSION, - SourceOrigin.PUBLISHED_EXTENSION_VERSION, - ].includes(newSourceOrigin) - ) { - validUpdate = true; - } - } else if ( - existingSourceOrigin === SourceOrigin.LOCAL || - existingSourceOrigin === SourceOrigin.URL - ) { - if ([SourceOrigin.LOCAL, SourceOrigin.URL].includes(newSourceOrigin)) { - validUpdate = true; - } - } + const validUpdate = isValidUpdate(existingSourceOrigin, newSourceOrigin); if (!validUpdate) { throw new FirebaseError( `Cannot update from a(n) ${existingSourceOrigin} to a(n) ${newSourceOrigin}. Please provide a new source that is a(n) ${existingSourceOrigin} and try again.` ); } - - const isPublished = [ - SourceOrigin.OFFICIAL_EXTENSION, - SourceOrigin.OFFICIAL_EXTENSION_VERSION, - SourceOrigin.PUBLISHED_EXTENSION, - SourceOrigin.PUBLISHED_EXTENSION_VERSION, - ].includes(newSourceOrigin); - displayExtInfo(instanceId, existingSpec, isPublished); - // TODO: remove "falls through" once producer and registry experience are released switch (newSourceOrigin) { case SourceOrigin.LOCAL: @@ -180,7 +164,7 @@ export default new Command("ext:update [updateSource]") break; } case SourceOrigin.OFFICIAL_EXTENSION_VERSION: - newSourceName = await updateToVersionFromRegistry( + newSourceName = await updateToVersionFromRegistryFile( projectId, instanceId, existingSpec, @@ -189,7 +173,7 @@ export default new Command("ext:update [updateSource]") ); break; case SourceOrigin.OFFICIAL_EXTENSION: - newSourceName = await updateFromRegistry( + newSourceName = await updateFromRegistryFile( projectId, instanceId, existingSpec, @@ -198,30 +182,23 @@ export default new Command("ext:update [updateSource]") break; // falls through case SourceOrigin.PUBLISHED_EXTENSION_VERSION: - if (previews.extdev) { - newSourceName = await updateToVersionFromPublisherSource( - projectId, - instanceId, - updateSource, - existingSpec, - existingSource - ); - published = true; - break; - } - // falls through + newSourceName = await updateToVersionFromPublisherSource( + projectId, + instanceId, + updateSource, + existingSpec, + existingSource + ); + break; case SourceOrigin.PUBLISHED_EXTENSION: - if (previews.extdev) { - newSourceName = await updateFromPublisherSource( - projectId, - instanceId, - updateSource, - existingSpec, - existingSource - ); - published = true; - break; - } + newSourceName = await updateFromPublisherSource( + projectId, + instanceId, + updateSource, + existingSpec, + existingSource + ); + break; default: throw new FirebaseError(`Unknown source '${clc.bold(updateSource)}.'`); } @@ -250,7 +227,10 @@ export default new Command("ext:update [updateSource]") return; } } - await displayChanges(existingSpec, newSpec, published); + const isOfficial = + newSourceOrigin === SourceOrigin.OFFICIAL_EXTENSION || + newSourceOrigin === SourceOrigin.OFFICIAL_EXTENSION_VERSION; + await displayChanges(existingSpec, newSpec, isOfficial); if (newSpec.billingRequired) { const enabled = await isBillingEnabled(projectId); if (!enabled) { @@ -270,7 +250,6 @@ export default new Command("ext:update [updateSource]") const updateOptions: UpdateOptions = { projectId, instanceId, - source: newSource, }; if (newSourceName.includes("publisher")) { const { publisherId, extensionId, version } = extensionsApi.parseExtensionVersionName( diff --git a/src/commands/index.js b/src/commands/index.js index e5fcd999561..d604b33b792 100644 --- a/src/commands/index.js +++ b/src/commands/index.js @@ -77,6 +77,7 @@ module.exports = function (client) { client.ext.dev.emulators.exec = loadCommand("ext-dev-emulators-exec"); client.ext.dev.unpublish = loadCommand("ext-dev-unpublish"); client.ext.dev.publish = loadCommand("ext-dev-publish"); + client.ext.dev.delete = loadCommand("ext-dev-extension-delete"); } client.firestore = {}; client.firestore.delete = loadCommand("firestore-delete"); diff --git a/src/extensions/askUserForConsent.ts b/src/extensions/askUserForConsent.ts index 93a093b371d..5bc34e87b03 100644 --- a/src/extensions/askUserForConsent.ts +++ b/src/extensions/askUserForConsent.ts @@ -7,6 +7,8 @@ import { FirebaseError } from "../error"; import { logPrefix } from "../extensions/extensionsHelper"; import * as iam from "../gcp/iam"; import { promptOnce, Question } from "../prompt"; +import { confirmInstallInstance } from "./extensionsHelper"; +import { getTrustedPublishers } from "./resolveSource"; import * as utils from "../utils"; marked.setOptions({ @@ -101,3 +103,22 @@ export async function promptForPublisherTOS() { }); } } + +/** + * Displays a warning when installing an extension instance published by someone other than Firebase, and ask the user whether to continue installing. + * @param publisherId the publisher Id of the extension being installed. + * @param authorUrl the author url provided in the spec of the extension, if one was provided. + */ +export async function checkAndPromptForEapPublisher( + publisherId: string, + sourceUrl?: string +): Promise { + const trustedPublishers = await getTrustedPublishers(); + const publisherNameLink = sourceUrl ? `[${publisherId}](${sourceUrl})` : publisherId; + if (!trustedPublishers.includes(publisherId)) { + const warningMsg = `This extension is in preview and is built by a developer in the [Extensions Publisher Early Access Program](http://bit.ly/firex-provider). Its functionality might change in backward-incompatible ways. Since this extension isn't built by Firebase, reach out to ${publisherNameLink} with questions about this extension.`; + utils.logLabeledBullet(logPrefix, marked(warningMsg)); + return await confirmInstallInstance(false); + } + return true; +} diff --git a/src/extensions/displayExtensionInfo.ts b/src/extensions/displayExtensionInfo.ts index cecf0c0b358..ac36bed3187 100644 --- a/src/extensions/displayExtensionInfo.ts +++ b/src/extensions/displayExtensionInfo.ts @@ -26,14 +26,15 @@ const deletionColor = clc.red; */ export function displayExtInfo( extensionName: string, + publisher: string, spec: extensionsApi.ExtensionSpec, published = false ): string[] { const lines = []; lines.push(`**Name**: ${spec.displayName}`); - const url = spec.author?.url; - const urlMarkdown = url ? `(**[${url}](${url})**)` : ""; - lines.push(`**Author**: ${spec.author?.authorName} ${urlMarkdown}`); + if (publisher) { + lines.push(`**Publisher**: ${publisher}`); + } if (spec.description) { lines.push(`**Description**: ${spec.description}`); } @@ -74,7 +75,7 @@ export function displayExtInfo( export function displayUpdateChangesNoInput( spec: extensionsApi.ExtensionSpec, newSpec: extensionsApi.ExtensionSpec, - published = false + isOfficial = true ): string[] { const lines: string[] = []; if (spec.displayName !== newSpec.displayName) { @@ -104,7 +105,7 @@ export function displayUpdateChangesNoInput( ); } - if (published) { + if (!isOfficial) { if (spec.sourceUrl !== newSpec.sourceUrl) { lines.push( "", diff --git a/src/extensions/extensionsApi.ts b/src/extensions/extensionsApi.ts index b334434c1e6..ad54b43fa02 100644 --- a/src/extensions/extensionsApi.ts +++ b/src/extensions/extensionsApi.ts @@ -2,11 +2,12 @@ import * as semver from "semver"; import * as yaml from "js-yaml"; import * as _ from "lodash"; import * as clc from "cli-color"; - +import * as marked from "marked"; import * as api from "../api"; import { logger } from "../logger"; import * as operationPoller from "../operation-poller"; import { FirebaseError } from "../error"; +import { RegistryLaunchStage, Visibility } from "../extensions/extensionsHelper"; const VERSION = "v1beta"; const PAGE_SIZE_MAX = 100; @@ -15,7 +16,8 @@ const refRegex = new RegExp(/^([^/@\n]+)\/{1}([^/@\n]+)(@{1}([a-z0-9.-]+)|)$/); export interface Extension { name: string; ref: string; - state: "STATE_UNSPECIFIED" | "PUBLISHED"; + visibility: Visibility; + registryLaunchStage: RegistryLaunchStage; createTime: string; latestVersion?: string; latestVersionCreateTime?: string; @@ -25,7 +27,6 @@ export interface ExtensionVersion { name: string; ref: string; spec: ExtensionSpec; - state?: "STATE_UNSPECIFIED" | "PUBLISHED"; hash: string; createTime?: string; } @@ -394,8 +395,7 @@ async function patchInstance( return pollRes; } -function populateResourceProperties(source: ExtensionSource): void { - const spec: ExtensionSpec = source.spec; +function populateResourceProperties(spec: ExtensionSpec): void { if (spec) { spec.resources.forEach((r) => { try { @@ -435,7 +435,9 @@ export async function createSource( operationResourceName: createRes.body.name, masterTimeout: 600000, }); - populateResourceProperties(pollRes); + if (pollRes.spec) { + populateResourceProperties(pollRes.spec); + } return pollRes; } @@ -450,7 +452,9 @@ export function getSource(sourceName: string): Promise { origin: api.extensionsOrigin, }) .then((res) => { - populateResourceProperties(res.body); + if (res.body.spec) { + populateResourceProperties(res.body.spec); + } return res.body; }); } @@ -472,19 +476,13 @@ export async function getExtensionVersion(ref: string): Promise { @@ -626,12 +627,50 @@ export async function unpublishExtension(ref: string): Promise { throw new FirebaseError( `You are not the owner of extension '${clc.bold( ref - )}' and don’t have the correct permissions to unpublish this extension.` + )}' and don’t have the correct permissions to unpublish this extension.`, + { status: err.status } ); } else if (err instanceof FirebaseError) { throw err; } - throw new FirebaseError(`Error occurred unpublishing extension '${ref}': ${err}`); + throw new FirebaseError(`Error occurred unpublishing extension '${ref}': ${err}`, { + status: err.status, + }); + } +} + +/** + * Delete a published extension. + * This will also mark the name as reserved to prevent future usages. + * @param ref user-friendly identifier for the Extension (publisher-id/extension-id) + */ +export async function deleteExtension(ref: string): Promise { + const { publisherId, extensionId, version } = parseRef(ref); + if (version) { + throw new FirebaseError(`Extension reference "${ref}" must not contain a version.`); + } + const url = `/${VERSION}/publishers/${publisherId}/extensions/${extensionId}`; + try { + await api.request("DELETE", url, { + auth: true, + origin: api.extensionsOrigin, + }); + } catch (err) { + if (err.status === 403) { + throw new FirebaseError( + `You are not the owner of extension '${clc.bold( + ref + )}' and don’t have the correct permissions to delete this extension.`, + { status: err.status } + ); + } else if (err.status === 404) { + throw new FirebaseError(`Extension ${clc.bold(ref)} was not found.`); + } else if (err instanceof FirebaseError) { + throw err; + } + throw new FirebaseError(`Error occurred delete extension '${ref}': ${err}`, { + status: err.status, + }); } } @@ -653,23 +692,42 @@ export async function getExtension(ref: string): Promise { return res.body; } catch (err) { if (err.status === 404) { - throw new FirebaseError( - `The extension reference '${clc.bold( - ref - )}' doesn't exist. This could happen for two reasons:\n` + - ` -The publisher ID '${clc.bold(publisherId)}' doesn't exist or could be misspelled\n` + - ` -The name of the extension '${clc.bold( - extensionId - )}' doesn't exist or could be misspelled\n` + - `Please correct the extension reference and try again.` - ); + throw refNotFoundError(publisherId, extensionId); } else if (err instanceof FirebaseError) { throw err; } - throw new FirebaseError(`Failed to query the extension '${clc.bold(ref)}': ${err}`); + throw new FirebaseError(`Failed to query the extension '${clc.bold(ref)}': ${err}`, { + status: err.status, + }); } } +function refNotFoundError( + publisherId: string, + extensionId: string, + versionId?: string +): FirebaseError { + const versionRef = `${publisherId}/${extensionId}@${versionId}`; + const extensionRef = `${publisherId}/${extensionId}`; + return new FirebaseError( + `The extension reference '${clc.bold( + versionId ? versionRef : extensionRef + )}' doesn't exist. This could happen for two reasons:\n` + + ` -The publisher ID '${clc.bold(publisherId)}' doesn't exist or could be misspelled\n` + + ` -The name of the ${versionId ? "extension version" : "extension"} '${clc.bold( + versionId ? `${extensionId}@${versionId}` : extensionId + )}' doesn't exist or could be misspelled\n\n` + + `Please correct the extension reference and try again. If you meant to install an extension from a local source, please provide a relative path prefixed with '${clc.bold( + "./" + )}', '${clc.bold("../")}', or '${clc.bold( + "~/" + )}'. Learn more about local extension installation at ${marked( + "[https://firebase.google.com/docs/extensions/alpha/install-extensions_community#install](https://firebase.google.com/docs/extensions/alpha/install-extensions_community#install)." + )}`, + { status: 404 } + ); +} + /** * @param ref user-friendly identifier * @return array of ref split into publisher id, extension id, and version id (if applicable) diff --git a/src/extensions/extensionsHelper.ts b/src/extensions/extensionsHelper.ts index 4962abd30d3..cbb136de515 100644 --- a/src/extensions/extensionsHelper.ts +++ b/src/extensions/extensionsHelper.ts @@ -31,6 +31,19 @@ import { promptOnce } from "../prompt"; import { logger } from "../logger"; import { envOverride } from "../utils"; +export enum RegistryLaunchStage { + EXPERIMENTAL = "EXPERIMENTAL", + BETA = "BETA", + GA = "GA", + DEPRECATED = "DEPRECATED", + REGISTRY_LAUNCH_STAGE_UNSPECIFIED = "REGISTRY_LAUNCH_STAGE_UNSPECIFIED", +} + +export enum Visibility { + UNLISTED = "unlisted", + PUBLIC = "public", +} + /** * SpecParamType represents the exact strings that the extensions * backend expects for each param type in the extensionYaml. @@ -553,7 +566,7 @@ export async function confirmExtensionVersion( `You are about to publish version ${clc.green(versionId)} of ${clc.green( `${publisherId}/${extensionId}` )} to Firebase's registry of extensions.\n\n` + - "Once an extension version is published, it cannot be changed. If you wish to make changes after publishing, you will need to publish a new version. If you are a member of the Extensions EAP group, your published extensions will only be accessible to other members of the EAP group.\n\n" + + "Once an extension version is published, it cannot be changed. If you wish to make changes after publishing, you will need to publish a new version.\n\n" + "Do you wish to continue?"; return await promptOnce({ type: "confirm", @@ -623,6 +636,25 @@ export async function instanceIdExists(projectId: string, instanceId: string): P return true; } +export function isUrlPath(extInstallPath: string): boolean { + return urlRegex.test(extInstallPath); +} + +export function isLocalPath(extInstallPath: string): boolean { + const trimmedPath = extInstallPath.trim(); + return ( + trimmedPath.startsWith("~/") || + trimmedPath.startsWith("./") || + trimmedPath.startsWith("../") || + trimmedPath.startsWith("/") || + [".", ".."].includes(trimmedPath) + ); +} + +export function isLocalOrURLPath(extInstallPath: string): boolean { + return isLocalPath(extInstallPath) || isUrlPath(extInstallPath); +} + /** * Given an update source, return where the update source came from. * @param sourceOrVersion path to a source or reference to a source version @@ -640,10 +672,10 @@ export async function getSourceOrigin(sourceOrVersion: string): Promise { +export async function confirmInstallInstance(defaultOption?: boolean): Promise { const message = `Would you like to continue installing this extension?`; return await promptOnce({ type: "confirm", message, + default: defaultOption, }); } diff --git a/src/extensions/listExtensions.ts b/src/extensions/listExtensions.ts index 17fffc8f684..e0b31e2ab83 100644 --- a/src/extensions/listExtensions.ts +++ b/src/extensions/listExtensions.ts @@ -26,19 +26,23 @@ export async function listExtensions( } const table = new Table({ - head: ["Extension", "Author", "Instance ID", "State", "Version", "Your last update"], + head: ["Extension", "Publisher", "Instance ID", "State", "Version", "Your last update"], style: { head: ["yellow"] }, }); // Order instances newest to oldest. const sorted = _.sortBy(instances, "createTime", "asc").reverse(); sorted.forEach((instance) => { let extension = _.get(instance, "config.extensionRef", ""); + let publisher; if (extension === "") { extension = _.get(instance, "config.source.spec.name", ""); + publisher = "N/A"; + } else { + publisher = extension.split("/")[0]; } table.push([ extension, - _.get(instance, "config.source.spec.author.authorName", ""), + publisher, _.last(instance.name.split("/")), instance.state + (_.get(instance, "config.source.state", "ACTIVE") === "DELETED" ? " (UNPUBLISHED)" : ""), diff --git a/src/extensions/publishHelpers.ts b/src/extensions/publishHelpers.ts new file mode 100644 index 00000000000..073f4d39229 --- /dev/null +++ b/src/extensions/publishHelpers.ts @@ -0,0 +1,5 @@ +import { consoleOrigin } from "../api"; + +export function consoleInstallLink(extVersionRef: string): string { + return `${consoleOrigin}/project/_/extensions/install?ref=${extVersionRef}`; +} diff --git a/src/extensions/resolveSource.ts b/src/extensions/resolveSource.ts index 2d7ad345ae0..d5f8b15157e 100644 --- a/src/extensions/resolveSource.ts +++ b/src/extensions/resolveSource.ts @@ -6,24 +6,21 @@ import * as api from "../api"; import { FirebaseError } from "../error"; import { logger } from "../logger"; import { promptOnce } from "../prompt"; +import { RegistryLaunchStage } from "../extensions/extensionsHelper"; const EXTENSIONS_REGISTRY_ENDPOINT = "/extensions.json"; -const AUDIENCE_WARNING_MESSAGES: { [key: string]: string } = { - "open-alpha": marked( - `${clc.bold("Important")}: This extension is part of the ${clc.bold( - "preliminary-release program" - )} for extensions.\n Its functionality might change in backward-incompatible ways before its official release. Learn more: https://github.com/firebase/extensions/tree/master/.preliminary-release-extensions` - ), - "closed-alpha": marked( - `${clc.yellow.bold("Important")}: This extension is part of the ${clc.bold( - "Firebase Alpha program" - )}.\n This extension is strictly confidential, and its functionality might change in backward-incompatible ways before its official, public release. Learn more: https://dev-partners.googlesource.com/samples/firebase/extensions-alpha/+/refs/heads/master/README.md` - ), - experimental: marked( +// @TODO(b/184601300): Remove null type as an option in LAUNCH_STAGE_WARNING_MESSAGES, and leave the value +// as undefined for stages where there is no explicit warning. +const LAUNCH_STAGE_WARNING_MESSAGES: { [key in RegistryLaunchStage]: string | null } = { + EXPERIMENTAL: marked( `${clc.yellow.bold("Important")}: This extension is ${clc.bold( "experimental" )} and may not be production-ready. Its functionality might change in backward-incompatible ways before its official release, or it may be discontinued. Learn more: https://github.com/FirebaseExtended/experimental-extensions` ), + BETA: null, + DEPRECATED: null, + GA: null, + REGISTRY_LAUNCH_STAGE_UNSPECIFIED: null, }; export interface RegistryEntry { @@ -32,6 +29,7 @@ export interface RegistryEntry { versions: { [key: string]: string }; updateWarnings?: { [key: string]: UpdateWarning[] }; audience?: string; + publisher: string; } export interface UpdateWarning { @@ -150,13 +148,15 @@ export async function promptForUpdateWarnings( } /** - * Checks the audience field of a RegistryEntry, displays a warning text + * Checks the launch stage field of a RegistryEntry, displays a warning text * for closed and open alpha extensions, and prompts the user to accept. */ -export async function promptForAudienceConsent(registryEntry: RegistryEntry): Promise { +export async function promptForLaunchStageConsent( + launchStage: RegistryLaunchStage +): Promise { let consent = true; - if (registryEntry.audience && AUDIENCE_WARNING_MESSAGES[registryEntry.audience]) { - logger.info(AUDIENCE_WARNING_MESSAGES[registryEntry.audience]); + if (LAUNCH_STAGE_WARNING_MESSAGES[launchStage]) { + logger.info(LAUNCH_STAGE_WARNING_MESSAGES[launchStage] || ""); consent = await promptOnce({ type: "confirm", message: "Do you acknowledge the status of this extension?", @@ -186,3 +186,25 @@ export async function getExtensionRegistry( } return extensions; } + +/** + * Fetches a list all publishers that appear in the v1 registry. + */ +export async function getTrustedPublishers(): Promise { + let registry: { [key: string]: RegistryEntry }; + try { + registry = await getExtensionRegistry(); + } catch (err) { + logger.debug( + "Couldn't get extensions registry, assuming no trusted publishers except Firebase." + ); + return ["firebase "]; + } + const publisherIds = new Set(); + + // eslint-disable-next-line guard-for-in + for (const entry in registry) { + publisherIds.add(registry[entry].publisher); + } + return Array.from(publisherIds); +} diff --git a/src/extensions/updateHelper.ts b/src/extensions/updateHelper.ts index 7f9b44a317a..4cdfb13daa1 100644 --- a/src/extensions/updateHelper.ts +++ b/src/extensions/updateHelper.ts @@ -6,12 +6,20 @@ import { logger } from "../logger"; import * as resolveSource from "./resolveSource"; import * as extensionsApi from "./extensionsApi"; import { promptOnce } from "../prompt"; -import { createSourceFromLocation, logPrefix, SourceOrigin, urlRegex } from "./extensionsHelper"; +import * as marked from "marked"; +import { + createSourceFromLocation, + logPrefix, + SourceOrigin, + urlRegex, + isLocalOrURLPath, +} from "./extensionsHelper"; import * as utils from "../utils"; import { displayUpdateChangesNoInput, displayUpdateChangesRequiringConfirmation, getConsent, + displayExtInfo, } from "./displayExtensionInfo"; function invalidSourceErrMsgTemplate(instanceId: string, source: string): string { @@ -34,6 +42,11 @@ export async function getExistingSourceOrigin( if (instance && instance.config.extensionRef) { return SourceOrigin.PUBLISHED_EXTENSION; } + // TODO: Deprecate this once official extensions are fully using the Registry. + // This logic will try to resolve the source with the Registry File. This allows us to use the old update flow + // of official => officical extensions, if the extension_ref is not filled out. + // After the migration is complete, all instances will have extension_ref filled out (except instances of local/URL sources). + // Once that we happens, we can deprecate this whole try-catch block and assume it is a url/local source. let existingSourceOrigin: SourceOrigin; try { const registryEntry = await resolveSource.resolveRegistryEntry(extensionName); @@ -80,33 +93,34 @@ async function showUpdateVersionInfo( /** * Prints out warning messages and requires user to consent before continuing with update. - * @param projectId name of the project - * @param instanceId name of the instance - * @param extensionName name of the extension being updated - * @param existingSource current source of the extension instance - * @param nextSourceOrigin new source of the extension instance (to be updated to) - * @param warning source origin specific warning message - * @param additionalMsg any additional warnings associated with this update + * @param sourceOrigin source origin */ -export async function warningUpdateToOtherSource( - existingSourceOrigin: SourceOrigin, - warning: string, - nextSourceOrigin: SourceOrigin, - additionalMsg?: string -): Promise { - let msg = warning; - let joinText = ""; - if (existingSourceOrigin === nextSourceOrigin) { - joinText = "also "; +export async function warningUpdateToOtherSource(sourceOrigin: SourceOrigin): Promise { + let targetText; + if ( + [ + SourceOrigin.PUBLISHED_EXTENSION, + SourceOrigin.PUBLISHED_EXTENSION_VERSION, + SourceOrigin.OFFICIAL_EXTENSION, + SourceOrigin.OFFICIAL_EXTENSION_VERSION, + ].includes(sourceOrigin) + ) { + targetText = "published extension"; + } else if (sourceOrigin === SourceOrigin.LOCAL) { + targetText = "local directory"; + } else if (sourceOrigin === SourceOrigin.URL) { + targetText = "URL"; + } + const warning = `All the instance's resources and logic will be overwritten to use the source code and files from the ${targetText}.\n`; + logger.info(marked(warning)); + const continueUpdate = await promptOnce({ + type: "confirm", + message: "Do you wish to continue with this update?", + default: false, + }); + if (!continueUpdate) { + throw new FirebaseError(`Update cancelled.`, { exit: 2 }); } - msg += - `The current source for this instance is a(n) ${existingSourceOrigin}. The new source for this instance will ${joinText}be a(n) ${nextSourceOrigin}.\n\n` + - `${additionalMsg || ""}`; - const updateWarning = { - from: existingSourceOrigin, - description: msg, - }; - return await resolveSource.confirmUpdateWarning(updateWarning); } /** @@ -121,13 +135,13 @@ export async function warningUpdateToOtherSource( export async function displayChanges( spec: extensionsApi.ExtensionSpec, newSpec: extensionsApi.ExtensionSpec, - published = false + isOfficial = true ): Promise { logger.info( "This update contains the following changes (in green and red). " + "If at any point you choose not to continue, the extension will not be updated and the changes will be discarded:\n" ); - displayUpdateChangesNoInput(spec, newSpec, published); + displayUpdateChangesNoInput(spec, newSpec, isOfficial); await displayUpdateChangesRequiringConfirmation(spec, newSpec); } @@ -145,9 +159,9 @@ export async function retryUpdate(): Promise { /** * @param projectId Id of the project containing the instance to update * @param instanceId Id of the instance to update - * @param source A ExtensionSource to update to - * @param params A new set of params to set on the instance - * @param billingRequired Whether the extension requires billing + * @param extRef Extension reference + * @param source An ExtensionSource to update to (if extRef is not passed in) + * @param params Actual fields to update */ export interface UpdateOptions { @@ -193,6 +207,7 @@ export async function updateFromLocalSource( existingSpec: extensionsApi.ExtensionSpec, existingSource: string ): Promise { + displayExtInfo(instanceId, "", existingSpec, false); let source; try { source = await createSourceFromLocation(projectId, localSource); @@ -204,22 +219,7 @@ export async function updateFromLocalSource( `${clc.bold("You are updating this extension instance to a local source.")}` ); await showUpdateVersionInfo(instanceId, existingSpec.version, source.spec.version, localSource); - const warning = - "All the instance's extension-specific resources and logic will be overwritten to use the source code and files from the local directory.\n\n"; - const additionalMsg = - "After updating from a local source, this instance cannot be updated in the future to use a published source from Firebase's registry of extensions."; - const existingSourceOrigin = await getExistingSourceOrigin( - projectId, - instanceId, - existingSpec.name, - existingSource - ); - await module.exports.warningUpdateToOtherSource( - existingSourceOrigin, - warning, - SourceOrigin.LOCAL, - additionalMsg - ); + await warningUpdateToOtherSource(SourceOrigin.LOCAL); return source.name; } @@ -238,6 +238,7 @@ export async function updateFromUrlSource( existingSpec: extensionsApi.ExtensionSpec, existingSource: string ): Promise { + displayExtInfo(instanceId, "", existingSpec, false); let source; try { source = await createSourceFromLocation(projectId, urlSource); @@ -249,22 +250,7 @@ export async function updateFromUrlSource( `${clc.bold("You are updating this extension instance to a URL source.")}` ); await showUpdateVersionInfo(instanceId, existingSpec.version, source.spec.version, urlSource); - const warning = - "All the instance's extension-specific resources and logic will be overwritten to use the source code and files from the URL.\n\n"; - const additionalMsg = - "After updating from a URL source, this instance cannot be updated in the future to use a published source from Firebase's registry of extensions."; - const existingSourceOrigin = await getExistingSourceOrigin( - projectId, - instanceId, - existingSpec.name, - existingSource - ); - await module.exports.warningUpdateToOtherSource( - existingSourceOrigin, - warning, - SourceOrigin.URL, - additionalMsg - ); + await warningUpdateToOtherSource(SourceOrigin.URL); return source.name; } @@ -282,14 +268,13 @@ export async function updateToVersionFromPublisherSource( existingSource: string ): Promise { let source; + const refObj = extensionsApi.parseRef(extVersionRef); + const version = refObj.version; + displayExtInfo(instanceId, refObj.publisherId, existingSpec, true); + const extension = await extensionsApi.getExtension(`${refObj.publisherId}/${refObj.extensionId}`); try { source = await extensionsApi.getExtensionVersion(extVersionRef); } catch (err) { - const refObj = extensionsApi.parseRef(extVersionRef); - const version = refObj.version; - const extension = await extensionsApi.getExtension( - `${refObj.publisherId}/${refObj.extensionId}` - ); throw new FirebaseError( `Could not find source '${clc.bold(extVersionRef)}' because (${clc.bold( version @@ -298,24 +283,44 @@ export async function updateToVersionFromPublisherSource( )}).` ); } + let registryEntry; + let sourceOrigin; + try { + // Double check that both publisher and extension ID match + // If the publisher and extension ID both match, we know it's an official extension (i.e. it's specifically listed in our Registry File) + // Otherwise, it's simply a published extension in the Registry + registryEntry = await resolveSource.resolveRegistryEntry(existingSpec.name); + sourceOrigin = + registryEntry.publisher === refObj.publisherId + ? SourceOrigin.OFFICIAL_EXTENSION + : SourceOrigin.PUBLISHED_EXTENSION; + } catch (err) { + sourceOrigin = SourceOrigin.PUBLISHED_EXTENSION; + } utils.logLabeledBullet( logPrefix, - `${clc.bold("You are updating this extension instance to a published source.")}` + `${clc.bold(`You are updating this extension instance to a(n) ${sourceOrigin}.`)}` ); + if (registryEntry) { + // Do not allow user to "downgrade" to a version lower than the minimum required version. + const minVer = resolveSource.getMinRequiredVersion(registryEntry); + if (minVer && semver.gt(minVer, source.spec.version)) { + throw new FirebaseError( + `The version you are trying to update to (${clc.bold( + source.spec.version + )}) is less than the minimum version required (${clc.bold(minVer)}) to use this extension.` + ); + } + } await showUpdateVersionInfo(instanceId, existingSpec.version, source.spec.version, extVersionRef); - const warning = - "All the instance's extension-specific resources and logic will be overwritten to use the source code and files from the published extension.\n\n"; - const existingSourceOrigin = await getExistingSourceOrigin( - projectId, - instanceId, - existingSpec.name, - existingSource - ); - await module.exports.warningUpdateToOtherSource( - existingSourceOrigin, - warning, - SourceOrigin.PUBLISHED_EXTENSION - ); + await warningUpdateToOtherSource(SourceOrigin.PUBLISHED_EXTENSION); + if (registryEntry) { + await resolveSource.promptForUpdateWarnings( + registryEntry, + existingSpec.version, + source.spec.version + ); + } return source.name; } @@ -349,7 +354,7 @@ export async function updateFromPublisherSource( * @param existingSource name of existing instance source * @param version Version to update the instance to */ -export async function updateToVersionFromRegistry( +export async function updateToVersionFromRegistryFile( projectId: string, instanceId: string, existingSpec: extensionsApi.ExtensionSpec, @@ -369,6 +374,7 @@ export async function updateToVersionFromRegistry( `Cannot find the latest version of this extension. To update this instance to a local source or URL source, run "firebase ext:update ${instanceId} ".` ); } + displayExtInfo(instanceId, registryEntry.publisher, existingSpec, true); utils.logLabeledBullet( logPrefix, clc.bold("You are updating this extension instance to an official source.") @@ -387,19 +393,7 @@ export async function updateToVersionFromRegistry( } const targetVersion = resolveSource.getTargetVersion(registryEntry, version); await showUpdateVersionInfo(instanceId, existingSpec.version, targetVersion); - const warning = - "All the instance's extension-specific resources and logic will be overwritten to use the source code and files from the latest released version.\n\n"; - const existingSourceOrigin = await getExistingSourceOrigin( - projectId, - instanceId, - existingSpec.name, - existingSource - ); - await module.exports.warningUpdateToOtherSource( - existingSourceOrigin, - warning, - SourceOrigin.OFFICIAL_EXTENSION - ); + await warningUpdateToOtherSource(SourceOrigin.OFFICIAL_EXTENSION); await resolveSource.promptForUpdateWarnings(registryEntry, existingSpec.version, targetVersion); return resolveSource.resolveSourceUrl(registryEntry, existingSpec.name, targetVersion); } @@ -411,11 +405,35 @@ export async function updateToVersionFromRegistry( * @param existingSpec ExtensionSpec of the existing instance source * @param existingSource name of existing instance source */ -export async function updateFromRegistry( +export async function updateFromRegistryFile( projectId: string, instanceId: string, existingSpec: extensionsApi.ExtensionSpec, existingSource: string ): Promise { - return updateToVersionFromRegistry(projectId, instanceId, existingSpec, existingSource, "latest"); + return updateToVersionFromRegistryFile( + projectId, + instanceId, + existingSpec, + existingSource, + "latest" + ); +} + +export function inferUpdateSource(updateSource: string, existingRef: string): string { + if (!updateSource) { + return `${existingRef}@latest`; + } + if (semver.valid(updateSource)) { + return `${existingRef}@${updateSource}`; + } + if (!isLocalOrURLPath(updateSource) && updateSource.split("/").length < 2) { + return updateSource.includes("@") + ? `firebase/${updateSource}` + : `firebase/${updateSource}@latest`; + } + if (!isLocalOrURLPath(updateSource) && !updateSource.includes("@")) { + return `${updateSource}@latest`; + } + return updateSource; } diff --git a/src/test/extensions/askUserForConsent.spec.ts b/src/test/extensions/askUserForConsent.spec.ts index 7fd2424e46b..9e6aa98d9c1 100644 --- a/src/test/extensions/askUserForConsent.spec.ts +++ b/src/test/extensions/askUserForConsent.spec.ts @@ -8,6 +8,8 @@ import * as sinon from "sinon"; import * as askUserForConsent from "../../extensions/askUserForConsent"; import * as iam from "../../gcp/iam"; +import * as resolveSource from "../../extensions/resolveSource"; +import * as extensionHelper from "../../extensions/extensionsHelper"; const expect = chai.expect; @@ -51,4 +53,47 @@ describe("askUserForConsent", () => { return expect(actual).to.eventually.deep.equal(expected); }); }); + + describe("checkAndPromptForEapPublisher", () => { + let getTrustedPublisherStub: sinon.SinonStub; + let confirmInstallStub: sinon.SinonStub; + + beforeEach(() => { + getTrustedPublisherStub = sinon.stub(resolveSource, "getTrustedPublishers"); + getTrustedPublisherStub.returns(["firebase"]); + confirmInstallStub = sinon.stub(extensionHelper, "confirmInstallInstance"); + confirmInstallStub.rejects("UNDEFINED TEST BEHAVIOR"); + }); + + afterEach(() => { + getTrustedPublisherStub.restore(); + confirmInstallStub.restore(); + }); + + it("should not prompt if the publisher is on the approved publisher list", async () => { + const publisherId = "firebase"; + + expect(await askUserForConsent.checkAndPromptForEapPublisher(publisherId)).to.be.true; + + expect(confirmInstallStub).to.not.have.been.called; + }); + + it("should prompt if the publisher is not on the approved publisher list", async () => { + confirmInstallStub.onFirstCall().returns(true); + const publisherId = "pubby-mcpublisher"; + + expect(await askUserForConsent.checkAndPromptForEapPublisher(publisherId)).to.be.true; + + expect(confirmInstallStub).to.have.been.called; + }); + + it("should return false if the user doesn't accept the prompt", async () => { + confirmInstallStub.onFirstCall().returns(false); + const publisherId = "pubby-mcpublisher"; + + expect(await askUserForConsent.checkAndPromptForEapPublisher(publisherId)).to.be.false; + + expect(confirmInstallStub).to.have.been.called; + }); + }); }); diff --git a/src/test/extensions/displayExtensionInfo.spec.ts b/src/test/extensions/displayExtensionInfo.spec.ts index 4bf4631463c..9440f6e523b 100644 --- a/src/test/extensions/displayExtensionInfo.spec.ts +++ b/src/test/extensions/displayExtensionInfo.spec.ts @@ -34,19 +34,20 @@ const SPEC = { describe("displayExtensionInfo", () => { describe("displayExtInfo", () => { it("should display info during install", () => { - const loggedLines = displayExtensionInfo.displayExtInfo(SPEC.name, SPEC); - const expected: string[] = [ - "**Name**: Old", - "**Author**: Tester (**[firebase.google.com](firebase.google.com)**)", - "**Description**: descriptive", - ]; + const loggedLines = displayExtensionInfo.displayExtInfo(SPEC.name, "", SPEC); + const expected: string[] = ["**Name**: Old", "**Description**: descriptive"]; expect(loggedLines).to.eql(expected); }); it("should display additional information for a published extension", () => { - const loggedLines = displayExtensionInfo.displayExtInfo(SPEC.name, SPEC, true); + const loggedLines = displayExtensionInfo.displayExtInfo( + SPEC.name, + "testpublisher", + SPEC, + true + ); const expected: string[] = [ "**Name**: Old", - "**Author**: Tester (**[firebase.google.com](firebase.google.com)**)", + "**Publisher**: testpublisher", "**Description**: descriptive", "**License**: MIT", "**Source code**: test.com", diff --git a/src/test/extensions/extensionsApi.spec.ts b/src/test/extensions/extensionsApi.spec.ts index 543b8d3c80c..7c152b6778f 100644 --- a/src/test/extensions/extensionsApi.spec.ts +++ b/src/test/extensions/extensionsApi.spec.ts @@ -592,6 +592,38 @@ describe("publishExtensionVersion", () => { }); }); +describe("deleteExtension", () => { + afterEach(() => { + nock.cleanAll(); + }); + + it("should make a DELETE call to the correct endpoint", async () => { + nock(api.extensionsOrigin) + .delete(`/${VERSION}/publishers/${PUBLISHER_ID}/extensions/${EXTENSION_ID}`) + .reply(200); + + await extensionsApi.deleteExtension(`${PUBLISHER_ID}/${EXTENSION_ID}`); + expect(nock.isDone()).to.be.true; + }); + + it("should throw a FirebaseError if the endpoint returns an error response", async () => { + nock(api.extensionsOrigin) + .delete(`/${VERSION}/publishers/${PUBLISHER_ID}/extensions/${EXTENSION_ID}`) + .reply(404); + + await expect( + extensionsApi.deleteExtension(`${PUBLISHER_ID}/${EXTENSION_ID}`) + ).to.be.rejectedWith(FirebaseError); + expect(nock.isDone()).to.be.true; + }); + + it("should throw an error for an invalid ref", async () => { + await expect( + extensionsApi.deleteExtension(`${PUBLISHER_ID}/${EXTENSION_ID}@`) + ).to.be.rejectedWith(FirebaseError, "Extension reference must be in format"); + }); +}); + describe("unpublishExtension", () => { afterEach(() => { nock.cleanAll(); diff --git a/src/test/extensions/extensionsHelper.spec.ts b/src/test/extensions/extensionsHelper.spec.ts index 0769159aeee..3bc65d82401 100644 --- a/src/test/extensions/extensionsHelper.spec.ts +++ b/src/test/extensions/extensionsHelper.spec.ts @@ -777,6 +777,7 @@ describe("extensionsHelper", () => { "0.1.0": "projects/test-proj/sources/def456", "0.1.1": testOnePlatformSourceName, }, + publisher: "firebase", }; const testSource: ExtensionSource = { name: "test", diff --git a/src/test/extensions/resolveSource.spec.ts b/src/test/extensions/resolveSource.spec.ts index 62b2b796f2a..ba59efdff86 100644 --- a/src/test/extensions/resolveSource.spec.ts +++ b/src/test/extensions/resolveSource.spec.ts @@ -8,6 +8,7 @@ const testRegistryEntry = { labels: { latest: "0.2.0", }, + publisher: "firebase", versions: { "0.1.0": "projects/firebasemods/sources/2kd", "0.1.1": "projects/firebasemods/sources/xyz", diff --git a/src/test/extensions/updateHelper.spec.ts b/src/test/extensions/updateHelper.spec.ts index 706698bf679..96292d0dbb8 100644 --- a/src/test/extensions/updateHelper.spec.ts +++ b/src/test/extensions/updateHelper.spec.ts @@ -14,7 +14,7 @@ const SPEC = { name: "test", displayName: "Old", description: "descriptive", - version: "0.1.0", + version: "0.2.0", license: "MIT", apis: [ { apiName: "api1", reason: "" }, @@ -35,6 +35,8 @@ const SPEC = { params: [], }; +const OLD_SPEC = Object.assign({}, SPEC, { version: "0.1.0" }); + const SOURCE = { name: "projects/firebasemods/sources/new-test-source", packageUri: "https://firebase-fake-bucket.com", @@ -54,7 +56,6 @@ const EXTENSION_VERSION = { const EXTENSION = { name: "publishers/test-publisher/extensions/test", ref: "test-publisher/test", - spec: SPEC, state: "PUBLISHED", createTime: "2020-06-30T00:21:06.722782Z", latestVersion: "0.2.0", @@ -426,7 +427,7 @@ describe("updateHelper", () => { it("should return the correct source name for a valid published source", async () => { promptStub.resolves(true); registryEntryStub.resolves(REGISTRY_ENTRY); - const name = await updateHelper.updateToVersionFromRegistry( + const name = await updateHelper.updateToVersionFromRegistryFile( "test-project", "test-instance", SPEC, @@ -440,7 +441,7 @@ describe("updateHelper", () => { promptStub.resolves(true); registryEntryStub.throws("Unable to find extension source"); await expect( - updateHelper.updateToVersionFromRegistry( + updateHelper.updateToVersionFromRegistryFile( "test-project", "test-instance", SPEC, @@ -453,11 +454,11 @@ describe("updateHelper", () => { it("should not update if the update warning is not confirmed", async () => { promptStub.resolves(false); await expect( - updateHelper.updateToVersionFromRegistry( + updateHelper.updateToVersionFromRegistryFile( "test-project", "test-instance", - SPEC, - SPEC.name, + OLD_SPEC, + OLD_SPEC.name, "0.1.2" ) ).to.be.rejectedWith(FirebaseError, "Update cancelled."); @@ -465,7 +466,7 @@ describe("updateHelper", () => { it("should not update if version given less than min version required", async () => { await expect( - updateHelper.updateToVersionFromRegistry( + updateHelper.updateToVersionFromRegistryFile( "test-project", "test-instance", SPEC, @@ -503,7 +504,7 @@ describe("updateHelper", () => { it("should return the correct source name for a valid published source", async () => { promptStub.resolves(true); - const name = await updateHelper.updateFromRegistry( + const name = await updateHelper.updateFromRegistryFile( "test-project", "test-instance", SPEC, @@ -516,7 +517,7 @@ describe("updateHelper", () => { promptStub.resolves(true); registryEntryStub.throws("Unable to find extension source"); await expect( - updateHelper.updateFromRegistry("test-project", "test-instance", SPEC, SPEC.name) + updateHelper.updateFromRegistryFile("test-project", "test-instance", SPEC, SPEC.name) ).to.be.rejectedWith(FirebaseError, "Cannot find the latest version of this extension."); }); @@ -524,8 +525,41 @@ describe("updateHelper", () => { promptStub.resolves(false); registryEntryStub.resolves(REGISTRY_ENTRY); await expect( - updateHelper.updateFromRegistry("test-project", "test-instance", SPEC, SPEC.name) + updateHelper.updateFromRegistryFile( + "test-project", + "test-instance", + OLD_SPEC, + OLD_SPEC.name + ) ).to.be.rejectedWith(FirebaseError, "Update cancelled."); }); }); }); + +describe("inferUpdateSource", () => { + it("should infer update source from ref without version", () => { + const result = updateHelper.inferUpdateSource("", "firebase/storage-resize-images"); + expect(result).to.equal("firebase/storage-resize-images@latest"); + }); + + it("should infer update source from ref with just version", () => { + const result = updateHelper.inferUpdateSource("0.1.2", "firebase/storage-resize-images"); + expect(result).to.equal("firebase/storage-resize-images@0.1.2"); + }); + + it("should infer update source from ref and extension name", () => { + const result = updateHelper.inferUpdateSource( + "storage-resize-images", + "firebase/storage-resize-images" + ); + expect(result).to.equal("firebase/storage-resize-images@latest"); + }); + + it("should infer update source if it is a ref distinct from the input ref", () => { + const result = updateHelper.inferUpdateSource( + "notfirebase/storage-resize-images", + "firebase/storage-resize-images" + ); + expect(result).to.equal("notfirebase/storage-resize-images@latest"); + }); +});