feat: add functions:kits:install command under kits experiment#10794
feat: add functions:kits:install command under kits experiment#10794wandamora wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a new experimental command, functions:kits:install, which automates the installation of an npm package containing Cloud Functions into a newly initialized codebase. The feedback primarily focuses on aligning the implementation with the repository's TypeScript style guide by replacing any and as any with strict types, interfaces, and unknown catch blocks utilizing the getErrMsg helper. Additionally, the reviewer suggests extracting a helper function to strip version specifiers from package names to prevent incorrectly named codebase directories, and updating the unit tests accordingly.
| import { | ||
| assertUnique, | ||
| normalize, | ||
| validateCodebase, | ||
| ValidatedConfig, | ||
| } from "../functions/projectConfig"; |
There was a problem hiding this comment.
Import normalizeAndValidate to fully parse and validate the existing functions configuration (including populating default values like codebase: "default"). Also, import FunctionConfig to properly type the updated configuration array and avoid any.
| import { | |
| assertUnique, | |
| normalize, | |
| validateCodebase, | |
| ValidatedConfig, | |
| } from "../functions/projectConfig"; | |
| import { FunctionConfig } from "../firebaseConfig"; | |
| import { | |
| assertUnique, | |
| normalizeAndValidate, | |
| validateCodebase, | |
| ValidatedConfig, | |
| } from "../functions/projectConfig"; |
References
- Never use
anyorunknownas an escape hatch. Define proper interfaces/types or use type guards. (link)
| let existingFunctions: ValidatedConfig | [] = []; | ||
| if ( | ||
| config.src.functions && | ||
| (!Array.isArray(config.src.functions) || config.src.functions.length > 0) | ||
| ) { | ||
| try { | ||
| existingFunctions = normalize(config.src.functions) as ValidatedConfig; | ||
| } catch (err: any) { | ||
| throw new FirebaseError(`Invalid existing functions configuration: ${err.message}`); | ||
| } | ||
| } |
There was a problem hiding this comment.
Use normalizeAndValidate instead of normalize to ensure that default values (such as codebase: "default") are populated and the existing configuration is fully validated. Also, use unknown for the catch block and extract the error message safely.
| let existingFunctions: ValidatedConfig | [] = []; | |
| if ( | |
| config.src.functions && | |
| (!Array.isArray(config.src.functions) || config.src.functions.length > 0) | |
| ) { | |
| try { | |
| existingFunctions = normalize(config.src.functions) as ValidatedConfig; | |
| } catch (err: any) { | |
| throw new FirebaseError(`Invalid existing functions configuration: ${err.message}`); | |
| } | |
| } | |
| let existingFunctions: ValidatedConfig | [] = []; | |
| if ( | |
| config.src.functions && | |
| (!Array.isArray(config.src.functions) || config.src.functions.length > 0) | |
| ) { | |
| try { | |
| existingFunctions = normalizeAndValidate(config.src.functions); | |
| } catch (err: unknown) { | |
| throw new FirebaseError(`Invalid existing functions configuration: ${getErrMsg(err)}`); | |
| } | |
| } |
References
- Never use
anyorunknownas an escape hatch. Define proper interfaces/types or use type guards. (link)
|
|
||
| import { Command } from "../command"; | ||
| import { Config } from "../config"; | ||
| import { FirebaseError } from "../error"; |
There was a problem hiding this comment.
Avoid using any in catch blocks or as an escape hatch. Instead, use unknown and leverage the getErrMsg helper from ../error to extract error messages safely, adhering to the repository style guide.
| import { FirebaseError } from "../error"; | |
| import { FirebaseError, getErrMsg } from "../error"; |
References
- Never use
anyorunknownas an escape hatch. Define proper interfaces/types or use type guards. (link)
| function sanitizePackageNameToCodebase(pkgName: string): string { | ||
| const sanitized = pkgName | ||
| .toLowerCase() | ||
| .replace(/^@/, "") | ||
| .replace(/\//g, "-") | ||
| .replace(/[^a-z0-9_-]/g, ""); | ||
| if (!sanitized) { | ||
| return "functions"; | ||
| } | ||
| return sanitized.slice(0, 63); | ||
| } |
There was a problem hiding this comment.
Extract the package name parsing logic into a helper function so that version specifiers (e.g., @^1.0.0) are stripped before sanitizing the package name into a codebase/directory name. This prevents creating confusing codebase names like org-test-package100.
function extractPackageName(spec: string): string {
if (spec.startsWith("@")) {
return "@" + spec.slice(1).split("@")[0];
}
return spec.split("@")[0];
}
function sanitizePackageNameToCodebase(pkgName: string): string {
const nameOnly = extractPackageName(pkgName);
const sanitized = nameOnly
.toLowerCase()
.replace(/^@/, "")
.replace(/\//g, "-")
.replace(/[^a-z0-9_-]/g, "");
if (!sanitized) {
return "functions";
}
return sanitized.slice(0, 63);
}| export const command = new Command("functions:kits:install") | ||
| .description("install an npm package filled with Cloud Functions into a new functions codebase") | ||
| .option("--npm_package <package_name>", "the npm package containing Cloud Functions to install") | ||
| .option( | ||
| "--codebase <codebase>", | ||
| "the codebase name to initialize (defaults to sanitized package name)", | ||
| ) | ||
| .option("--source <source>", "the directory for the new codebase (defaults to codebase name)") | ||
| .action(async (options: { npm_package?: string; codebase?: string; source?: string } & any) => { |
There was a problem hiding this comment.
Define a proper TypeScript interface for the command options instead of using & any as an escape hatch, ensuring strict type safety.
interface InstallOptions {
npm_package?: string;
codebase?: string;
source?: string;
cwd?: string;
configPath?: string;
}
export const command = new Command("functions:kits:install")
.description("install an npm package filled with Cloud Functions into a new functions codebase")
.option("--npm_package <package_name>", "the npm package containing Cloud Functions to install")
.option(
"--codebase <codebase>",
"the codebase name to initialize (defaults to sanitized package name)",
)
.option("--source <source>", "the directory for the new codebase (defaults to codebase name)")
.action(async (options: InstallOptions) => {References
- Never use
anyorunknownas an escape hatch. Define proper interfaces/types or use type guards. (link)
| logger.info(`Installing base dependencies in ${clc.bold(source)}...`); | ||
| try { | ||
| await wrapSpawn("npm", ["install"], `${config.projectDir}/${source}`); | ||
| } catch (err: any) { | ||
| throw new FirebaseError(`Failed to install base dependencies in ${source}: ${err.message}`); | ||
| } |
There was a problem hiding this comment.
Use unknown for the catch block and safely extract the error message using getErrMsg.
| logger.info(`Installing base dependencies in ${clc.bold(source)}...`); | |
| try { | |
| await wrapSpawn("npm", ["install"], `${config.projectDir}/${source}`); | |
| } catch (err: any) { | |
| throw new FirebaseError(`Failed to install base dependencies in ${source}: ${err.message}`); | |
| } | |
| logger.info(`Installing base dependencies in ${clc.bold(source)}...`); | |
| try { | |
| await wrapSpawn("npm", ["install"], `${config.projectDir}/${source}`); | |
| } catch (err: unknown) { | |
| throw new FirebaseError(`Failed to install base dependencies in ${source}: ${getErrMsg(err)}`); | |
| } |
References
- Never use
anyorunknownas an escape hatch. Define proper interfaces/types or use type guards. (link)
| logger.info(`Installing kits package ${clc.bold(options.npm_package)}...`); | ||
| try { | ||
| await wrapSpawn( | ||
| "npm", | ||
| ["install", "--save", options.npm_package], | ||
| `${config.projectDir}/${source}`, | ||
| ); | ||
| } catch (err: any) { | ||
| throw new FirebaseError( | ||
| `Failed to install package ${options.npm_package} in ${source}: ${err.message}`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Use unknown for the catch block and safely extract the error message using getErrMsg.
| logger.info(`Installing kits package ${clc.bold(options.npm_package)}...`); | |
| try { | |
| await wrapSpawn( | |
| "npm", | |
| ["install", "--save", options.npm_package], | |
| `${config.projectDir}/${source}`, | |
| ); | |
| } catch (err: any) { | |
| throw new FirebaseError( | |
| `Failed to install package ${options.npm_package} in ${source}: ${err.message}`, | |
| ); | |
| } | |
| logger.info(`Installing kits package ${clc.bold(options.npm_package)}...`); | |
| try { | |
| await wrapSpawn( | |
| "npm", | |
| ["install", "--save", options.npm_package], | |
| `${config.projectDir}/${source}`, | |
| ); | |
| } catch (err: unknown) { | |
| throw new FirebaseError( | |
| `Failed to install package ${options.npm_package} in ${source}: ${getErrMsg(err)}`, | |
| ); | |
| } |
References
- Never use
anyorunknownas an escape hatch. Define proper interfaces/types or use type guards. (link)
| let installedPackageName = afterDeps.find((dep) => !initialDeps.has(dep)); | ||
| if (!installedPackageName) { | ||
| const raw = options.npm_package as string; | ||
| if (raw.startsWith("@")) { | ||
| installedPackageName = "@" + raw.slice(1).split("@")[0]; | ||
| } else { | ||
| installedPackageName = raw.split("@")[0]; | ||
| } | ||
| } |
There was a problem hiding this comment.
Reuse the extractPackageName helper function to safely parse the package name from the raw specifier when the fallback is triggered.
let installedPackageName = afterDeps.find((dep) => !initialDeps.has(dep));
if (!installedPackageName && options.npm_package) {
installedPackageName = extractPackageName(options.npm_package);
}| logger.info(`Building TypeScript functions in ${clc.bold(source)}...`); | ||
| try { | ||
| await wrapSpawn("npm", ["run", "build"], `${config.projectDir}/${source}`); | ||
| } catch (err: any) { | ||
| throw new FirebaseError(`Failed to build TypeScript functions in ${source}: ${err.message}`); | ||
| } |
There was a problem hiding this comment.
Use unknown for the catch block and safely extract the error message using getErrMsg.
| logger.info(`Building TypeScript functions in ${clc.bold(source)}...`); | |
| try { | |
| await wrapSpawn("npm", ["run", "build"], `${config.projectDir}/${source}`); | |
| } catch (err: any) { | |
| throw new FirebaseError(`Failed to build TypeScript functions in ${source}: ${err.message}`); | |
| } | |
| logger.info(`Building TypeScript functions in ${clc.bold(source)}...`); | |
| try { | |
| await wrapSpawn("npm", ["run", "build"], `${config.projectDir}/${source}`); | |
| } catch (err: unknown) { | |
| throw new FirebaseError(`Failed to build TypeScript functions in ${source}: ${getErrMsg(err)}`); | |
| } |
References
- Never use
anyorunknownas an escape hatch. Define proper interfaces/types or use type guards. (link)
| it("should initialize codebase matching the package name and install dependencies", async () => { | ||
| const mockConfig = { | ||
| src: { functions: [] }, | ||
| defaults: {}, | ||
| projectDir: "/mock/project", | ||
| set: sinon.spy(), | ||
| writeProjectFile: sinon.spy(), | ||
| askWriteProjectFile: sinon.stub().resolves(), | ||
| readProjectFile: sinon.stub().callsFake((filepath: string) => { | ||
| if (filepath.endsWith("package.json")) { | ||
| // Simulate package.json after npm install --save @org/test-package | ||
| return { | ||
| dependencies: { | ||
| "firebase-admin": "^13.6.0", | ||
| "firebase-functions": "^7.0.0", | ||
| "@org/test-package": "^1.0.0", | ||
| }, | ||
| }; | ||
| } | ||
| return {}; | ||
| }), | ||
| }; | ||
| configLoadStub.returns(mockConfig); | ||
|
|
||
| await command.runner()({ npm_package: "@org/test-package@^1.0.0" }); | ||
|
|
||
| expect(mockConfig.set.calledOnce).to.be.true; | ||
| expect(mockConfig.set.firstCall.args[0]).to.equal("functions"); | ||
| expect(mockConfig.set.firstCall.args[1]).to.deep.equal([ | ||
| { | ||
| source: "org-test-package100", | ||
| codebase: "org-test-package100", | ||
| predeploy: ['npm --prefix "$RESOURCE_DIR" run build'], | ||
| disallowLegacyRuntimeConfig: true, | ||
| }, | ||
| ]); | ||
| expect(mockConfig.writeProjectFile.calledWith("firebase.json")).to.be.true; | ||
| expect( | ||
| mockConfig.askWriteProjectFile.calledWith( | ||
| "org-test-package100/src/index.ts", | ||
| 'export * from "@org/test-package";\n', | ||
| ), | ||
| ).to.be.true; | ||
| expect(wrapSpawnStub.calledThrice).to.be.true; | ||
| expect(wrapSpawnStub.secondCall.args).to.deep.equal([ | ||
| "npm", | ||
| ["install", "--save", "@org/test-package@^1.0.0"], | ||
| "/mock/project/org-test-package100", | ||
| ]); | ||
| expect(wrapSpawnStub.thirdCall.args).to.deep.equal([ | ||
| "npm", | ||
| ["run", "build"], | ||
| "/mock/project/org-test-package100", | ||
| ]); | ||
| }); |
There was a problem hiding this comment.
Update the unit test expectations to match the new codebase and source directory names (org-test-package instead of org-test-package100) after stripping the version specifier.
it("should initialize codebase matching the package name and install dependencies", async () => {
const mockConfig = {
src: { functions: [] },
defaults: {},
projectDir: "/mock/project",
set: sinon.spy(),
writeProjectFile: sinon.spy(),
askWriteProjectFile: sinon.stub().resolves(),
readProjectFile: sinon.stub().callsFake((filepath: string) => {
if (filepath.endsWith("package.json")) {
// Simulate package.json after npm install --save @org/test-package
return {
dependencies: {
"firebase-admin": "^13.6.0",
"firebase-functions": "^7.0.0",
"@org/test-package": "^1.0.0",
},
};
}
return {};
}),
};
configLoadStub.returns(mockConfig);
await command.runner()({ npm_package: "@org/test-package@^1.0.0" });
expect(mockConfig.set.calledOnce).to.be.true;
expect(mockConfig.set.firstCall.args[0]).to.equal("functions");
expect(mockConfig.set.firstCall.args[1]).to.deep.equal([
{
source: "org-test-package",
codebase: "org-test-package",
predeploy: ['npm --prefix "$RESOURCE_DIR" run build'],
disallowLegacyRuntimeConfig: true,
},
]);
expect(mockConfig.writeProjectFile.calledWith("firebase.json")).to.be.true;
expect(
mockConfig.askWriteProjectFile.calledWith(
"org-test-package/src/index.ts",
'export * from "@org/test-package";\n',
),
).to.be.true;
expect(wrapSpawnStub.calledThrice).to.be.true;
expect(wrapSpawnStub.secondCall.args).to.deep.equal([
"npm",
["install", "--save", "@org/test-package@^1.0.0"],
"/mock/project/org-test-package",
]);
expect(wrapSpawnStub.thirdCall.args).to.deep.equal([
"npm",
["run", "build"],
"/mock/project/org-test-package",
]);
});
Description
Introduces the firebase functions:kits:install CLI command, gated behind the kits experiment flag. This command automates initializing a new Cloud Functions codebase and installing an npm package containing pre-built Cloud Functions.
Scenarios Tested
Sample Commands