diff --git a/build-tools/packages/build-infrastructure/api-report/build-infrastructure.api.md b/build-tools/packages/build-infrastructure/api-report/build-infrastructure.api.md index 9f564eeaa1c4..0760471dde61 100644 --- a/build-tools/packages/build-infrastructure/api-report/build-infrastructure.api.md +++ b/build-tools/packages/build-infrastructure/api-report/build-infrastructure.api.md @@ -12,6 +12,9 @@ import { SimpleGit } from 'simple-git'; // @public export type AdditionalPackageProps = Record | undefined; +// @public +export function createPackageManager(name: PackageManagerName): IPackageManager; + // @public export interface FluidPackageJsonFields { pnpm?: { @@ -22,6 +25,30 @@ export interface FluidPackageJsonFields { // @public export const FLUIDREPO_CONFIG_VERSION = 1; +// @public +export class FluidRepoBase

implements IFluidRepo

{ + constructor(searchPath: string, + upstreamRemotePartialUrl?: string | undefined); + protected readonly configFilePath: string; + readonly configuration: IFluidRepoLayout; + getGitRepository(): Promise>; + getPackageReleaseGroup(pkg: Readonly

): Readonly; + get packages(): Map; + relativeToRepo(p: string): string; + get releaseGroups(): Map; + reload(): void; + readonly root: string; + readonly upstreamRemotePartialUrl?: string | undefined; + get workspaces(): Map; +} + +// @public +export function getAllDependenciesInRepo(repo: IFluidRepo, packages: IPackage[]): { + packages: IPackage[]; + releaseGroups: IReleaseGroup[]; + workspaces: IWorkspace[]; +}; + // @public export function getFluidRepoLayout(searchPath: string, noCache?: boolean): { config: IFluidRepoLayout; @@ -49,7 +76,6 @@ export interface IFluidRepo

