Skip to content

feat: add functions:kits:install command under kits experiment#10794

Open
wandamora wants to merge 1 commit into
mainfrom
morawand-kits-install-prototype
Open

feat: add functions:kits:install command under kits experiment#10794
wandamora wants to merge 1 commit into
mainfrom
morawand-kits-install-prototype

Conversation

@wandamora

Copy link
Copy Markdown
Contributor

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.

  • Validates options and checks for a valid Firebase project directory.
  • Auto-sanitizes the package name into a valid codebase/directory name if --codebase or --source are not specified.
  • Appends the new codebase configuration to firebase.json.
  • Scaffolds configuration files (package.json, tsconfig.json, .gitignore).
  • Installs base dependencies and the specified npm package using npm install.
  • Generates src/index.ts to re-export functions from the installed kit package (export * from "";).
  • Executes npm run build to compile the TypeScript functions.

Scenarios Tested

  • Tested install of local tarball NPM package
  • Unit tests

Sample Commands

# Enable the experiment
firebase experiments: enable kits

# Install a kit package (defaults codebase and source to sanitized package name)
firebase functions:kits:install --npm_package @my-org/my-functions-kit

# Install with explicit codebase and source directory
firebase functions:kits:install --npm_package @my-org/my-functions-kit --codebase custom-kit --source functions-custom

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +10 to +15
import {
assertUnique,
normalize,
validateCodebase,
ValidatedConfig,
} from "../functions/projectConfig";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
import {
assertUnique,
normalize,
validateCodebase,
ValidatedConfig,
} from "../functions/projectConfig";
import { FunctionConfig } from "../firebaseConfig";
import {
assertUnique,
normalizeAndValidate,
validateCodebase,
ValidatedConfig,
} from "../functions/projectConfig";
References
  1. Never use any or unknown as an escape hatch. Define proper interfaces/types or use type guards. (link)

Comment on lines +58 to +68
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}`);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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
  1. Never use any or unknown as an escape hatch. Define proper interfaces/types or use type guards. (link)


import { Command } from "../command";
import { Config } from "../config";
import { FirebaseError } from "../error";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
import { FirebaseError } from "../error";
import { FirebaseError, getErrMsg } from "../error";
References
  1. Never use any or unknown as an escape hatch. Define proper interfaces/types or use type guards. (link)

Comment on lines +24 to +34
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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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);
}

Comment on lines +36 to +44
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) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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
  1. Never use any or unknown as an escape hatch. Define proper interfaces/types or use type guards. (link)

Comment on lines +113 to +118
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}`);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use unknown for the catch block and safely extract the error message using getErrMsg.

Suggested change
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
  1. Never use any or unknown as an escape hatch. Define proper interfaces/types or use type guards. (link)

Comment on lines +129 to +140
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}`,
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use unknown for the catch block and safely extract the error message using getErrMsg.

Suggested change
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
  1. Never use any or unknown as an escape hatch. Define proper interfaces/types or use type guards. (link)

Comment on lines +147 to +155
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];
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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);
    }

Comment on lines +160 to +165
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}`);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use unknown for the catch block and safely extract the error message using getErrMsg.

Suggested change
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
  1. Never use any or unknown as an escape hatch. Define proper interfaces/types or use type guards. (link)

Comment on lines +39 to +93
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",
]);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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",
    ]);
  });

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants