firebase-tools
Version:
Command-Line Interface for Firebase
418 lines (417 loc) • 20.3 kB
JavaScript
;
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.`);
});