extends Reloadable { configuration: IFluidRepoLayout; getGitRepository(): Promise>; getPackageReleaseGroup(pkg: Readonly

): Readonly; - getPackageWorkspace(pkg: Readonly

): Readonly; packages: Map; relativeToRepo(p: string): string; releaseGroups: Map; @@ -72,7 +98,7 @@ export interface IFluidRepoLayout { // @public export interface Installable { - checkInstall(): Promise; + checkInstall(): Promise; install(updateLockfile: boolean): Promise; } @@ -126,6 +152,7 @@ export function isIReleaseGroup(toCheck: Exclude; @@ -134,13 +161,47 @@ export interface IWorkspace extends Installable, Reloadable { toString(): string; } +// @public +export function loadFluidRepo

(searchPath: string, upstreamRemotePartialUrl?: string): IFluidRepo

; + // @public export class NotInGitRepository extends Error { - constructor(path: string); - // (undocumented) + constructor( + path: string); readonly path: string; } +// @public +export abstract class PackageBase implements IPackage { + constructor( + packageJsonFilePath: string, + packageManager: IPackageManager, + workspace: IWorkspace, + isWorkspaceRoot: boolean, + releaseGroup: ReleaseGroupName, + isReleaseGroupRoot: boolean, additionalProperties?: TAddProps); + checkInstall(): Promise; + get combinedDependencies(): Generator; + get directory(): string; + getScript(name: string): string | undefined; + install(updateLockfile: boolean): Promise; + isReleaseGroupRoot: boolean; + readonly isWorkspaceRoot: boolean; + get name(): PackageName; + get nameColored(): string; + get packageJson(): J; + readonly packageJsonFilePath: string; + readonly packageManager: IPackageManager; + get private(): boolean; + readonly releaseGroup: ReleaseGroupName; + reload(): void; + savePackageJson(): Promise; + // (undocumented) + toString(): string; + get version(): string; + readonly workspace: IWorkspace; +} + // @public export interface PackageDependency { depKind: "prod" | "dev" | "peer"; @@ -170,7 +231,6 @@ export type ReleaseGroupName = Opaque; // @public export interface Reloadable { - // (undocumented) reload(): void; } diff --git a/build-tools/packages/build-infrastructure/package.json b/build-tools/packages/build-infrastructure/package.json index 236256235b3a..3ae6842e8a1e 100644 --- a/build-tools/packages/build-infrastructure/package.json +++ b/build-tools/packages/build-infrastructure/package.json @@ -40,9 +40,10 @@ "build": "fluid-build . --task build", "build:commonjs": "npm run tsc && npm run build:test", "build:compile": "npm run build:commonjs", - "build:docs": "api-extractor run --local && typedoc", + "build:docs": "api-extractor run --local", "build:esnext": "tsc --project ./tsconfig.json", "build:manifest": "oclif manifest", + "build:readme": "oclif readme --version 0.0.0 --no-aliases --readme-path src/docs/cli.md", "build:test": "npm run build:test:esm && npm run build:test:cjs", "build:test:cjs": "fluid-tsc commonjs --project ./src/test/tsconfig.cjs.json", "build:test:esm": "tsc --project ./src/test/tsconfig.json", diff --git a/build-tools/packages/build-infrastructure/src/commands/list.ts b/build-tools/packages/build-infrastructure/src/commands/list.ts new file mode 100644 index 000000000000..0cb60d6806ab --- /dev/null +++ b/build-tools/packages/build-infrastructure/src/commands/list.ts @@ -0,0 +1,91 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { Command, Flags } from "@oclif/core"; +import colors from "picocolors"; + +import { getAllDependenciesInRepo, loadFluidRepo } from "../fluidRepo.js"; +import type { IFluidRepo } from "../types.js"; + +/** + * This command is intended for testing and debugging use only. + */ +export class ListCommand extends Command { + static override description = + "List objects in the Fluid repo, like release groups, workspaces, and packages. USED FOR TESTING ONLY."; + + static override flags = { + path: Flags.directory({ + description: "Path to start searching for the Fluid repo.", + default: ".", + }), + full: Flags.boolean({ + description: "Output the full report.", + }), + } as const; + + async run(): Promise { + const { flags } = await this.parse(ListCommand); + const { path: searchPath, full } = flags; + + // load the Fluid repo + const repo = loadFluidRepo(searchPath); + const _ = full ? await this.logFullReport(repo) : await this.logCompactReport(repo); + } + + private async logFullReport(repo: IFluidRepo): Promise { + this.logIndent(colors.underline("Repository layout")); + for (const workspace of repo.workspaces.values()) { + this.log(); + this.logIndent(colors.blue(workspace.toString()), 1); + for (const releaseGroup of workspace.releaseGroups.values()) { + this.log(); + this.logIndent(colors.green(releaseGroup.toString()), 2); + this.logIndent(colors.bold("Packages"), 3); + for (const pkg of releaseGroup.packages) { + const pkgMessage = colors.white( + `${pkg.name}${pkg.isReleaseGroupRoot ? colors.bold(" (root)") : ""}`, + ); + this.logIndent(pkgMessage, 4); + } + + const { releaseGroups, workspaces } = getAllDependenciesInRepo( + repo, + releaseGroup.packages, + ); + if (releaseGroups.length > 0 || workspaces.length > 0) { + this.log(); + this.logIndent(colors.bold("Depends on:"), 3); + for (const depReleaseGroup of releaseGroups) { + this.logIndent(depReleaseGroup.toString(), 4); + } + for (const depWorkspace of workspaces) { + this.logIndent(depWorkspace.toString(), 4); + } + } + } + } + } + + private async logCompactReport(repo: IFluidRepo): Promise { + this.logIndent(colors.underline("Repository layout")); + for (const workspace of repo.workspaces.values()) { + this.log(); + this.logIndent(colors.blue(workspace.toString()), 1); + this.logIndent(colors.bold("Packages"), 2); + for (const pkg of workspace.packages) { + const pkgMessage = colors.white( + `${pkg.isReleaseGroupRoot ? colors.bold("(root) ") : ""}${pkg.name} ${colors.black(colors.bgGreen(pkg.releaseGroup))}`, + ); + this.logIndent(pkgMessage, 3); + } + } + } + + private logIndent(message: string, indent: number = 0): void { + const spaces = " ".repeat(2 * indent); + this.log(`${spaces}${message}`); + } +} diff --git a/build-tools/packages/build-infrastructure/src/config.ts b/build-tools/packages/build-infrastructure/src/config.ts index 840959945476..f8bba299a64b 100644 --- a/build-tools/packages/build-infrastructure/src/config.ts +++ b/build-tools/packages/build-infrastructure/src/config.ts @@ -167,10 +167,11 @@ export function matchesReleaseGroupDefinition( } /** - * Finds the name of the release group that a package belongs to. + * Finds the name of the release group that a package belongs to based on the release group configuration within a + * workspace. * - * @param pkg - The package for which to fina a release group. - * @param definition - The "releaseGroups" config from the RepoLayout config/ + * @param pkg - The package for which to find a release group. + * @param definition - The "releaseGroups" config from the RepoLayout configuration. * @returns The name of the package's release group. */ export function findReleaseGroupForPackage( diff --git a/build-tools/packages/build-infrastructure/src/docs/cli.md b/build-tools/packages/build-infrastructure/src/docs/cli.md new file mode 100644 index 000000000000..3a5fa35afe25 --- /dev/null +++ b/build-tools/packages/build-infrastructure/src/docs/cli.md @@ -0,0 +1,37 @@ +--- +title: repo-layout -- the build-infrastructure CLI +--- + +# repo-layout -- the build-infrastructure CLI + +# Table of contents + + +* [repo-layout -- the build-infrastructure CLI](#repo-layout----the-build-infrastructure-cli) +* [Table of contents](#table-of-contents) +* [Commands](#commands) + + +# Commands + + +* [`repo-layout list`](#repo-layout-list) + +## `repo-layout list` + +List objects in the Fluid repo, like release groups, workspaces, and packages. USED FOR TESTING ONLY. + +``` +USAGE + $ repo-layout list [--path ] [--full] + +FLAGS + --full Output the full report. + --path= [default: .] Path to start searching for the Fluid repo. + +DESCRIPTION + List objects in the Fluid repo, like release groups, workspaces, and packages. USED FOR TESTING ONLY. +``` + +_See code: [src/commands/list.ts](https://github.com/microsoft/FluidFramework/blob/main/build-tools/packages/build-infrastructure/src/commands/list.ts)_ + diff --git a/build-tools/packages/build-infrastructure/src/errors.ts b/build-tools/packages/build-infrastructure/src/errors.ts index fb4385bc02d8..437e26e8dc8e 100644 --- a/build-tools/packages/build-infrastructure/src/errors.ts +++ b/build-tools/packages/build-infrastructure/src/errors.ts @@ -7,7 +7,12 @@ * An error thrown when a path is not within a Git repository. */ export class NotInGitRepository extends Error { - constructor(public readonly path: string) { + constructor( + /** + * The path that was checked and found to be outside a Git repository. + */ + public readonly path: string, + ) { super(`Path is not in a Git repository: ${path}`); } } diff --git a/build-tools/packages/build-infrastructure/src/fluidRepo.ts b/build-tools/packages/build-infrastructure/src/fluidRepo.ts new file mode 100644 index 000000000000..e8fae51a98ad --- /dev/null +++ b/build-tools/packages/build-infrastructure/src/fluidRepo.ts @@ -0,0 +1,237 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import path from "node:path"; + +import { type SimpleGit, simpleGit } from "simple-git"; + +import { type IFluidRepoLayout, getFluidRepoLayout } from "./config.js"; +import { NotInGitRepository } from "./errors.js"; +import { findGitRootSync } from "./git.js"; +import { + type IFluidRepo, + type IPackage, + type IReleaseGroup, + type IWorkspace, + type PackageName, + type ReleaseGroupName, + type WorkspaceName, +} from "./types.js"; +import { Workspace } from "./workspace.js"; +import { loadWorkspacesFromLegacyConfig } from "./workspaceCompat.js"; + +/** + * {@inheritDoc IFluidRepo} + */ +export class FluidRepo

implements IFluidRepo

{ + /** + * The absolute path to the root of the FluidRepo. This is the path where the config file is located. + */ + public readonly root: string; + + /** + * {@inheritDoc IFluidRepo.configuration} + */ + public readonly configuration: IFluidRepoLayout; + + /** + * The absolute path to the config file. + */ + protected readonly configFilePath: string; + + /** + * @param searchPath - The path that should be searched for a repo layout config file. + * @param gitRepository - A SimpleGit instance rooted in the root of the Git repository housing the FluidRepo. This + * should be set to false if the FluidRepo is not within a Git repository. + */ + public constructor( + searchPath: string, + + /** + * {@inheritDoc IFluidRepo.upstreamRemotePartialUrl} + */ + public readonly upstreamRemotePartialUrl?: string, + ) { + const { config, configFilePath } = getFluidRepoLayout(searchPath); + this.root = path.resolve(path.dirname(configFilePath)); + this.configuration = config; + this.configFilePath = configFilePath; + + // Check for the repoLayout config first + if (config.repoLayout === undefined) { + // If there's no `repoLayout` _and_ no `repoPackages`, then we need to error since there's no loadable config. + if (config.repoPackages === undefined) { + throw new Error(`Can't find configuration.`); + } else { + console.warn( + `The repoPackages setting is deprecated and will no longer be read in a future version. Use repoLayout instead.`, + ); + this._workspaces = loadWorkspacesFromLegacyConfig(config.repoPackages, this); + } + } else { + this._workspaces = new Map( + Object.entries(config.repoLayout.workspaces).map((entry) => { + const name = entry[0] as WorkspaceName; + const definition = entry[1]; + const ws = Workspace.load(name, definition, this.root, this); + return [name, ws]; + }), + ); + } + + const releaseGroups = new Map(); + for (const ws of this.workspaces.values()) { + for (const [rgName, rg] of ws.releaseGroups) { + if (releaseGroups.has(rgName)) { + throw new Error(`Duplicate release group: ${rgName}`); + } + releaseGroups.set(rgName, rg); + } + } + this._releaseGroups = releaseGroups; + } + + private readonly _workspaces: Map; + + /** + * {@inheritDoc IFluidRepo.workspaces} + */ + public get workspaces(): Map { + return this._workspaces; + } + + private readonly _releaseGroups: Map; + + /** + * {@inheritDoc IFluidRepo.releaseGroups} + */ + public get releaseGroups(): Map { + return this._releaseGroups; + } + + /** + * {@inheritDoc IFluidRepo.packages} + */ + public get packages(): Map { + const pkgs: Map = new Map(); + for (const ws of this.workspaces.values()) { + for (const pkg of ws.packages) { + if (pkgs.has(pkg.name)) { + throw new Error(`Duplicate package: ${pkg.name}`); + } + + pkgs.set(pkg.name, pkg as P); + } + } + + return pkgs; + } + + /** + * {@inheritDoc IFluidRepo.relativeToRepo} + */ + public relativeToRepo(p: string): string { + // Replace \ in result with / in case OS is Windows. + return path.relative(this.root, p).replace(/\\/g, "/"); + } + + /** + * Reload the Fluid repo by calling `reload` on each workspace in the repository. + */ + public reload(): void { + for (const ws of this.workspaces.values()) { + ws.reload(); + } + } + + private gitRepository: SimpleGit | undefined; + private _checkedForGitRepo = false; + + /** + * {@inheritDoc IFluidRepo.getGitRepository} + */ + public async getGitRepository(): Promise> { + if (this.gitRepository !== undefined) { + return this.gitRepository; + } + + if (this._checkedForGitRepo === false) { + this._checkedForGitRepo = true; + // Check if the path is within a Git repo by trying to find the path to the Git repo root. If not within a git + // repo, this call will throw a `NotInGitRepository` error. + const gitRoot = findGitRootSync(this.root); + this.gitRepository = simpleGit(gitRoot); + return this.gitRepository; + } + + throw new NotInGitRepository(this.root); + } + + /** + * {@inheritDoc IFluidRepo.getPackageReleaseGroup} + */ + public getPackageReleaseGroup(pkg: Readonly

): Readonly { + const found = this.releaseGroups.get(pkg.releaseGroup); + if (found === undefined) { + throw new Error(`Cannot find release group for package: ${pkg}`); + } + + return found; + } +} + +/** + * Searches for a Fluid repo config file and loads the repo layout from the config if found. + * + * @typeParam P - The type to use for Packages. + * @param searchPath - The path to start searching for a Fluid repo config. + * @param upstreamRemotePartialUrl - A partial URL to the upstream repo. This is used to find the local git remote that + * corresponds to the upstream repo. + * @returns The loaded Fluid repo. + */ +export function loadFluidRepo

( + searchPath: string, + upstreamRemotePartialUrl?: string, +): IFluidRepo

{ + const repo: IFluidRepo

= new FluidRepo

(searchPath, upstreamRemotePartialUrl); + return repo; +} + +/** + * Returns an object containing all the packages, release groups, and workspaces that a given set of packages depends + * on. This function only considers packages in the Fluid repo. + */ +export function getAllDependenciesInRepo( + repo: IFluidRepo, + packages: IPackage[], +): { packages: IPackage[]; releaseGroups: IReleaseGroup[]; workspaces: IWorkspace[] } { + const dependencyPackages: Set = new Set(); + const releaseGroups: Set = new Set(); + const workspaces: Set = new Set(); + + for (const pkg of packages) { + for (const { name } of pkg.combinedDependencies) { + const depPackage = repo.packages.get(name); + if (depPackage === undefined) { + continue; + } + + if (pkg.releaseGroup !== depPackage.releaseGroup) { + dependencyPackages.add(depPackage); + releaseGroups.add(repo.getPackageReleaseGroup(depPackage)); + + if (pkg.workspace !== depPackage.workspace) { + workspaces.add(depPackage.workspace); + } + } + } + } + + return { + packages: [...dependencyPackages], + releaseGroups: [...releaseGroups], + workspaces: [...workspaces], + }; +} diff --git a/build-tools/packages/build-infrastructure/src/git.ts b/build-tools/packages/build-infrastructure/src/git.ts new file mode 100644 index 000000000000..dd3d61286861 --- /dev/null +++ b/build-tools/packages/build-infrastructure/src/git.ts @@ -0,0 +1,45 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import execa from "execa"; + +import { NotInGitRepository } from "./errors.js"; + +/** + * Returns the absolute path to the nearest Git repository root found starting at `cwd`. + * + * @param cwd - The working directory to use to start searching for Git repositories. Defaults to `process.cwd()` if not + * provided. + * + * @throws A `NotInGitRepository` error if no git repo is found. + * + * @privateRemarks + * This function is helpful because it is synchronous. The SimpleGit wrapper is async-only. + */ +export function findGitRootSync(cwd = process.cwd()): string { + try { + // This call will throw outside a git repo, which we'll catch and throw a NotInGitRepo error instead. + const result = execa.sync("git", ["rev-parse", "--show-toplevel"], { + cwd, + encoding: "utf8", + // Ignore stdin but pipe (capture) stdout and stderr since git will write to both. + stdio: ["ignore", "pipe", "pipe"], + }); + + // If anything was written to stderr, then it's not a git repo. + // This is likely unnecessary since the earlier exec call should throw, but just in case, throw here as well. + if (result.stderr) { + throw new NotInGitRepository(cwd); + } + + return result.stdout.trim(); + } catch (error) { + const message = (error as Error).message; + if (message.includes("not a git repository")) { + throw new NotInGitRepository(cwd); + } + throw error; + } +} diff --git a/build-tools/packages/build-infrastructure/src/index.ts b/build-tools/packages/build-infrastructure/src/index.ts index f43ce7996414..a870d8379496 100644 --- a/build-tools/packages/build-infrastructure/src/index.ts +++ b/build-tools/packages/build-infrastructure/src/index.ts @@ -25,6 +25,13 @@ export { getFluidRepoLayout, } from "./config.js"; export { NotInGitRepository } from "./errors.js"; +export { + FluidRepo as FluidRepoBase, + getAllDependenciesInRepo, + loadFluidRepo, +} from "./fluidRepo.js"; +export { PackageBase } from "./package.js"; +export { createPackageManager } from "./packageManagers.js"; export type { AdditionalPackageProps, Installable, diff --git a/build-tools/packages/build-infrastructure/src/package.ts b/build-tools/packages/build-infrastructure/src/package.ts new file mode 100644 index 000000000000..f457abbbdfdb --- /dev/null +++ b/build-tools/packages/build-infrastructure/src/package.ts @@ -0,0 +1,367 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { existsSync } from "node:fs"; +import path from "node:path"; + +// Imports are written this way for CJS/ESM compat +import fsePkg from "fs-extra"; +const { readJsonSync } = fsePkg; +import colors from "picocolors"; + +import { type WorkspaceDefinition, findReleaseGroupForPackage } from "./config.js"; +import { readPackageJsonAndIndent, writePackageJson } from "./packageJsonUtils.js"; +import type { + AdditionalPackageProps, + IPackage, + IPackageManager, + IWorkspace, + PackageDependency, + PackageJson, + PackageName, + ReleaseGroupName, +} from "./types.js"; +import { lookUpDirSync } from "./utils.js"; + +/** + * A base class for npm packages. A custom type can be used for the package.json schema, which is useful + * when the package.json has custom keys/values. + * + * @typeParam J - The package.json type to use. This type must extend the {@link PackageJson} type defined in this + * package. + * @typeParam TAddProps - Additional typed props that will be added to the package object. + */ +export abstract class PackageBase< + J extends PackageJson = PackageJson, + TAddProps extends AdditionalPackageProps = undefined, +> implements IPackage +{ + // eslint-disable-next-line @typescript-eslint/prefer-readonly -- false positive; this value is changed + private static packageCount: number = 0; + private static readonly colorFunction = [ + colors.red, + colors.green, + colors.yellow, + colors.blue, + colors.magenta, + colors.cyan, + colors.white, + colors.gray, + colors.redBright, + colors.greenBright, + colors.yellowBright, + colors.blueBright, + colors.magentaBright, + colors.cyanBright, + colors.whiteBright, + ]; + + private readonly _indent: string; + private _packageJson: J; + private readonly packageId = Package.packageCount++; + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + private get color() { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return Package.colorFunction[this.packageId % Package.colorFunction.length]!; + } + + /** + * Create a new package from a package.json file. **Prefer the .load method to calling the contructor directly.** + * + * @param packageJsonFilePath - The path to a package.json file. + * @param packageManager - The package manager used by the workspace. + * @param isWorkspaceRoot - Set to true if this package is the root of a workspace. + * @param additionalProperties - An object with additional properties that should be added to the class. This is + * useful to augment the package class with additional properties. + */ + public constructor( + /** + * {@inheritDoc IPackage.packageJsonFilePath} + */ + public readonly packageJsonFilePath: string, + + /** + * {@inheritDoc IPackage.packageManager} + */ + public readonly packageManager: IPackageManager, + + /** + * {@inheritDoc IPackage.workspace} + */ + public readonly workspace: IWorkspace, + + /** + * {@inheritDoc IPackage.isWorkspaceRoot} + */ + public readonly isWorkspaceRoot: boolean, + + /** + * {@inheritDoc IPackage.releaseGroup} + */ + public readonly releaseGroup: ReleaseGroupName, + + /** + * {@inheritDoc IPackage.isReleaseGroupRoot} + */ + public isReleaseGroupRoot: boolean, + additionalProperties?: TAddProps, + ) { + [this._packageJson, this._indent] = readPackageJsonAndIndent(packageJsonFilePath); + if (additionalProperties !== undefined) { + Object.assign(this, additionalProperties); + } + } + + /** + * {@inheritDoc IPackage.combinedDependencies} + */ + public get combinedDependencies(): Generator { + return iterateDependencies(this.packageJson); + } + + /** + * {@inheritDoc IPackage.directory} + */ + public get directory(): string { + return path.dirname(this.packageJsonFilePath); + } + + /** + * {@inheritDoc IPackage.name} + */ + public get name(): PackageName { + return this.packageJson.name as PackageName; + } + + /** + * {@inheritDoc IPackage.nameColored} + */ + public get nameColored(): string { + return this.color(this.name); + } + + /** + * {@inheritDoc IPackage.packageJson} + */ + public get packageJson(): J { + return this._packageJson; + } + + /** + * {@inheritDoc IPackage.private} + */ + public get private(): boolean { + return this.packageJson.private ?? false; + } + + /** + * {@inheritDoc IPackage.version} + */ + public get version(): string { + return this.packageJson.version; + } + + /** + * {@inheritDoc IPackage.savePackageJson} + */ + public async savePackageJson(): Promise { + writePackageJson(this.packageJsonFilePath, this.packageJson, this._indent); + } + + /** + * Reload the package from the on-disk package.json. + */ + public reload(): void { + this._packageJson = readJsonSync(this.packageJsonFilePath) as J; + } + + public toString(): string { + return `${this.name} (${this.directory})`; + } + + /** + * {@inheritDoc IPackage.getScript} + */ + public getScript(name: string): string | undefined { + return this.packageJson.scripts === undefined ? undefined : this.packageJson.scripts[name]; + } + + /** + * {@inheritDoc Installable.checkInstall} + */ + public async checkInstall(): Promise { + if (this.combinedDependencies.next().done === true) { + // No dependencies + return true; + } + + if (!existsSync(path.join(this.directory, "node_modules"))) { + return [`${this.nameColored}: node_modules not installed in ${this.directory}`]; + } + + const errors: string[] = []; + for (const dep of this.combinedDependencies) { + const found = lookUpDirSync(this.directory, (currentDir) => { + // TODO: check semver as well + return existsSync(path.join(currentDir, "node_modules", dep.name)); + }); + + if (found === undefined) { + errors.push(`${this.nameColored}: dependency ${dep.name} not found`); + } + } + return errors.length === 0 ? true : errors; + } + + /** + * Installs the dependencies for all packages in this package's workspace. + */ + public async install(updateLockfile: boolean): Promise { + return this.workspace.install(updateLockfile); + } +} + +/** + * A concrete class that is used internally within build-infrastructure as the concrete {@link IPackage} implementation. + * + * @typeParam J - The package.json type to use. This type must extend the {@link PackageJson} type defined in this + * package. + * @typeParam TAddProps - Additional typed props that will be added to the package object. + */ +class Package< + J extends PackageJson = PackageJson, + TAddProps extends AdditionalPackageProps = undefined, +> extends PackageBase { + /** + * Loads an {@link IPackage} from a {@link WorkspaceDefinition}. + * + * @param packageJsonFilePath - The path to the package.json for the package being loaded. + * @param packageManager - The package manager to use. + * @param isWorkspaceRoot - Set to `true` if the package is a workspace root package. + * @param workspaceDefinition - The workspace definition. + * @param workspace - The workspace that this package belongs to. + * @param additionalProperties - Additional properties that will be added to the package object. + * @returns A loaded {@link IPackage} instance. + */ + public static loadFromWorkspaceDefinition< + T extends typeof Package, + J extends PackageJson = PackageJson, + TAddProps extends AdditionalPackageProps = undefined, + >( + this: T, + packageJsonFilePath: string, + packageManager: IPackageManager, + isWorkspaceRoot: boolean, + workspaceDefinition: WorkspaceDefinition, + workspace: IWorkspace, + additionalProperties?: TAddProps, + ): IPackage { + const packageName: PackageName = (readJsonSync(packageJsonFilePath) as J) + .name as PackageName; + const releaseGroupName = findReleaseGroupForPackage( + packageName, + workspaceDefinition.releaseGroups, + ); + + if (releaseGroupName === undefined) { + throw new Error(`Cannot find release group for package '${packageName}'`); + } + + const releaseGroupDefinition = + workspaceDefinition.releaseGroups[releaseGroupName as string]; + + if (releaseGroupDefinition === undefined) { + throw new Error(`Cannot find release group definition for ${releaseGroupName}`); + } + + const { rootPackageName } = releaseGroupDefinition; + const isReleaseGroupRoot = + rootPackageName === undefined ? false : packageName === rootPackageName; + + const pkg = new this( + packageJsonFilePath, + packageManager, + workspace, + isWorkspaceRoot, + releaseGroupName, + isReleaseGroupRoot, + additionalProperties, + ); + + return pkg; + } +} + +/** + * Loads an {@link IPackage} from a {@link WorkspaceDefinition}. + * + * @param packageJsonFilePath - The path to the package.json for the package being loaded. + * @param packageManager - The package manager to use. + * @param isWorkspaceRoot - Set to `true` if the package is a workspace root package. + * @param workspaceDefinition - The workspace definition. + * @param workspace - The workspace that this package belongs to. + * @returns A loaded {@link IPackage} instance. + */ +export function loadPackageFromWorkspaceDefinition( + packageJsonFilePath: string, + packageManager: IPackageManager, + isWorkspaceRoot: boolean, + workspaceDefinition: WorkspaceDefinition, + workspace: IWorkspace, +): IPackage { + return Package.loadFromWorkspaceDefinition( + packageJsonFilePath, + packageManager, + isWorkspaceRoot, + workspaceDefinition, + workspace, + ); +} + +/** + * A generator function that returns all production, dev, and peer dependencies in package.json. + * + * @param packageJson - The package.json whose dependencies should be iterated. + */ +function* iterateDependencies( + packageJson: T, +): Generator { + for (const [pkgName, version] of Object.entries(packageJson.dependencies ?? {})) { + const name = pkgName as PackageName; + if (version === undefined) { + throw new Error(`Dependency found without a version specifier: ${name}`); + } + yield { + name, + version, + depKind: "prod", + } as const; + } + + for (const [pkgName, version] of Object.entries(packageJson.devDependencies ?? {})) { + const name = pkgName as PackageName; + if (version === undefined) { + throw new Error(`Dependency found without a version specifier: ${name}`); + } + yield { + name, + version, + depKind: "dev", + } as const; + } + + for (const [pkgName, version] of Object.entries(packageJson.devDependencies ?? {})) { + const name = pkgName as PackageName; + if (version === undefined) { + throw new Error(`Dependency found without a version specifier: ${name}`); + } + yield { + name, + version, + depKind: "peer", + } as const; + } +} diff --git a/build-tools/packages/build-infrastructure/src/packageJsonUtils.ts b/build-tools/packages/build-infrastructure/src/packageJsonUtils.ts new file mode 100644 index 000000000000..b5c8fc13b669 --- /dev/null +++ b/build-tools/packages/build-infrastructure/src/packageJsonUtils.ts @@ -0,0 +1,109 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { readFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import detectIndent from "detect-indent"; +// Imports are written this way for CJS/ESM compat +import fsePkg from "fs-extra"; +const { writeJson, writeJsonSync } = fsePkg; +import sortPackageJson from "sort-package-json"; + +import type { PackageJson } from "./types.js"; + +/** + * Reads the contents of package.json, applies a transform function to it, then writes the results back to the source + * file. + * + * @param packagePath - A path to a package.json file or a folder containing one. If the path is a directory, the + * package.json from that directory will be used. + * @param packageTransformer - A function that will be executed on the package.json contents before writing it + * back to the file. + * + * @remarks + * + * The package.json is always sorted using sort-package-json. + */ +export function updatePackageJsonFile( + packagePath: string, + packageTransformer: (json: J) => void, +): void { + const resolvedPath = packagePath.endsWith("package.json") + ? packagePath + : path.join(packagePath, "package.json"); + const [pkgJson, indent] = readPackageJsonAndIndent(resolvedPath); + + // Transform the package.json + packageTransformer(pkgJson); + + writePackageJson(resolvedPath, pkgJson, indent); +} + +/** + * Reads a package.json file from a path, detects its indentation, and returns both the JSON as an object and + * indentation. + */ +export function readPackageJsonAndIndent( + pathToJson: string, +): [json: J, indent: string] { + const contents = readFileSync(pathToJson).toString(); + const indentation = detectIndent(contents).indent || "\t"; + const pkgJson: J = JSON.parse(contents) as J; + return [pkgJson, indentation]; +} + +/** + * Writes a PackageJson object to a file using the provided indentation. + */ +export function writePackageJson( + packagePath: string, + pkgJson: J, + indent: string, +): void { + return writeJsonSync(packagePath, sortPackageJson(pkgJson), { spaces: indent }); +} + +/** + * Reads the contents of package.json, applies a transform function to it, then writes + * the results back to the source file. + * + * @param packagePath - A path to a package.json file or a folder containing one. If the + * path is a directory, the package.json from that directory will be used. + * @param packageTransformer - A function that will be executed on the package.json + * contents before writing it back to the file. + * + * @remarks + * The package.json is always sorted using sort-package-json. + */ +export async function updatePackageJsonFileAsync( + packagePath: string, + packageTransformer: (json: J) => Promise, +): Promise { + const resolvedPath = packagePath.endsWith("package.json") + ? packagePath + : path.join(packagePath, "package.json"); + const [pkgJson, indent] = await readPackageJsonAndIndentAsync(resolvedPath); + + // Transform the package.json + await packageTransformer(pkgJson); + + await writeJson(resolvedPath, sortPackageJson(pkgJson), { spaces: indent }); +} + +/** + * Reads a package.json file from a path, detects its indentation, and returns both the JSON as an object and + * indentation. + */ +async function readPackageJsonAndIndentAsync( + pathToJson: string, +): Promise<[json: J, indent: string]> { + return readFile(pathToJson, { encoding: "utf8" }).then((contents) => { + const indentation = detectIndent(contents).indent || "\t"; + const pkgJson: J = JSON.parse(contents) as J; + return [pkgJson, indentation]; + }); +} diff --git a/build-tools/packages/build-infrastructure/src/packageManagers.ts b/build-tools/packages/build-infrastructure/src/packageManagers.ts new file mode 100644 index 000000000000..d434bc99e148 --- /dev/null +++ b/build-tools/packages/build-infrastructure/src/packageManagers.ts @@ -0,0 +1,68 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import type { IPackageManager, PackageManagerName } from "./types.js"; + +export class PackageManager implements IPackageManager { + public readonly lockfileName: string; + + /** + * Instantiates a new package manager object. Prefer the createPackageManager function to calling the constructor + * directly. + */ + public constructor(public readonly name: PackageManagerName) { + switch (this.name) { + case "npm": { + this.lockfileName = "package-lock.json"; + break; + } + + case "pnpm": { + this.lockfileName = "pnpm-lock.yaml"; + break; + } + + case "yarn": { + this.lockfileName = "yarn.lock"; + break; + } + + default: { + throw new Error(`Unknown package manager name: ${this.name}`); + } + } + } + + public installCommand(updateLockfile: boolean): string { + switch (this.name) { + case "npm": { + const command = "install"; + const update = updateLockfile ? "--package-lock=true" : "--package-lock=false"; + return `${command} ${update}`; + } + + case "pnpm": { + const command = "install"; + const update = updateLockfile ? "--no-frozen-lockfile" : "--frozen-lockfile"; + return `${command} ${update}`; + } + + case "yarn": { + return "install"; + } + + default: { + throw new Error(`Unknown package manager name: ${this.name}`); + } + } + } +} + +/** + * Create a new package manager instance. + */ +export function createPackageManager(name: PackageManagerName): IPackageManager { + return new PackageManager(name); +} diff --git a/build-tools/packages/build-infrastructure/src/releaseGroup.ts b/build-tools/packages/build-infrastructure/src/releaseGroup.ts new file mode 100644 index 000000000000..c20aabc41c08 --- /dev/null +++ b/build-tools/packages/build-infrastructure/src/releaseGroup.ts @@ -0,0 +1,123 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { type ReleaseGroupDefinition, matchesReleaseGroupDefinition } from "./config.js"; +import type { + IPackage, + IReleaseGroup, + IWorkspace, + PackageName, + ReleaseGroupName, +} from "./types.js"; + +/** + * {@inheritDoc IReleaseGroup} + */ +export class ReleaseGroup implements IReleaseGroup { + /** + * {@inheritDoc IReleaseGroup.name} + */ + public readonly name: ReleaseGroupName; + + /** + * {@inheritDoc IReleaseGroup.adoPipelineUrl} + */ + public readonly adoPipelineUrl: string | undefined; + + public constructor( + name: string, + releaseGroupDefinition: ReleaseGroupDefinition, + + /** + * {@inheritDoc IReleaseGroup.workspace} + */ + public workspace: IWorkspace, + + /** + * {@inheritDoc IReleaseGroup.rootPackage} + */ + public readonly rootPackage?: IPackage, + ) { + this.name = name as ReleaseGroupName; + this.adoPipelineUrl = releaseGroupDefinition.adoPipelineUrl; + this.packages = workspace.packages + .filter((pkg) => matchesReleaseGroupDefinition(pkg, releaseGroupDefinition)) + .map((pkg) => { + // update the release group in the package object so we have an easy way to get from packages to release groups + pkg.releaseGroup = this.name; + return pkg; + }); + + if (releaseGroupDefinition.rootPackageName !== undefined) { + // Find the root package in the set of release group packages + const releaseGroupRoot = this.packages.find( + (pkg) => pkg.name === releaseGroupDefinition.rootPackageName, + ); + if (releaseGroupRoot === undefined) { + throw new Error( + `Could not find release group root package '${releaseGroupDefinition.rootPackageName}' in release group '${this.name}'`, + ); + } + releaseGroupRoot.isReleaseGroupRoot = true; + } + } + + /** + * {@inheritDoc IReleaseGroup.packages} + */ + public readonly packages: IPackage[]; + + /** + * {@inheritDoc IReleaseGroup.version} + */ + public get version(): string { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.packages[0]!.version; + } + + /** + * {@inheritDoc IReleaseGroup.releaseGroupDependencies} + */ + public get releaseGroupDependencies(): IReleaseGroup[] { + const dependentReleaseGroups = new Set(); + const ignoredDependencies = new Set(); + const fluidRepo = this.workspace.fluidRepo; + for (const pkg of this.packages) { + for (const { name } of pkg.combinedDependencies) { + if (ignoredDependencies.has(name)) { + continue; + } + const depPackage = fluidRepo.packages.get(name); + if (depPackage === undefined || depPackage.releaseGroup === this.name) { + ignoredDependencies.add(name); + continue; + } + + const releaseGroup = fluidRepo.releaseGroups.get(depPackage.releaseGroup); + if (releaseGroup === undefined) { + throw new Error( + `Cannot find release group "${depPackage.releaseGroup}" in workspace "${this.workspace}"`, + ); + } + dependentReleaseGroups.add(releaseGroup); + } + } + + return [...dependentReleaseGroups]; + } + + public toString(): string { + return `${this.name} (RELEASE GROUP)`; + } + + /** + * Synchronously reload all of the packages in the release group. + */ + public reload(): void { + for (const pkg of this.packages) { + pkg.reload(); + } + } +} diff --git a/build-tools/packages/build-infrastructure/src/test/data/spaces/_package.json b/build-tools/packages/build-infrastructure/src/test/data/spaces/_package.json new file mode 100644 index 000000000000..330848bba227 --- /dev/null +++ b/build-tools/packages/build-infrastructure/src/test/data/spaces/_package.json @@ -0,0 +1,5 @@ +{ + "name": "spaces-package", + "version": "1.0.0", + "private": true +} diff --git a/build-tools/packages/build-infrastructure/src/test/data/tabs/_package.json b/build-tools/packages/build-infrastructure/src/test/data/tabs/_package.json new file mode 100644 index 000000000000..3c275cecbfc4 --- /dev/null +++ b/build-tools/packages/build-infrastructure/src/test/data/tabs/_package.json @@ -0,0 +1,5 @@ +{ + "name": "tabs-package", + "version": "1.0.0", + "private": true +} diff --git a/build-tools/packages/build-infrastructure/src/test/fluidRepo.test.ts b/build-tools/packages/build-infrastructure/src/test/fluidRepo.test.ts new file mode 100644 index 000000000000..eb04d16e4d59 --- /dev/null +++ b/build-tools/packages/build-infrastructure/src/test/fluidRepo.test.ts @@ -0,0 +1,106 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { strict as assert } from "node:assert"; + +import chai, { expect } from "chai"; +import assertArrays from "chai-arrays"; +import { describe, it } from "mocha"; + +import { loadFluidRepo } from "../fluidRepo.js"; +import { findGitRootSync } from "../git.js"; +import type { ReleaseGroupName, WorkspaceName } from "../types.js"; + +import { testRepoRoot } from "./init.js"; + +chai.use(assertArrays); + +describe("loadFluidRepo", () => { + describe("testRepo", () => { + it("loads correctly", () => { + const repo = loadFluidRepo(testRepoRoot); + assert.strictEqual( + repo.workspaces.size, + 2, + `Expected 2 workspaces, found ${repo.workspaces.size}`, + ); + + const main = repo.workspaces.get("main" as WorkspaceName); + expect(main).to.not.be.undefined; + expect(main?.packages.length).to.equal( + 9, + "main workspace has the wrong number of packages", + ); + expect(main?.releaseGroups.size).to.equal( + 3, + "main workspace has the wrong number of release groups", + ); + + const mainReleaseGroup = repo.releaseGroups.get("main" as ReleaseGroupName); + expect(mainReleaseGroup).to.not.be.undefined; + expect(mainReleaseGroup?.packages.length).to.equal( + 5, + "main release group has the wrong number of packages", + ); + + const second = repo.workspaces.get("second" as WorkspaceName); + expect(second).to.not.be.undefined; + expect(second?.packages.length).to.equal( + 3, + "second workspace has the wrong number of packages", + ); + expect(second?.releaseGroups.size).to.equal( + 1, + "second workspace has the wrong number of release groups", + ); + }); + + it("releaseGroupDependencies", async () => { + const repo = loadFluidRepo(testRepoRoot); + const mainReleaseGroup = repo.releaseGroups.get("main" as ReleaseGroupName); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- test data (validated by another test) guarantees this has a value + const actualDependencies = mainReleaseGroup!.releaseGroupDependencies; + const names = actualDependencies.map((r) => r.name as string); + + expect(actualDependencies).to.not.be.undefined; + expect(names).to.be.containingAllOf(["group2"]); + }); + }); + + describe("FluidFramework repo - tests backCompat config loading", () => { + it("loads correctly", () => { + // Load the root config + const repo = loadFluidRepo(findGitRootSync()); + expect(repo.workspaces.size).to.be.greaterThan(1); + + const client = repo.workspaces.get("client" as WorkspaceName); + expect(client).to.not.be.undefined; + expect(client?.packages.length).to.be.greaterThan(1); + expect(client?.releaseGroups.size).to.be.greaterThan(0); + + const buildTools = repo.workspaces.get("build-tools" as WorkspaceName); + expect(buildTools).to.not.be.undefined; + expect(buildTools?.packages.length).to.equal( + 6, + "build-tools workspace has the wrong number of packages", + ); + expect(buildTools?.releaseGroups.size).to.equal( + 1, + "build-tools workspace has the wrong number of release groups", + ); + }); + + it("releaseGroupDependencies", async () => { + const repo = loadFluidRepo(findGitRootSync()); + const clientReleaseGroup = repo.releaseGroups.get("client" as ReleaseGroupName); + assert(clientReleaseGroup !== undefined); + + const actualDependencies = clientReleaseGroup.releaseGroupDependencies; + + expect(actualDependencies).to.not.be.undefined; + expect(actualDependencies).to.not.be.empty; + }); + }); +}); diff --git a/build-tools/packages/build-infrastructure/src/test/git.test.ts b/build-tools/packages/build-infrastructure/src/test/git.test.ts new file mode 100644 index 000000000000..9b315e162274 --- /dev/null +++ b/build-tools/packages/build-infrastructure/src/test/git.test.ts @@ -0,0 +1,31 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { strict as assert } from "node:assert"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { describe, it } from "mocha"; + +import { NotInGitRepository } from "../errors.js"; +import { findGitRootSync } from "../git.js"; + +import { packageRootPath } from "./init.js"; + +describe("findGitRootSync", () => { + it("finds root", () => { + // This is the path to the current repo, because when tests are executed the working directory is + // the root of this package: build-tools/packages/build-infrastructure + const expected = path.resolve(packageRootPath, "../../.."); + const actual = findGitRootSync(process.cwd()); + assert.strictEqual(actual, expected); + }); + + it("throws outside git repo", () => { + assert.throws(() => { + findGitRootSync(os.tmpdir()); + }, NotInGitRepository); + }); +}); diff --git a/build-tools/packages/build-infrastructure/src/test/packageJsonUtils.test.ts b/build-tools/packages/build-infrastructure/src/test/packageJsonUtils.test.ts new file mode 100644 index 000000000000..809137395a2b --- /dev/null +++ b/build-tools/packages/build-infrastructure/src/test/packageJsonUtils.test.ts @@ -0,0 +1,86 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { strict as assert } from "node:assert"; +import * as path from "node:path"; + +import { describe, it } from "mocha"; + +import { + readPackageJsonAndIndent, + updatePackageJsonFile, + updatePackageJsonFileAsync, +} from "../packageJsonUtils.js"; +import { type PackageJson } from "../types.js"; + +import { testDataPath } from "./init.js"; + +/** + * A transformer function that does nothing. + */ +const testTransformer = (json: PackageJson): void => { + // do nothing + return; +}; + +/** + * A transformer function that does nothing. + */ +const testTransformerAsync = async (json: PackageJson): Promise => { + // do nothing + return; +}; + +describe("readPackageJsonAndIndent", () => { + it("detects spaces indentation", () => { + const testFile = path.resolve(testDataPath, "spaces/_package.json"); + const [, indent] = readPackageJsonAndIndent(testFile); + const expectedIndent = " "; + assert.strictEqual(indent, expectedIndent); + }); + + it("detects tabs indentation", () => { + const testFile = path.resolve(testDataPath, "tabs/_package.json"); + const [, indent] = readPackageJsonAndIndent(testFile); + const expectedIndent = "\t"; + assert.strictEqual(indent, expectedIndent); + }); +}); + +describe("updatePackageJsonFile", () => { + it("keeps indentation style in file with spaces", () => { + const testFile = path.resolve(testDataPath, "spaces/_package.json"); + const expectedIndent = " "; + updatePackageJsonFile(testFile, testTransformer); + const [, indent] = readPackageJsonAndIndent(testFile); + assert.strictEqual(indent, expectedIndent); + }); + + it("keeps indentation style in file with tabs", () => { + const testFile = path.resolve(testDataPath, "tabs/_package.json"); + const expectedIndent = "\t"; + updatePackageJsonFile(testFile, testTransformer); + const [, indent] = readPackageJsonAndIndent(testFile); + assert.strictEqual(indent, expectedIndent); + }); +}); + +describe("updatePackageJsonFileAsync", () => { + it("keeps indentation style in file with spaces", async () => { + const testFile = path.resolve(testDataPath, "spaces/_package.json"); + const expectedIndent = " "; + await updatePackageJsonFileAsync(testFile, testTransformerAsync); + const [, indent] = readPackageJsonAndIndent(testFile); + assert.strictEqual(indent, expectedIndent); + }); + + it("keeps indentation style in file with tabs", async () => { + const testFile = path.resolve(testDataPath, "tabs/_package.json"); + const expectedIndent = "\t"; + await updatePackageJsonFileAsync(testFile, testTransformerAsync); + const [, indent] = readPackageJsonAndIndent(testFile); + assert.strictEqual(indent, expectedIndent); + }); +}); diff --git a/build-tools/packages/build-infrastructure/src/test/workspace.test.ts b/build-tools/packages/build-infrastructure/src/test/workspace.test.ts new file mode 100644 index 000000000000..06056315192d --- /dev/null +++ b/build-tools/packages/build-infrastructure/src/test/workspace.test.ts @@ -0,0 +1,81 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { strict as assert } from "node:assert"; +import { rm } from "node:fs/promises"; +import path from "node:path"; + +import { expect } from "chai"; +import { describe, it } from "mocha"; + +import { loadFluidRepo } from "../fluidRepo.js"; +import type { PackageName, WorkspaceName } from "../types.js"; + +import { testRepoRoot } from "./init.js"; + +describe("workspaces", () => { + const repo = loadFluidRepo(testRepoRoot); + const workspace = repo.workspaces.get("main" as WorkspaceName); + + describe("lockfile outdated", () => { + const pkg = repo.packages.get("@group2/pkg-e" as PackageName); + assert(pkg !== undefined); + + beforeEach(async () => { + pkg.packageJson.dependencies = { + "empty-npm-package": "1.0.0", + }; + await pkg.savePackageJson(); + }); + + afterEach(async () => { + pkg.packageJson.dependencies = {}; + await pkg.savePackageJson(); + }); + + // TODO: Test will be enabled in a follow-up change + // it("install succeeds when updateLockfile=true", async () => { + // await assert.rejects(async () => { + // await workspace?.install(true); + // }); + // }); + + it("install fails when updateLockfile=false", async () => { + await assert.rejects( + async () => { + await workspace?.install(false); + }, + { + name: "Error", + // Note: This assumes we are using pnpm as the package manager. Other package managers will throw different + // errors. + message: /.*ERR_PNPM_OUTDATED_LOCKFILE.*/, + }, + ); + }); + }); + + describe("not installed", () => { + beforeEach(async () => { + try { + await rm(path.join(repo.root, "node_modules"), { recursive: true, force: true }); + } catch { + // nothing + } + }); + + it("checkInstall returns errors when node_modules is missing", async () => { + const actual = await workspace?.checkInstall(); + expect(actual).not.to.be.true; + expect(actual?.[0]).to.include(": node_modules not installed in"); + }); + + it("install succeeds", async () => { + await assert.doesNotReject(async () => { + await workspace?.install(false); + }); + }); + }); +}); diff --git a/build-tools/packages/build-infrastructure/src/types.ts b/build-tools/packages/build-infrastructure/src/types.ts index 5858a94b08cb..d246d9ded034 100644 --- a/build-tools/packages/build-infrastructure/src/types.ts +++ b/build-tools/packages/build-infrastructure/src/types.ts @@ -102,11 +102,6 @@ export interface IFluidRepo

extends Reloadable { * Returns the {@link IReleaseGroup} associated with a package. */ getPackageReleaseGroup(pkg: Readonly

): Readonly; - - /** - * Returns the {@link IWorkspace} associated with a package. - */ - getPackageWorkspace(pkg: Readonly

): Readonly; } /** @@ -114,16 +109,17 @@ export interface IFluidRepo

extends Reloadable { */ export interface Installable { /** - * Returns `true` if the item is installed. If this returns `false`, then the `install` function can be called to - * install. + * Returns `true` if the item is installed. If the item is not installed, an array of error strings will be returned. */ - checkInstall(): Promise; + checkInstall(): Promise; /** * Installs the item. * * @param updateLockfile - If true, the lockfile will be updated. Otherwise, the lockfile will not be updated. This - * may cause the installation to fail. + * may cause the installation to fail and this function to throw an error. + * + * @throws An error if `updateLockfile` is false and the lockfile is outdated. */ install(updateLockfile: boolean): Promise; } @@ -132,6 +128,9 @@ export interface Installable { * An interface for things that can be reloaded, */ export interface Reloadable { + /** + * Synchronously reload. + */ reload(): void; } @@ -183,6 +182,11 @@ export interface IWorkspace extends Installable, Reloadable { */ releaseGroups: Map; + /** + * The Fluid repo that the workspace belongs to. + */ + fluidRepo: IFluidRepo; + /** * An array of all the packages in the workspace. This includes the workspace root and any release group roots and * constituent packages as well. @@ -336,7 +340,7 @@ export interface IPackage extends Installable, Reloadable { /** - * The name of the package + * The name of the package including the scope. */ readonly name: PackageName; diff --git a/build-tools/packages/build-infrastructure/src/utils.ts b/build-tools/packages/build-infrastructure/src/utils.ts new file mode 100644 index 000000000000..47675cb80b5a --- /dev/null +++ b/build-tools/packages/build-infrastructure/src/utils.ts @@ -0,0 +1,51 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import path from "node:path"; + +/** + * Traverses up the directory tree from the given starting directory, applying the callback function to each directory. + * If the callback returns `true` for any directory, that directory path is returned. If the root directory is reached + * without the callback returning true, the function returns `undefined`. + * + * @param dir - The starting directory. + * @param callback - A function that will be called for each path. If this function returns true, then the current path + * will be returned. + * @returns The first path for which the callback function returns true, or `undefined` if the root path is reached + * without the callback returning `true`. + */ +export function lookUpDirSync( + dir: string, + callback: (currentDir: string) => boolean, +): string | undefined { + let curr = path.resolve(dir); + // eslint-disable-next-line no-constant-condition + while (true) { + if (callback(curr)) { + return curr; + } + + const up = path.resolve(curr, ".."); + if (up === curr) { + break; + } + curr = up; + } + + return undefined; +} + +/** + * Determines if a path is under a parent path. + * @param parent - The parent path. + * @param maybeChild - The child path. + * @returns `true` if the child is under the parent path, `false` otherwise. + */ +export function isPathUnder(parent: string, maybeChild: string): boolean { + const resolvedPathA = path.resolve(parent); + const resolvedPathB = path.resolve(maybeChild); + + return resolvedPathB.startsWith(resolvedPathA + path.sep); +} diff --git a/build-tools/packages/build-infrastructure/src/workspace.ts b/build-tools/packages/build-infrastructure/src/workspace.ts new file mode 100644 index 000000000000..3c595d4fefda --- /dev/null +++ b/build-tools/packages/build-infrastructure/src/workspace.ts @@ -0,0 +1,231 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import path from "node:path"; + +import { getPackagesSync } from "@manypkg/get-packages"; +import execa from "execa"; + +import type { ReleaseGroupDefinition, WorkspaceDefinition } from "./config.js"; +import { loadPackageFromWorkspaceDefinition } from "./package.js"; +import { createPackageManager } from "./packageManagers.js"; +import { ReleaseGroup } from "./releaseGroup.js"; +import type { + IFluidRepo, + IPackage, + IPackageManager, + IReleaseGroup, + IWorkspace, + ReleaseGroupName, + WorkspaceName, +} from "./types.js"; + +/** + * {@inheritDoc IWorkspace} + */ +export class Workspace implements IWorkspace { + /** + * {@inheritDoc IWorkspace.name} + */ + public readonly name: WorkspaceName; + + /** + * {@inheritDoc IWorkspace.releaseGroups} + */ + public readonly releaseGroups: Map; + + /** + * {@inheritDoc IWorkspace.rootPackage} + */ + public readonly rootPackage: IPackage; + + /** + * {@inheritDoc IWorkspace.packages} + */ + public readonly packages: IPackage[]; + + /** + * {@inheritDoc IWorkspace.directory} + */ + public readonly directory: string; + + private readonly packageManager: IPackageManager; + + /** + * Construct a new workspace object. + * + * @param name - The name of the workspace. + * @param definition - The definition of the workspace. + * @param root - The path to the root of the workspace. + */ + private constructor( + name: string, + definition: WorkspaceDefinition, + root: string, + + /** + * {@inheritDoc IWorkspace.fluidRepo} + */ + public readonly fluidRepo: IFluidRepo, + ) { + this.name = name as WorkspaceName; + this.directory = path.resolve(root, definition.directory); + + const { + tool, + packages: foundPackages, + rootPackage: foundRootPackage, + rootDir: foundRoot, + } = getPackagesSync(this.directory); + if (foundRoot !== this.directory) { + // This is a sanity check. directory is the path passed in when creating the Workspace object, while rootDir is + // the dir that `getPackagesSync` found. They should be the same. + throw new Error( + `The root dir found by manypkg, '${foundRoot}', does not match the configured directory '${this.directory}'`, + ); + } + + if (foundRootPackage === undefined) { + throw new Error(`No root package found for workspace in '${foundRoot}'`); + } + + switch (tool.type) { + case "npm": + case "pnpm": + case "yarn": { + this.packageManager = createPackageManager(tool.type); + break; + } + default: { + throw new Error(`Unknown package manager '${tool.type}'`); + } + } + + this.packages = []; + for (const pkg of foundPackages) { + const loadedPackage = loadPackageFromWorkspaceDefinition( + path.join(pkg.dir, "package.json"), + this.packageManager, + /* isWorkspaceRoot */ foundPackages.length === 1, + definition, + this, + ); + this.packages.push(loadedPackage); + } + + // Load the workspace root IPackage; only do this if more than one package was found in the workspace; otherwise the + // single package loaded will be the workspace root. + if (foundPackages.length > 1) { + this.rootPackage = loadPackageFromWorkspaceDefinition( + path.join(this.directory, "package.json"), + this.packageManager, + /* isWorkspaceRoot */ true, + definition, + this, + ); + + // Prepend the root package to the list of packages + this.packages.unshift(this.rootPackage); + } else { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.rootPackage = this.packages[0]!; + } + + const rGroupDefinitions: Map = + definition.releaseGroups === undefined + ? new Map() + : new Map( + Object.entries(definition.releaseGroups).map(([rgName, group]) => { + return [rgName as ReleaseGroupName, group]; + }), + ); + + this.releaseGroups = new Map(); + for (const [groupName, def] of rGroupDefinitions) { + const newGroup = new ReleaseGroup(groupName, def, this); + this.releaseGroups.set(groupName, newGroup); + } + + // sanity check - make sure that all packages are in a release group. + const noGroup = new Set(this.packages.map((p) => p.name)); + for (const group of this.releaseGroups.values()) { + for (const pkg of group.packages) { + noGroup.delete(pkg.name); + } + } + + if (noGroup.size > 0) { + const packageList = [...noGroup].join("\n"); + const message = `Found packages in the ${name} workspace that are not in any release groups. Check your config.\n${packageList}`; + throw new Error(message); + } + } + + /** + * {@inheritDoc Installable.checkInstall} + */ + public async checkInstall(): Promise { + const errors: string[] = []; + for (const buildPackage of this.packages) { + const installed = await buildPackage.checkInstall(); + if (installed !== true) { + errors.push(...installed); + } + } + + if (errors.length > 0) { + return errors; + } + return true; + } + + /** + * {@inheritDoc Installable.install} + */ + public async install(updateLockfile: boolean): Promise { + const command = this.packageManager.installCommand(updateLockfile); + + const output = await execa(this.packageManager.name, command.split(" "), { + cwd: this.directory, + }); + + if (output.exitCode !== 0) { + return false; + } + return true; + } + + /** + * Synchronously reload all of the packages in the workspace. + */ + public reload(): void { + for (const pkg of this.packages) { + pkg.reload(); + } + } + + public toString(): string { + return `${this.name} (WORKSPACE)`; + } + + /** + * Load a workspace from a {@link WorkspaceDefinition}. + * + * @param name - The name of the workspace. + * @param definition - The definition for the workspace. + * @param root - The path to the root of the workspace. + * @param fluidRepo - The Fluid repo that the workspace belongs to. + * @returns A loaded {@link IWorkspace}. + */ + public static load( + name: string, + definition: WorkspaceDefinition, + root: string, + fluidRepo: IFluidRepo, + ): IWorkspace { + const workspace = new Workspace(name, definition, root, fluidRepo); + return workspace; + } +} diff --git a/build-tools/packages/build-infrastructure/src/workspaceCompat.ts b/build-tools/packages/build-infrastructure/src/workspaceCompat.ts new file mode 100644 index 000000000000..93a76b389980 --- /dev/null +++ b/build-tools/packages/build-infrastructure/src/workspaceCompat.ts @@ -0,0 +1,119 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { existsSync } from "node:fs"; +import path from "node:path"; + +import globby from "globby"; + +import type { + // eslint-disable-next-line import/no-deprecated -- back-compat code + IFluidBuildDir, + // eslint-disable-next-line import/no-deprecated -- back-compat code + IFluidBuildDirs, + ReleaseGroupDefinition, + WorkspaceDefinition, +} from "./config.js"; +import type { IFluidRepo, IWorkspace, WorkspaceName } from "./types.js"; +import { Workspace } from "./workspace.js"; + +/** + * Loads workspaces based on the "legacy" config -- the former repoPackages section of the fluid-build config. + * + * **ONLY INTENDED FOR BACK-COMPAT.** + * + * @param entry - The config entry. + * @param fluidRepo - The Fluid repo the workspace belongs to. + */ +export function loadWorkspacesFromLegacyConfig( + // eslint-disable-next-line import/no-deprecated -- back-compat code + config: IFluidBuildDirs, + fluidRepo: IFluidRepo, +): Map { + const workspaces: Map = new Map(); + + // Iterate over the entries and create synthetic workspace definitions for them, then load the workspaces. + for (const [name, entry] of Object.entries(config)) { + const loadedWorkspaces: IWorkspace[] = []; + if (Array.isArray(entry)) { + for (const item of entry) { + loadedWorkspaces.push(...loadWorkspacesFromLegacyConfigEntry(item, fluidRepo)); + } + } else if (typeof entry === "object") { + loadedWorkspaces.push(...loadWorkspacesFromLegacyConfigEntry(entry, fluidRepo, name)); + } else { + loadedWorkspaces.push(...loadWorkspacesFromLegacyConfigEntry(entry, fluidRepo)); + } + for (const ws of loadedWorkspaces) { + workspaces.set(ws.name, ws); + } + } + + return workspaces; +} + +/** + * Loads workspaces based on an individual entry in the the "legacy" config -- the former repoPackages section of the + * fluid-build config. A single entry may represent multiple workspaces, so this function returns all of them. An + * example of such a case is when a legacy config includes a folder that isn't itself a package (i.e. it has no + * package.json). Such config entries are intended to include all packages found under the path, so they are each + * treated as individual single-package workspaces and are loaded as such. + * + * **ONLY INTENDED FOR BACK-COMPAT.** + * + * @param entry - The config entry. + * @param fluidRepoRoot - The path to the root of the FluidRepo. + * @param name - If provided, this name will be used for the workspace. If it is not provided, the name will be derived + * from the directory name. + */ +function loadWorkspacesFromLegacyConfigEntry( + // eslint-disable-next-line import/no-deprecated -- back-compat code + entry: string | IFluidBuildDir, + fluidRepo: IFluidRepo, + name?: string, +): IWorkspace[] { + const directory = typeof entry === "string" ? entry : entry.directory; + const rgName = name ?? path.basename(directory); + const workspaceName = rgName; + const releaseGroupDefinitions: { + [name: string]: ReleaseGroupDefinition; + } = {}; + releaseGroupDefinitions[rgName] = { + include: ["*"], + }; + + // BACK-COMPAT HACK - assume that a directory in the legacy config either has a package.json -- in which case the + // directory will be treated as a workspace root -- or it does not, in which case all package.json files under the + // path will be treated as workspace roots. + const packagePath = path.join(fluidRepo.root, directory, "package.json"); + if (existsSync(packagePath)) { + const workspaceDefinition: WorkspaceDefinition = { + directory, + releaseGroups: releaseGroupDefinitions, + }; + + return [Workspace.load(workspaceName, workspaceDefinition, fluidRepo.root, fluidRepo)]; + } + + const packageJsonPaths = globby + .sync(["**/package.json"], { + cwd: path.dirname(packagePath), + gitignore: true, + onlyFiles: true, + absolute: true, + // BACK-COMPAT HACK - only search two levels below entries for package.jsons. This avoids finding some test + // files and treating them as packages. This is only needed when loading old configs. + deep: 2, + }) + .map( + // Make the paths relative to the repo root + (filePath) => path.relative(fluidRepo.root, filePath), + ); + const workspaces = packageJsonPaths.flatMap((pkgPath) => { + const dir = path.dirname(pkgPath); + return loadWorkspacesFromLegacyConfigEntry(dir, fluidRepo); + }); + return workspaces; +} diff --git a/build-tools/packages/build-infrastructure/typedoc.config.cjs b/build-tools/packages/build-infrastructure/typedoc.config.cjs index c9b2c53b602b..c3ef2dff3817 100644 --- a/build-tools/packages/build-infrastructure/typedoc.config.cjs +++ b/build-tools/packages/build-infrastructure/typedoc.config.cjs @@ -18,7 +18,7 @@ module.exports = { out: "docs", readme: "./README.md", mergeReadme: true, - // projectDocuments: ["./src/docs/cli.md"], + projectDocuments: ["./src/docs/cli.md"], defaultCategory: "API", categorizeByGroup: true, navigation: {