UNPKG

firebase-tools

Version:
418 lines (417 loc) 20.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.command = exports.FUNCTION_KITS_DIR = exports.DEFAULT_TEMPLATE = exports.TEMPLATES = void 0; exports.generateUniqueId = generateUniqueId; exports.parseNpmPackageSpecifier = parseNpmPackageSpecifier; exports.validateNpmPackageName = validateNpmPackageName; exports.sanitizePackageNameToKitName = sanitizePackageNameToKitName; exports.isThirdPartyPackage = isThirdPartyPackage; exports.checkPackageHasShrinkwrap = checkPackageHasShrinkwrap; exports.isKitConfiguredForProject = isKitConfiguredForProject; exports.extractExistingFunctionsInfo = extractExistingFunctionsInfo; exports.promptKitInstanceId = promptKitInstanceId; exports.promptKitId = promptKitId; exports.promptSecurityConfirmation = promptSecurityConfirmation; exports.promptExistingInstanceForProject = promptExistingInstanceForProject; exports.addKitInstanceOrConfigureProject = addKitInstanceOrConfigureProject; exports.scaffoldKitFiles = scaffoldKitFiles; exports.buildAndInstallKit = buildAndInstallKit; exports.addKitToConfig = addKitToConfig; const clc = require("colorette"); const crypto = require("crypto"); const path = require("path"); const fs = require("fs-extra"); const command_1 = require("../command"); const error_1 = require("../error"); const projectUtils_1 = require("../projectUtils"); const utils_1 = require("../utils"); const projectConfig_1 = require("../functions/projectConfig"); const experiments = require("../experiments"); const logger_1 = require("../logger"); const prompt_1 = require("../prompt"); const spawn_1 = require("../init/spawn"); const templates_1 = require("../templates"); const supported = require("../deploy/functions/runtimes/supported"); const env_1 = require("../functions/env"); const self = require("./functions-kits-install"); const PACKAGE_NO_LINTING_TEMPLATE = (0, templates_1.readTemplateSync)("init/functions/typescript/package.nolint.json"); const TSCONFIG_TEMPLATE = (0, templates_1.readTemplateSync)("init/functions/typescript/tsconfig.json"); const GITIGNORE_TEMPLATE = (0, templates_1.readTemplateSync)("init/functions/typescript/_gitignore"); const INDEX_KIT_TEMPLATE = (0, templates_1.readTemplateSync)("init/functions/typescript/index-kit.ts"); const INDEX_KIT_MIGRATION_TEMPLATE = (0, templates_1.readTemplateSync)("init/functions/typescript/index-kit-migration.ts"); exports.TEMPLATES = { installation: INDEX_KIT_TEMPLATE, migration: INDEX_KIT_MIGRATION_TEMPLATE, }; exports.DEFAULT_TEMPLATE = "installation"; exports.FUNCTION_KITS_DIR = "function-kits"; function generateUniqueId(baseId, existingIds) { if (!existingIds.has(baseId)) { return baseId; } const prefix = baseId.slice(0, 35); let candidate = ""; do { const randomSuffix = crypto.randomBytes(2).toString("hex"); candidate = `${prefix}-${randomSuffix}`; } while (existingIds.has(candidate)); return candidate; } function parseNpmPackageSpecifier(rawPkg) { const lastAt = rawPkg.lastIndexOf("@"); if (lastAt > 0) { return { packageName: rawPkg.substring(0, lastAt), version: rawPkg.substring(lastAt + 1), }; } return { packageName: rawPkg }; } function validateNpmPackageName(packageName) { const npmPackageRegex = /^(?:@[a-z0-9_.-]+\/[a-z0-9_.-]+|[a-z0-9_.-]+)$/i; if (!packageName || packageName.length > 214 || !npmPackageRegex.test(packageName)) { throw new error_1.FirebaseError(`Invalid NPM package name '${packageName}'. Package names must be valid npm package specifiers (e.g. 'my-kit' or '@scope/my-kit').`); } } function sanitizePackageNameToKitName(packageName) { const parts = packageName.split("/"); const nameWithoutScope = parts[parts.length - 1] || packageName; const sanitized = nameWithoutScope.toLowerCase().replace(/[^a-z0-9_-]/g, ""); return sanitized.slice(0, 40); } function isThirdPartyPackage(packageName) { return !packageName.startsWith("@firebase-functions-kits/"); } async function checkPackageHasShrinkwrap(rawPkgName) { try { const output = await (0, spawn_1.spawnWithOutput)("npm", ["pack", rawPkgName, "--dry-run", "--json"]); const parsed = JSON.parse(output); if (Array.isArray(parsed) && parsed.length > 0) { const pkgInfo = parsed[0]; if (pkgInfo.hasShrinkwrap) { return true; } if (Array.isArray(pkgInfo.files)) { return pkgInfo.files.some((f) => f.path === "npm-shrinkwrap.json" || f.path === "npm-shrinkwrap.js"); } } } catch (err) { logger_1.logger.debug(`Failed to inspect package shrinkwrap for ${rawPkgName}: ${(0, error_1.getErrMsg)(err)}`); } return false; } function isKitConfiguredForProject(config, kit, projectId, projectAlias) { return Object.values(kit.instances || {}).some((configDir) => (0, env_1.hasProjectEnv)(config.path(configDir), projectId, projectAlias)); } function extractExistingFunctionsInfo(configFunctions) { const existingFunctions = configFunctions && (!Array.isArray(configFunctions) || configFunctions.length > 0) ? (0, projectConfig_1.normalizeAndValidate)(configFunctions) : []; const existingKitIds = new Set(); const existingCodebases = new Set(); const existingInstanceIds = new Set(); for (const c of existingFunctions) { if ((0, projectConfig_1.isKitConfig)(c)) { if (c.kit) { existingKitIds.add(c.kit); } if (c.instances) { for (const instId of Object.keys(c.instances)) { existingInstanceIds.add(instId); } } } else if (c.codebase) { existingCodebases.add(c.codebase); } } return { existingFunctions, existingKitIds, existingCodebases, existingInstanceIds, }; } async function promptKitInstanceId(baseKitId, existingInstanceIds, existingCodebases, nonInteractive) { const instanceCollisions = new Set([...existingInstanceIds, ...existingCodebases]); const defaultInstanceId = generateUniqueId(baseKitId, instanceCollisions); const instanceId = await (0, prompt_1.input)({ message: "What would you like to name this instance?", default: defaultInstanceId, nonInteractive, validate: (val) => { try { (0, projectConfig_1.validateKitInstanceId)(val); } catch (err) { return (0, error_1.getErrMsg)(err); } if (existingInstanceIds.has(val)) { return `functions kit instance ID must be unique across all kits, but '${val}' was used more than once.`; } if (existingCodebases.has(val)) { return `functions codebase name and kit instance ID must be mutually exclusive, but '${val}' was used as both a codebase name and a kit instance ID.`; } return true; }, }); (0, projectConfig_1.validateKitInstanceId)(instanceId); if (existingInstanceIds.has(instanceId)) { throw new error_1.FirebaseError(`functions kit instance ID must be unique across all kits, but '${instanceId}' was used more than once.`); } if (existingCodebases.has(instanceId)) { throw new error_1.FirebaseError(`functions codebase name and kit instance ID must be mutually exclusive, but '${instanceId}' was used as both a codebase name and a kit instance ID.`); } return instanceId; } async function promptKitId(packageName, existingKitIds, nonInteractive) { const baseKitId = sanitizePackageNameToKitName(packageName); const defaultKitId = generateUniqueId(baseKitId, existingKitIds); const kitId = await (0, prompt_1.input)({ message: "What would you like to name this kit?", default: defaultKitId, nonInteractive, validate: (val) => { try { (0, projectConfig_1.validateKit)(val); } catch (err) { return (0, error_1.getErrMsg)(err); } if (existingKitIds.has(val)) { return `functions.kit must be unique but '${val}' was used more than once.`; } return true; }, }); (0, projectConfig_1.validateKit)(kitId); if (existingKitIds.has(kitId)) { throw new error_1.FirebaseError(`functions.kit must be unique but '${kitId}' was used more than once.`); } return kitId; } async function promptSecurityConfirmation(rawPkgName, packageName, nonInteractive) { const isThirdParty = isThirdPartyPackage(packageName); if (isThirdParty) { (0, utils_1.logLabeledWarning)("functions", `Package ${clc.bold(packageName)} is a third-party kit (outside the @firebase-functions-kits scope).`); } const hasShrinkwrap = await self.checkPackageHasShrinkwrap(rawPkgName); if (!hasShrinkwrap) { (0, utils_1.logLabeledWarning)("functions", `Package ${clc.bold(packageName)} does not have an npm-shrinkwrap.json file. npm-shrinkwrap guarantees that you deploy the same version of dependencies that the publisher tested against. Since this kit does not have an npm-shrinkwrap, it is possible that deploys or updates may introduce bugs or vulnerabilities in newer dependency versions that the publisher did not test against.`); } if (isThirdParty || !hasShrinkwrap) { let confirmMessage; if (isThirdParty && !hasShrinkwrap) { confirmMessage = `Are you sure you want to install the third-party kit ${packageName} without locked dependencies?`; } else if (isThirdParty) { confirmMessage = `Are you sure you want to install the third-party kit ${packageName}?`; } else { confirmMessage = `Are you sure you want to install ${packageName} without locked dependencies?`; } const confirmInstallation = await (0, prompt_1.confirm)({ message: confirmMessage, default: false, nonInteractive, }); if (!confirmInstallation) { throw new error_1.FirebaseError("Installation cancelled."); } } return isThirdParty; } async function addInstanceToExistingKit(options, existingKit, existingFunctionsInfo) { const instanceId = await promptKitInstanceId(existingKit.kit, existingFunctionsInfo.existingInstanceIds, existingFunctionsInfo.existingCodebases, options.nonInteractive); const configDirPath = path.join(exports.FUNCTION_KITS_DIR, existingKit.kit, `config-${instanceId}`); const absConfigDirPath = options.config.path(configDirPath); await fs.ensureDir(absConfigDirPath); existingKit.instances[instanceId] = configDirPath; options.config.writeProjectFile("firebase.json", options.config.src); (0, utils_1.logLabeledSuccess)("functions", `Function kit instance ${clc.bold(instanceId)} successfully added to kit ${clc.bold(existingKit.kit)}.`); } async function promptExistingInstanceForProject(options, existingKit) { const instanceIds = Object.keys(existingKit.instances); if (instanceIds.length === 0) { throw new error_1.FirebaseError(`Kit '${existingKit.kit}' has no instances configured.`); } let selectedInstanceId = "<instance-name>"; if (instanceIds.length === 1) { selectedInstanceId = instanceIds[0]; } else if (!options.nonInteractive) { selectedInstanceId = await (0, prompt_1.select)({ message: "Which instance would you like to configure for this project?", choices: instanceIds.map((id) => ({ name: id, value: id })), }); } const targetProject = (0, projectUtils_1.getProjectId)(options) || options.project || "<project-name>"; (0, utils_1.logLabeledBullet)("functions", `To create a new instance in this project, deploy the instance dedicated to this project using\n` + clc.bold(`firebase deploy --only functions:${selectedInstanceId} --project ${targetProject}`)); } async function addKitInstanceOrConfigureProject(options, existingKit, existingFunctionsInfo) { const projectId = (0, projectUtils_1.getProjectId)(options); const projectAlias = options.rc?.hasProjects && options.project && options.rc.hasProjectAlias(options.project) ? options.project : undefined; const isConfiguredForProject = isKitConfiguredForProject(options.config, existingKit, projectId, projectAlias); let action; if (!isConfiguredForProject && !options.nonInteractive) { const existingInstances = Object.keys(existingKit.instances || {}).join(", "); action = await (0, prompt_1.select)({ message: `The following instances already exist, but are not configured for this project: ${existingInstances}. What would you like to do?`, choices: [ { name: "Add an instance to the existing kit", value: "addInstance", }, { name: "Configure an existing instance for this project", value: "addEnv", }, ], }); } else { if (isConfiguredForProject) { (0, utils_1.logLabeledBullet)("functions", `This package is already installed as kit ${existingKit.kit}, creating a new instance.`); } action = "addInstance"; } if (action === "addInstance") { await addInstanceToExistingKit(options, existingKit, existingFunctionsInfo); return; } if (action === "addEnv") { await promptExistingInstanceForProject(options, existingKit); return; } } async function writeKitPackageJson(config, sourcePath, kitId, packageName, version) { const relPackageJsonPath = path.join(sourcePath, "package.json"); const absPackageJsonPath = config.path(relPackageJsonPath); let pkgJson = {}; if (await fs.pathExists(absPackageJsonPath)) { try { pkgJson = (await fs.readJson(absPackageJsonPath)); } catch (err) { logger_1.logger.debug(`Failed to read existing package.json: ${(0, error_1.getErrMsg)(err)}`); } } else { const latestNodeVersion = supported.latest("nodejs").replace("nodejs", ""); const subbedTemplate = PACKAGE_NO_LINTING_TEMPLATE.replace("{{RUNTIME}}", latestNodeVersion); try { pkgJson = JSON.parse(subbedTemplate); } catch (err) { throw new error_1.FirebaseError("Failed to parse package.nolint.json template: " + (0, error_1.getErrMsg)(err)); } } pkgJson.name = `${kitId}-wrapper`; pkgJson.dependencies = pkgJson.dependencies || {}; pkgJson.dependencies[packageName] = version || "latest"; await config.askWriteProjectFile(relPackageJsonPath, pkgJson); } async function writeKitIndexTs(config, sourcePath, packageName, templateType) { const relIndexTsPath = path.join(sourcePath, "src", "index.ts"); const absIndexTsPath = config.path(relIndexTsPath); if (!(await fs.pathExists(absIndexTsPath))) { const template = exports.TEMPLATES[templateType]; const indexContent = template.replace("{{PACKAGE_NAME}}", packageName); await config.askWriteProjectFile(relIndexTsPath, indexContent); } } async function scaffoldKitFiles(config, kitId, instanceId, packageName, version, templateType = exports.DEFAULT_TEMPLATE) { const sourcePath = path.join(exports.FUNCTION_KITS_DIR, kitId, "source"); const configDirPath = path.join(exports.FUNCTION_KITS_DIR, kitId, `config-${instanceId}`); const absSourcePath = config.path(sourcePath); const absConfigDirPath = config.path(configDirPath); await fs.ensureDir(absSourcePath); await fs.ensureDir(absConfigDirPath); await writeKitPackageJson(config, sourcePath, kitId, packageName, version); await config.askWriteProjectFile(path.join(sourcePath, "tsconfig.json"), TSCONFIG_TEMPLATE); await config.askWriteProjectFile(path.join(sourcePath, ".gitignore"), GITIGNORE_TEMPLATE); await writeKitIndexTs(config, sourcePath, packageName, templateType); return { sourcePath, configDirPath, absSourcePath }; } async function buildAndInstallKit(absSourcePath, isThirdParty) { const installArgs = isThirdParty ? ["install", "--ignore-scripts"] : ["install"]; (0, utils_1.logLabeledBullet)("functions", `Running npm ${installArgs.join(" ")}...`); try { await (0, spawn_1.wrapSpawn)("npm", installArgs, absSourcePath); } catch (err) { throw new error_1.FirebaseError(`NPM install failed: ${(0, error_1.getErrMsg)(err)}`); } (0, utils_1.logLabeledBullet)("functions", "Building TypeScript source..."); try { await (0, spawn_1.wrapSpawn)("npm", ["run", "build"], absSourcePath); } catch (err) { throw new error_1.FirebaseError(`TypeScript build failed: ${(0, error_1.getErrMsg)(err)}`); } } function addKitToConfig(config, kitId, instanceId, packageName, sourcePath, configDirPath) { const configSrc = config.src; const newKitConfig = { kit: kitId, sourcePackage: { name: packageName, }, source: sourcePath, instances: { [instanceId]: configDirPath, }, predeploy: ['npm --prefix "$RESOURCE_DIR" run build'], }; const functionsRaw = configSrc.functions; if (!functionsRaw) { configSrc.functions = [newKitConfig]; } else if (Array.isArray(functionsRaw)) { functionsRaw.push(newKitConfig); } else { configSrc.functions = [functionsRaw, newKitConfig]; } config.writeProjectFile("firebase.json", configSrc); } exports.command = new command_1.Command("functions:kits:install") .description("install a function kit into your project") .option("--npm_package <package>", "NPM package name or specifier to install as a function kit") .option(`--template [${Object.keys(exports.TEMPLATES).join("|")}]`, "template to use for the kit index file", exports.DEFAULT_TEMPLATE) .action(async (options) => { experiments.assertEnabled("kits", "install a function kit"); if (!options.config) { throw new error_1.FirebaseError("Not in a Firebase project directory (firebase.json not found)."); } const templateType = (options.template || exports.DEFAULT_TEMPLATE); if (!(templateType in exports.TEMPLATES)) { const validTemplates = Object.keys(exports.TEMPLATES) .map((t) => `'${t}'`) .join(" or "); throw new error_1.FirebaseError(`Invalid template '${templateType}'. Template must be ${validTemplates}.`); } const rawPkgName = options.npm_package; if (!rawPkgName) { throw new error_1.FirebaseError("set the --npm_package option to a valid NPM package and try again."); } const { packageName, version } = parseNpmPackageSpecifier(rawPkgName); validateNpmPackageName(packageName); const existingFunctionsInfo = extractExistingFunctionsInfo(options.config.src.functions); const existingKit = existingFunctionsInfo.existingFunctions.find((c) => (0, projectConfig_1.isKitConfig)(c) && c.sourcePackage?.name === packageName); if (existingKit) { await addKitInstanceOrConfigureProject(options, existingKit, existingFunctionsInfo); return; } const isThirdParty = await promptSecurityConfirmation(rawPkgName, packageName, options.nonInteractive); const kitId = await promptKitId(packageName, existingFunctionsInfo.existingKitIds, options.nonInteractive); const instanceId = await promptKitInstanceId(kitId, existingFunctionsInfo.existingInstanceIds, existingFunctionsInfo.existingCodebases, options.nonInteractive); const { sourcePath, configDirPath, absSourcePath } = await scaffoldKitFiles(options.config, kitId, instanceId, packageName, version, templateType); await buildAndInstallKit(absSourcePath, isThirdParty); addKitToConfig(options.config, kitId, instanceId, packageName, sourcePath, configDirPath); (0, utils_1.logLabeledSuccess)("functions", `Function kit ${clc.bold(kitId)} successfully installed.`); });