1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 05:13:29 +00:00

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

* feat(nx): add basic-lib generator for streamlined library creation

This adds a new nx-plugin library with a generator for creating "common" type
Bitwarden libs. It is set up to accept a lib name, description, team, and
directory. It then
- Creates a folder in the directory (default to libs)
- Sets up complete library scaffolding:
  - README with team ownership
  - Build, lint and test task configuration
  - Test infrastructure
- Configures TypeScript path mapping
- Updates CODEOWNERS with team ownership
- Runs npm i

This will make library creation more consistent and reduce manual boilerplate setup.

The plugin design itself was generated by `npx nx g plugin`. This means we
used a plugin to generate a plugin that exports generators. To create our
generator generator, we first needed a generator.

* fix(dirt/card): correct tsconfig path in jest configuration

Fix the relative path to tsconfig.base in the dirt/card library's Jest config.
The path was incorrectly using four parent directory traversals (../../../../)
when only three (../../../) were needed to reach the project root.

* chore(codeowners): clarify some nx ownership stuff
This commit is contained in:
Addison Beck
2025-06-05 14:20:23 -04:00
committed by GitHub
parent 509af7b7bd
commit e8224fdbe3
34 changed files with 1273 additions and 578 deletions

5
libs/nx-plugin/README.md Normal file
View File

@@ -0,0 +1,5 @@
# nx-plugin
Owned by: Platform
Custom Nx tools like generators and executors for Bitwarden projects

View File

@@ -0,0 +1,3 @@
import baseConfig from "../../eslint.config.mjs";
export default [...baseConfig];

View File

@@ -0,0 +1,9 @@
{
"generators": {
"basic-lib": {
"factory": "./src/generators/basic-lib",
"schema": "./src/generators/schema.json",
"description": "basic-lib generator"
}
}
}

View File

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

View File

@@ -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"
}

View File

@@ -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"
}
}
}
}

View File

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

View File

@@ -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;

View File

@@ -0,0 +1,4 @@
# <%= name %>
Owned by: <%= team %>
<%= description %>

View File

@@ -0,0 +1,3 @@
import baseConfig from "<%= offsetFromRoot %>eslint.config.mjs";
export default [...baseConfig];

View File

@@ -0,0 +1,10 @@
module.exports = {
displayName: '<%= name %>',
preset: '<%= offsetFromRoot %>jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '<%= offsetFromRoot %>coverage/libs/<%= name %>',
};

View File

@@ -0,0 +1,11 @@
{
"name": "@bitwarden/<%= name %>",
"version": "0.0.1",
"description": "<%= description %>",
"private": true,
"type": "commonjs",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"license": "GPL-3.0",
"author": "<%= team %>"
}

View File

@@ -0,0 +1,33 @@
{
"name": "<%= name %>",
"$schema": "<%= offsetFromRoot %>node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/<%= name %>/src",
"projectType": "library",
"tags": [],
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/<%= name %>",
"main": "libs/<%= name %>/src/index.ts",
"tsConfig": "libs/<%= name %>/tsconfig.lib.json",
"assets": ["libs/<%= name%>/*.md"]
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["libs/<%= name %>/**/*.ts"]
}
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/<%= name %>/jest.config.js"
}
}
},
}

View File

@@ -0,0 +1,8 @@
import * as lib from './index';
describe('<%= name %>', () => {
// This test will fail until something is exported from index.ts
it('should work', () => {
expect(lib).toBeDefined();
});
});

View File

@@ -0,0 +1,13 @@
{
"extends": "<%= offsetFromRoot %>tsconfig.base.json",
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "<%= offsetFromRoot %>dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["jest.config.js", "src/**/*.spec.ts"]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "<%= offsetFromRoot %>/dist/out-tsc",
"module": "commonjs",
"moduleResolution": "node10",
"types": ["jest", "node"]
},
"include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
}

View File

@@ -0,0 +1,6 @@
export interface BasicLibGeneratorSchema {
name: string;
description: string;
team: string;
directory: string;
}

View File

@@ -0,0 +1,96 @@
{
"$schema": "https://json-schema.org/schema",
"$id": "BasicLib",
"title": "",
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Library name",
"$default": {
"$source": "argv",
"index": 0
},
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
"x-prompt": "What name would you like to use? (kebab-case, alphanumeric)",
"x-priority": "important"
},
"description": {
"type": "string",
"description": "Library description",
"x-prompt": "Please describe your library in one sentence (for package.json and README)",
"x-priority": "important"
},
"directory": {
"type": "string",
"description": "Directory where the library will be created",
"default": "libs",
"x-prompt": "What directory would you like your lib in?",
"x-priority": "important"
},
"team": {
"type": "string",
"description": "Maintaining team",
"x-priority": "important",
"x-prompt": {
"message": "What team maintains this library?",
"type": "list",
"items": [
{
"value": "admin-console",
"label": "Admin Console"
},
{
"value": "auth",
"label": "Auth"
},
{
"value": "autofill",
"label": "Autofill"
},
{
"value": "billing",
"label": "Billing"
},
{
"value": "data-insights-and-reporting",
"label": "Data Insights And Reporting"
},
{
"value": "key-management",
"label": "Key Management"
},
{
"value": "platform",
"label": "Platform"
},
{
"value": "tools",
"label": "Tools"
},
{
"value": "ui-foundation",
"label": "UI Foundation"
},
{
"value": "vault",
"label": "Vault"
}
]
},
"enum": [
"admin-console",
"auth",
"autofill",
"billing",
"data-insights-and-reporting",
"key-management",
"platform",
"tools",
"ui-foundation",
"vault"
]
}
},
"required": ["name", "description", "team"]
}

View File

View File

@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs"
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
}

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "node10",
"types": ["jest", "node"]
},
"include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
}