Skip to content

build(nx): add basic-lib generator for streamlined library creation #14992

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ apps/web/src/translation-constants.ts @bitwarden/team-platform-dev
.github/workflows/version-auto-bump.yml @bitwarden/team-platform-dev
# ESLint custom rules
libs/eslint @bitwarden/team-platform-dev
# Typescript tooling
tsconfig.base.json @bitwarden/team-platform-dev
nx.json @bitwarden/team-platform-dev

## Autofill team files ##
apps/browser/src/autofill @bitwarden/team-autofill-dev
Expand Down Expand Up @@ -189,3 +192,4 @@ apps/web/src/locales/en/messages.json
# To track that effort please see https://bitwarden.atlassian.net/browse/PM-21636
**/tsconfig.json @bitwarden/team-platform-dev
**/jest.config.js @bitwarden/team-platform-dev
**/project.jsons @bitwarden/team-platform-dev
5 changes: 5 additions & 0 deletions .github/renovate.json5
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@
"@electron/notarize",
"@electron/rebuild",
"@ngtools/webpack",
"@nx/devkit",
"@nx/eslint",
"@nx/jest",
"@nx/js",
"@types/chrome",
"@types/firefox-webext-browser",
"@types/glob",
Expand Down Expand Up @@ -210,6 +214,7 @@
"simplelog",
"style-loader",
"sysinfo",
"ts-node",
Copy link
Contributor Author

Choose a reason for hiding this comment

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

ts-node is used by nx/js

"ts-loader",
"tsconfig-paths-webpack-plugin",
"type-fest",
Expand Down
1 change: 1 addition & 0 deletions .github/whitelist-capital-letters.txt
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,4 @@
./apps/browser/src/safari/safari/Info.plist
./apps/browser/src/safari/desktop.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
./SECURITY.md
./libs/nx-plugin/src/generators/files/README.md__tmpl__
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We might need a better way to do this in the future as we add more READMEs, more generators, other languages, etc. Maybe the no-capital-letters rule in general doesn't fit our project structure? I've let this be for now.

Copy link
Member

Choose a reason for hiding this comment

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

README.md are generally exempt this one isn't because of the alternate suffix. I would agree that it is fine for now but if we need we could probably change that to README.md* if we need to.

7 changes: 7 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,12 @@ export default tseslint.config(
]),
},
},
{
files: ["libs/nx-plugin/**/*.ts", "libs/nx-plugin/**/*.js"],
rules: {
"no-console": "off",
},
},
/// Bandaids for keeping existing circular dependencies from getting worse and new ones from being created
/// Will be removed after Nx is implemented and existing circular dependencies are removed.
{
Expand Down Expand Up @@ -604,6 +610,7 @@ export default tseslint.config(
"libs/components/tailwind.config.js",

"scripts/*.js",
"jest.preset.js",
],
},
);
Expand Down
3 changes: 3 additions & 0 deletions jest.preset.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const nxPreset = require("@nx/jest/preset").default;

module.exports = { ...nxPreset };
2 changes: 1 addition & 1 deletion libs/dirt/card/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const { pathsToModuleNameMapper } = require("ts-jest");

const { compilerOptions } = require("../../../../tsconfig.base");
const { compilerOptions } = require("../../../tsconfig.base");
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This started throwing errors on me. Not sure how it's not failing on main.


const sharedConfig = require("../../shared/jest.config.angular");

Expand Down
5 changes: 5 additions & 0 deletions libs/nx-plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# nx-plugin

Owned by: Platform

Custom Nx tools like generators and executors for Bitwarden projects
3 changes: 3 additions & 0 deletions libs/nx-plugin/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import baseConfig from "../../eslint.config.mjs";

export default [...baseConfig];
9 changes: 9 additions & 0 deletions libs/nx-plugin/generators.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"generators": {
"basic-lib": {
"factory": "./src/generators/basic-lib",
"schema": "./src/generators/schema.json",
"description": "basic-lib generator"
}
}
}
10 changes: 10 additions & 0 deletions libs/nx-plugin/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export default {
displayName: "nx-plugin",
preset: "../../jest.preset.js",
testEnvironment: "node",
transform: {
"^.+\\.[tj]s$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.spec.json" }],
},
moduleFileExtensions: ["ts", "js", "html"],
coverageDirectory: "../../coverage/libs/nx-plugin",
};
12 changes: 12 additions & 0 deletions libs/nx-plugin/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "@bitwarden/nx-plugin",
"version": "0.0.1",
"description": "Custom Nx tools like generators and executors for Bitwarden projects",
"private": true,
"type": "commonjs",
"main": "./src/index.js",
"types": "./src/index.d.ts",
"license": "GPL-3.0",
"author": "Platform",
"generators": "./generators.json"
}
51 changes: 51 additions & 0 deletions libs/nx-plugin/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"name": "nx-plugin",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/nx-plugin/src",
"projectType": "library",
"tags": [],
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/nx-plugin",
"main": "libs/nx-plugin/src/index.ts",
"tsConfig": "libs/nx-plugin/tsconfig.lib.json",
"assets": [
"libs/nx-plugin/*.md",
{
"input": "./libs/nx-plugin/src",
"glob": "**/!(*.ts)",
"output": "./src"
},
{
"input": "./libs/nx-plugin/src",
"glob": "**/*.d.ts",
"output": "./src"
},
{
"input": "./libs/nx-plugin",
"glob": "generators.json",
"output": "."
},
{
"input": "./libs/nx-plugin",
"glob": "executors.json",
"output": "."
}
]
}
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/nx-plugin/jest.config.ts"
}
}
}
}
85 changes: 85 additions & 0 deletions libs/nx-plugin/src/generators/basic-lib.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Tree, readProjectConfiguration } from "@nx/devkit";
import { createTreeWithEmptyWorkspace } from "@nx/devkit/testing";

