Skip to content

Commit

Permalink
Adds support for codebases in firebase init functions (#4965)
Browse files Browse the repository at this point in the history
* add codebase setup via init fn for javascript

* add codebase compatibility to typescript language choice

* add unit tests for init fns

* revise retry flow for source, codebase user input, add unit test for name suggestion

* reword changelog entry, add pr number

* update unit tests
  • Loading branch information
blidd-google committed Sep 23, 2022
1 parent 023f7dd commit 1c1bd04
Show file tree
Hide file tree
Showing 8 changed files with 415 additions and 47 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Adds support for codebases in `firebase init functions` flow (#4965).
31 changes: 24 additions & 7 deletions src/functions/projectConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,25 +25,42 @@ export function normalize(config?: FunctionsConfig): NormalizedConfig {
return [config];
}

/**
* Check that the codebase name is less than 64 characters and only contains allowed characters.
*/
export function validateCodebase(codebase: string): void {
if (codebase.length === 0 || codebase.length > 63 || !/^[a-z0-9_-]+$/.test(codebase)) {
throw new FirebaseError(
"Invalid codebase name. Codebase must be less than 64 characters and " +
"can contain only lowercase letters, numeric characters, underscores, and dashes."
);
}
}

function validateSingle(config: FunctionConfig): ValidatedSingle {
if (!config.source) {
throw new FirebaseError("functions.source must be specified");
}
if (!config.codebase) {
config.codebase = DEFAULT_CODEBASE;
}
if (config.codebase.length > 63 || !/^[a-z0-9_-]+$/.test(config.codebase)) {
throw new FirebaseError(
"Invalid codebase name. Codebase must be less than 63 characters and " +
"can contain only lowercase letters, numeric characters, underscores, and dashes."
);
}
validateCodebase(config.codebase);

return { ...config, source: config.source, codebase: config.codebase };
}

function assertUnique(config: ValidatedConfig, property: keyof ValidatedSingle) {
/**
* Check that the property is unique in the given config.
*/
export function assertUnique(
config: ValidatedConfig,
property: keyof ValidatedSingle,
propval?: string
): void {
const values = new Set();
if (propval) {
values.add(propval);
}
for (const single of config) {
const value = single[property];
if (values.has(value)) {
Expand Down
168 changes: 150 additions & 18 deletions src/init/features/functions/index.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,26 @@
import * as clc from "colorette";
import * as _ from "lodash";

import { logger } from "../../../logger";
import { promptOnce } from "../../../prompt";
import { requirePermissions } from "../../../requirePermissions";
import { previews } from "../../../previews";
import { Options } from "../../../options";
import { ensure } from "../../../ensureApiEnabled";
import { Config } from "../../../config";
import {
normalizeAndValidate,
configForCodebase,
validateCodebase,
assertUnique,
} from "../../../functions/projectConfig";
import { FirebaseError } from "../../../error";

const MAX_ATTEMPTS = 5;

/**
* Set up a new firebase project for functions.
*/
export async function doSetup(setup: any, config: any, options: Options) {
logger.info();
logger.info(
"A " + clc.bold("functions") + " directory will be created in your project with sample code"
);
logger.info(
"pre-configured. Functions can be deployed with " + clc.bold("firebase deploy") + "."
);
logger.info();

setup.functions = {};
export async function doSetup(setup: any, config: Config, options: Options): Promise<any> {
const projectId = setup?.rcfile?.projects?.default;
if (projectId) {
await requirePermissions({ ...options, project: projectId });
Expand All @@ -30,6 +29,136 @@ export async function doSetup(setup: any, config: any, options: Options) {
ensure(projectId, "runtimeconfig.googleapis.com", "unused", true),
]);
}
setup.functions = {};
// check if functions have been initialized yet
if (!config.src.functions) {
setup.config.functions = [];
return initNewCodebase(setup, config);
}
const codebases = setup.config.functions.map((cfg: any) => clc.bold(cfg.codebase));
logger.info(`\nDetected existing codebase(s): ${codebases.join(", ")}\n`);

setup.config.functions = normalizeAndValidate(setup.config.functions);
const choices = [
{
name: "Initialize",
value: "new",
},
{
name: "Overwrite",
value: "overwrite",
},
];
const initOpt = await promptOnce({
type: "list",
message: "Would you like to initialize a new codebase, or overwrite an existing one?",
default: "new",
choices,
});
return initOpt === "new" ? initNewCodebase(setup, config) : overwriteCodebase(setup, config);
}

/**
* User dialogue to set up configuration for functions codebase.
*/
async function initNewCodebase(setup: any, config: Config): Promise<any> {
logger.info("Let's create a new codebase for your functions.");
logger.info("A directory corresponding to the codebase will be created in your project");
logger.info("with sample code pre-configured.\n");

logger.info("See https://firebase.google.com/docs/functions/organize-functions for");
logger.info("more information on organizing your functions using codebases.\n");

logger.info(`Functions can be deployed with ${clc.bold("firebase deploy")}.\n`);

let source: string;
let codebase: string;

if (setup.config.functions.length === 0) {
source = "functions";
codebase = "default";
} else {
let attempts = 0;
while (true) {
if (attempts++ >= MAX_ATTEMPTS) {
throw new FirebaseError(
"Exceeded max number of attempts to input valid codebase name. Please restart."
);
}
codebase = await promptOnce({
type: "input",
message: "What should be the name of this codebase?",
});
try {
validateCodebase(codebase);
assertUnique(setup.config.functions, "codebase", codebase);
break;
} catch (err: any) {
logger.error(err as FirebaseError);
}
}

attempts = 0;
while (true) {
if (attempts >= MAX_ATTEMPTS) {
throw new FirebaseError(
"Exceeded max number of attempts to input valid source. Please restart."
);
}
attempts++;
source = await promptOnce({
type: "input",
message: `In what sub-directory would you like to initialize your functions for codebase ${clc.bold(
codebase
)}?`,
default: codebase,
});
try {
assertUnique(setup.config.functions, "source", source);
break;
} catch (err: any) {
logger.error(err as FirebaseError);
}
}
}

setup.config.functions.push({
source,
codebase,
});
setup.functions.source = source;
setup.functions.codebase = codebase;
return languageSetup(setup, config);
}

async function overwriteCodebase(setup: any, config: Config): Promise<any> {
let codebase;
if (setup.config.functions.length > 1) {
const choices = setup.config.functions.map((cfg: any) => ({
name: cfg["codebase"],
value: cfg["codebase"],
}));
codebase = await promptOnce({
type: "list",
message: "Which codebase would you like to overwrite?",
choices,
});
} else {
codebase = setup.config.functions[0].codebase; // only one codebase exists
}

const cbconfig = configForCodebase(setup.config.functions, codebase);
setup.functions.source = cbconfig.source;
setup.functions.codebase = cbconfig.codebase;

logger.info(`\nOverwriting ${clc.bold(`codebase ${codebase}...\n`)}`);
return languageSetup(setup, config);
}

/**
* User dialogue to set up configuration for functions codebase language choice.
*/
async function languageSetup(setup: any, config: Config): Promise<any> {
const choices = [
{
name: "JavaScript",
Expand All @@ -52,11 +181,14 @@ export async function doSetup(setup: any, config: any, options: Options) {
default: "javascript",
choices,
});
_.set(setup, "config.functions.ignore", [
"node_modules",
".git",
"firebase-debug.log",
"firebase-debug.*.log",
]);
const cbconfig = configForCodebase(setup.config.functions, setup.functions.codebase);
switch (language) {
case "javascript":
cbconfig.ignore = ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"];
break;
case "typescript":
cbconfig.ignore = ["node_modules", ".git", "firebase-debug.log", "firebase-debug.*.log"];
break;
}
return require("./" + language).setup(setup, config);
}
18 changes: 11 additions & 7 deletions src/init/features/functions/javascript.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import * as _ from "lodash";
import * as fs from "fs";
import * as path from "path";

import { askInstallDependencies } from "./npm-dependencies";
import { prompt } from "../../../prompt";
import { configForCodebase } from "../../../functions/projectConfig";

const TEMPLATE_ROOT = path.resolve(__dirname, "../../../../templates/init/functions/javascript/");
const INDEX_TEMPLATE = fs.readFileSync(path.join(TEMPLATE_ROOT, "index.js"), "utf8");
Expand All @@ -29,20 +29,24 @@ export function setup(setup: any, config: any): Promise<any> {
])
.then(() => {
if (setup.functions.lint) {
_.set(setup, "config.functions.predeploy", ['npm --prefix "$RESOURCE_DIR" run lint']);
const cbconfig = configForCodebase(setup.config.functions, setup.functions.codebase);
cbconfig.predeploy = ['npm --prefix "$RESOURCE_DIR" run lint'];
return config
.askWriteProjectFile("functions/package.json", PACKAGE_LINTING_TEMPLATE)
.askWriteProjectFile(`${setup.functions.source}/package.json`, PACKAGE_LINTING_TEMPLATE)
.then(() => {
config.askWriteProjectFile("functions/.eslintrc.js", ESLINT_TEMPLATE);
config.askWriteProjectFile(`${setup.functions.source}/.eslintrc.js`, ESLINT_TEMPLATE);
});
}
return config.askWriteProjectFile("functions/package.json", PACKAGE_NO_LINTING_TEMPLATE);
return config.askWriteProjectFile(
`${setup.functions.source}/package.json`,
PACKAGE_NO_LINTING_TEMPLATE
);
})
.then(() => {
return config.askWriteProjectFile("functions/index.js", INDEX_TEMPLATE);
return config.askWriteProjectFile(`${setup.functions.source}/index.js`, INDEX_TEMPLATE);
})
.then(() => {
return config.askWriteProjectFile("functions/.gitignore", GITIGNORE_TEMPLATE);
return config.askWriteProjectFile(`${setup.functions.source}/.gitignore`, GITIGNORE_TEMPLATE);
})
.then(() => {
return askInstallDependencies(setup.functions, config);
Expand Down
2 changes: 1 addition & 1 deletion src/init/features/functions/npm-dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function askInstallDependencies(setup: any, config: any): Promise<void> {
if (setup.npm) {
return new Promise<void>((resolve) => {
const installer = spawn("npm", ["install"], {
cwd: config.projectDir + "/functions",
cwd: config.projectDir + `/${setup.source}`,
stdio: "inherit",
});

Expand Down
39 changes: 26 additions & 13 deletions src/init/features/functions/typescript.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import * as _ from "lodash";
import * as fs from "fs";
import * as path from "path";

import { askInstallDependencies } from "./npm-dependencies";
import { prompt } from "../../../prompt";
import { configForCodebase } from "../../../functions/projectConfig";

const TEMPLATE_ROOT = path.resolve(__dirname, "../../../../templates/init/functions/typescript/");
const PACKAGE_LINTING_TEMPLATE = fs.readFileSync(
Expand Down Expand Up @@ -33,33 +33,46 @@ export function setup(setup: any, config: any): Promise<any> {
},
])
.then(() => {
const cbconfig = configForCodebase(setup.config.functions, setup.functions.codebase);
cbconfig.predeploy = [];
if (setup.functions.lint) {
_.set(setup, "config.functions.predeploy", [
'npm --prefix "$RESOURCE_DIR" run lint',
'npm --prefix "$RESOURCE_DIR" run build',
]);
cbconfig.predeploy.push('npm --prefix "$RESOURCE_DIR" run lint');
cbconfig.predeploy.push('npm --prefix "$RESOURCE_DIR" run build');
return config
.askWriteProjectFile("functions/package.json", PACKAGE_LINTING_TEMPLATE)
.askWriteProjectFile(`${setup.functions.source}/package.json`, PACKAGE_LINTING_TEMPLATE)
.then(() => {
return config.askWriteProjectFile("functions/.eslintrc.js", ESLINT_TEMPLATE);
return config.askWriteProjectFile(
`${setup.functions.source}/.eslintrc.js`,
ESLINT_TEMPLATE
);
});
} else {
cbconfig.predeploy.push('npm --prefix "$RESOURCE_DIR" run build');
}
_.set(setup, "config.functions.predeploy", 'npm --prefix "$RESOURCE_DIR" run build');
return config.askWriteProjectFile("functions/package.json", PACKAGE_NO_LINTING_TEMPLATE);
return config.askWriteProjectFile(
`${setup.functions.source}/package.json`,
PACKAGE_NO_LINTING_TEMPLATE
);
})
.then(() => {
return config.askWriteProjectFile("functions/tsconfig.json", TSCONFIG_TEMPLATE);
return config.askWriteProjectFile(
`${setup.functions.source}/tsconfig.json`,
TSCONFIG_TEMPLATE
);
})
.then(() => {
if (setup.functions.lint) {
return config.askWriteProjectFile("functions/tsconfig.dev.json", TSCONFIG_DEV_TEMPLATE);
return config.askWriteProjectFile(
`${setup.functions.source}/tsconfig.dev.json`,
TSCONFIG_DEV_TEMPLATE
);
}
})
.then(() => {
return config.askWriteProjectFile("functions/src/index.ts", INDEX_TEMPLATE);
return config.askWriteProjectFile(`${setup.functions.source}/src/index.ts`, INDEX_TEMPLATE);
})
.then(() => {
return config.askWriteProjectFile("functions/.gitignore", GITIGNORE_TEMPLATE);
return config.askWriteProjectFile(`${setup.functions.source}/.gitignore`, GITIGNORE_TEMPLATE);
})
.then(() => {
return askInstallDependencies(setup.functions, config);
Expand Down
2 changes: 1 addition & 1 deletion src/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type Options = Record<string, any> & { nonInteractive?: boolean };
* prompt is used to prompt the user for values. Specifically, any `name` of a
* provided question will be checked against the `options` object. If `name`
* exists as a key in `options`, it will *not* be prompted for. If `options`
* contatins `nonInteractive = true`, then any `question.name` that does not
* contains `nonInteractive = true`, then any `question.name` that does not
* have a value in `options` will cause an error to be returned. Once the values
* are queried, the values for them are put onto the `options` object, and the
* answers are returned.
Expand Down
Loading

0 comments on commit 1c1bd04

Please sign in to comment.