import { basicLibGenerator } from "./basic-lib";
import { BasicLibGeneratorSchema } from "./schema";

describe("basic-lib generator", () => {
let tree: Tree;
const options: BasicLibGeneratorSchema = {
name: "test",
description: "test",
team: "platform",
directory: "libs",
};

beforeEach(() => {
tree = createTreeWithEmptyWorkspace();
});

it("should update tsconfig.base.json paths", async () => {
tree.write("tsconfig.base.json", JSON.stringify({ compilerOptions: { paths: {} } }));
await basicLibGenerator(tree, options);
const tsconfigContent = tree.read("tsconfig.base.json");
expect(tsconfigContent).not.toBeNull();
const tsconfig = JSON.parse(tsconfigContent?.toString() ?? "");
expect(tsconfig.compilerOptions.paths[`@bitwarden/${options.name}`]).toEqual([
`libs/test/src/index.ts`,
]);
});

it("should update CODEOWNERS file", async () => {
tree.write(".github/CODEOWNERS", "# Existing content\n");
await basicLibGenerator(tree, options);
const codeownersContent = tree.read(".github/CODEOWNERS");
expect(codeownersContent).not.toBeNull();
const codeowners = codeownersContent?.toString();
expect(codeowners).toContain(`libs/test @bitwarden/team-platform-dev`);
});

it("should generate expected files", async () => {
await basicLibGenerator(tree, options);

const config = readProjectConfiguration(tree, "test");
expect(config).toBeDefined();

expect(tree.exists(`libs/test/README.md`)).toBeTruthy();
expect(tree.exists(`libs/test/eslint.config.mjs`)).toBeTruthy();
expect(tree.exists(`libs/test/jest.config.js`)).toBeTruthy();
expect(tree.exists(`libs/test/package.json`)).toBeTruthy();
expect(tree.exists(`libs/test/tsconfig.json`)).toBeTruthy();
expect(tree.exists(`libs/test/tsconfig.lib.json`)).toBeTruthy();
expect(tree.exists(`libs/test/tsconfig.spec.json`)).toBeTruthy();
expect(tree.exists(`libs/test/src/index.ts`)).toBeTruthy();
});

it("should handle missing CODEOWNERS file gracefully", async () => {
const consoleSpy = jest.spyOn(console, "warn").mockImplementation();
await basicLibGenerator(tree, options);
expect(consoleSpy).toHaveBeenCalledWith("CODEOWNERS file not found at .github/CODEOWNERS");
consoleSpy.mockRestore();
});

it("should map team names to correct GitHub handles", async () => {
tree.write(".github/CODEOWNERS", "");
await basicLibGenerator(tree, { ...options, team: "vault" });
const codeownersContent = tree.read(".github/CODEOWNERS");
expect(codeownersContent).not.toBeNull();
const codeowners = codeownersContent?.toString();
expect(codeowners).toContain(`libs/test @bitwarden/team-vault-dev`);
});

it("should generate expected files", async () => {
await basicLibGenerator(tree, options);
expect(tree.exists(`libs/test/README.md`)).toBeTruthy();
expect(tree.exists(`libs/test/eslint.config.mjs`)).toBeTruthy();
expect(tree.exists(`libs/test/jest.config.js`)).toBeTruthy();
expect(tree.exists(`libs/test/package.json`)).toBeTruthy();
expect(tree.exists(`libs/test/project.json`)).toBeTruthy();
expect(tree.exists(`libs/test/tsconfig.json`)).toBeTruthy();
expect(tree.exists(`libs/test/tsconfig.lib.json`)).toBeTruthy();
expect(tree.exists(`libs/test/tsconfig.spec.json`)).toBeTruthy();
expect(tree.exists(`libs/test/src/index.ts`)).toBeTruthy();
expect(tree.exists(`libs/test/src/test.spec.ts`)).toBeTruthy();
});
});
127 changes: 127 additions & 0 deletions libs/nx-plugin/src/generators/basic-lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { execSync } from "child_process";
import * as path from "path";

import {
formatFiles,
generateFiles,
Tree,
offsetFromRoot,
updateJson,
runTasksInSerial,
GeneratorCallback,
} from "@nx/devkit";

import { BasicLibGeneratorSchema } from "./schema";

/**
* An Nx generator for creating basic libraries.
* Generators help automate repetitive tasks like creating new components, libraries, or apps.
*
* @param {Tree} tree - The virtual file system tree that Nx uses to make changes
* @param {BasicLibGeneratorSchema} options - Configuration options for the generator
* @returns {Promise<void>} - Returns a promise that resolves when generation is complete
*/
export async function basicLibGenerator(
tree: Tree,
options: BasicLibGeneratorSchema,
): Promise<GeneratorCallback> {
const projectRoot = `${options.directory}/${options.name}`;
const srcRoot = `${projectRoot}/src`;

/**
* Generate files from templates in the 'files/' directory.
* This copies all template files to the new library location.
*/
generateFiles(tree, path.join(__dirname, "files"), projectRoot, {
...options,
// `tmpl` is used in file names for template files. Setting it to an
// empty string here lets use be explicit with the naming of template
// files, and lets Nx handle stripping out "__tmpl__" from file names.
tmpl: "",
// `name` is a variable passed to template files for interpolation into
// their contents. It is set to the name of the library being generated.
name: options.name,
root: projectRoot,
// `offsetFromRoot` is helper to calculate relative path from the new
// library to project root.
offsetFromRoot: offsetFromRoot(projectRoot),
});

// Add TypeScript path to the base tsconfig
updateTsConfigPath(tree, options.name, srcRoot);

// Update CODEOWNERS with the new lib
updateCodeowners(tree, options.directory, options.name, options.team);

// Format all new files with prettier
await formatFiles(tree);

const tasks: GeneratorCallback[] = [];
// Run npm i after generation. Nx ships a helper function for this called
// installPackagesTask. When used here it was leaving package-lock in a
// broken state, so a manual approach was used instead.
tasks.push(() => {
execSync("npm install", { stdio: "inherit" });
return Promise.resolve();
});
return runTasksInSerial(...tasks);
}

/**
* Updates the base tsconfig.json file to include the new library.
* This allows importing the library using its alias path.
*
* @param {Tree} tree - The virtual file system tree
* @param {string} name - The library name
* @param {string} srcRoot - Path to the library's source files
*/
function updateTsConfigPath(tree: Tree, name: string, srcRoot: string) {
updateJson(tree, "tsconfig.base.json", (json) => {
const paths = json.compilerOptions.paths || {};

paths[`@bitwarden/${name}`] = [`${srcRoot}/index.ts`];

json.compilerOptions.paths = paths;
return json;
});
}

/**
* Updates the CODEOWNERS file to add ownership for the new library
*
* @param {Tree} tree - The virtual file system tree
* @param {string} directory - Directory where the library is created
* @param {string} name - The library name
* @param {string} team - The team responsible for the library
*/
function updateCodeowners(tree: Tree, directory: string, name: string, team: string) {
const codeownersPath = ".github/CODEOWNERS";

if (!tree.exists(codeownersPath)) {
console.warn("CODEOWNERS file not found at .github/CODEOWNERS");
return;
}

const teamHandleMap: Record<string, string> = {
"admin-console": "@bitwarden/team-admin-console-dev",
auth: "@bitwarden/team-auth-dev",
autofill: "@bitwarden/team-autofill-dev",
billing: "@bitwarden/team-billing-dev",
"data-insights-and-reporting": "@bitwarden/team-data-insights-and-reporting-dev",
"key-management": "@bitwarden/team-key-management-dev",
platform: "@bitwarden/team-platform-dev",
tools: "@bitwarden/team-tools-dev",
"ui-foundation": "@bitwarden/team-ui-foundation",
vault: "@bitwarden/team-vault-dev",
};

const teamHandle = teamHandleMap[team] || `@bitwarden/team-${team}-dev`;
const libPath = `${directory}/${name}`;

const newLine = `${libPath} ${teamHandle}\n`;

const content = tree.read(codeownersPath)?.toString() || "";
tree.write(codeownersPath, content + newLine);
}

export default basicLibGenerator;
4 changes: 4 additions & 0 deletions libs/nx-plugin/src/generators/files/README.md__tmpl__
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# <%= name %>
Owned by: <%= team %>

<%= description %>
3 changes: 3 additions & 0 deletions libs/nx-plugin/src/generators/files/eslint.config.mjs__tmpl__
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import baseConfig from "<%= offsetFromRoot %>eslint.config.mjs";

export default [...baseConfig];
Loading
Loading