diff --git a/build-tools/packages/build-cli/src/commands/build.ts b/build-tools/packages/build-cli/src/commands/build.ts new file mode 100644 index 000000000000..ca4f25e11e0f --- /dev/null +++ b/build-tools/packages/build-cli/src/commands/build.ts @@ -0,0 +1,331 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { existsSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { loadBuildProject } from "@fluid-tools/build-infrastructure"; +import { + FluidRepoBuild, + getFluidBuildConfig, + getResolvedFluidRoot, + type IPackageMatchedOptions, +} from "@fluidframework/build-tools"; +import { Flags } from "@oclif/core"; +import chalk from "picocolors"; + +import { BaseCommand } from "../library/commands/base.js"; + +/** + * Build command that orchestrates incremental builds across the Fluid repo. + * + * This is the oclif equivalent of the `fluid-build` CLI. It uses build-infrastructure for + * repo layout discovery and build-tools for task graph execution. + */ +export default class BuildCommand extends BaseCommand { + static readonly description = + "Build packages in the repo using the fluid-build task graph engine."; + + static readonly flags = { + task: Flags.string({ + char: "t", + multiple: true, + description: "Target task to execute.", + }), + releaseGroup: Flags.string({ + char: "g", + multiple: true, + description: "Release group to operate on. Can be specified multiple times.", + }), + clean: Flags.boolean({ + char: "c", + description: + "Run the clean task on matched packages. Implies --force and disables build unless combined with --rebuild.", + }), + rebuild: Flags.boolean({ + char: "r", + description: "Clean and build matched packages. Implies --force.", + }), + force: Flags.boolean({ + char: "f", + description: "Force build and ignore dependency checks on matched packages.", + }), + dep: Flags.boolean({ + char: "d", + description: "Apply actions to matched packages AND their dependent packages.", + }), + all: Flags.boolean({ + description: + "Operate on all packages/monorepo (default: release group inferred from CWD).", + }), + install: Flags.boolean({ + description: + "Run install for all packages. Skips a package if node_modules already exists.", + }), + uninstall: Flags.boolean({ + description: "Clean all node_modules.", + }), + reinstall: Flags.boolean({ + description: "Same as --uninstall --install.", + }), + vscode: Flags.boolean({ + description: "Output error messages for the default VSCode problem matcher.", + }), + worker: Flags.boolean({ + description: + "Reuse worker threads for some tasks, increasing memory use but lowering overhead.", + }), + workerThreads: Flags.boolean({ + description: "Enable worker threads. Implies --worker.", + }), + workerMemoryLimitMB: Flags.integer({ + description: "Memory limit for worker threads in MiB.", + }), + concurrency: Flags.integer({ + description: "Maximum number of concurrent tasks.", + }), + nolint: Flags.boolean({ hidden: true, default: false }), + lintonly: Flags.boolean({ hidden: true, default: false }), + showExec: Flags.boolean({ hidden: true, default: false }), + ...BaseCommand.flags, + } as const; + + static readonly strict = false; + + async run(): Promise { + const { flags, argv } = this; + + // Resolve the options, applying flag interaction logic + const resolvedOptions = this.resolveOptions(flags, argv as string[]); + + // Resolve repo root + const resolvedRoot = await getResolvedFluidRoot(true); + this.verbose(`Build Root: ${resolvedRoot}`); + + // Load the build project from build-infrastructure + const buildProject = loadBuildProject(resolvedRoot); + + // Load the fluid-build config (needed for task definitions) + const fluidConfig = getFluidBuildConfig(resolvedRoot, false); + + // Populate the global options singleton so the build engine can read them. + // This is a pragmatic bridge until the engine is fully decoupled from globals. + const { options: globalOptions } = await import( + "@fluidframework/build-tools/dist/fluidBuild/options.js" + ); + Object.assign(globalOptions, { + nolint: resolvedOptions.nolint, + lintonly: resolvedOptions.lintonly, + showExec: resolvedOptions.showExec, + matchedOnly: resolvedOptions.matchedOnly, + vscode: resolvedOptions.vscode, + force: resolvedOptions.force, + concurrency: resolvedOptions.concurrency, + worker: resolvedOptions.worker, + workerThreads: resolvedOptions.workerThreads, + workerMemoryLimit: resolvedOptions.workerMemoryLimit, + }); + + const { GitRepo } = await import("@fluidframework/build-tools/dist/common/gitRepo.js"); + + // Create the build engine + const repo = new FluidRepoBuild(buildProject, { + repoRoot: resolvedRoot, + gitRepo: new GitRepo(resolvedRoot), + fluidBuildConfig: fluidConfig, + }); + + // Set matched packages + const matchOptions: IPackageMatchedOptions = { + match: resolvedOptions.match, + all: resolvedOptions.all, + dirs: resolvedOptions.dirs, + releaseGroups: resolvedOptions.releaseGroups, + }; + + const matched = repo.setMatched(matchOptions); + if (!matched) { + this.error("No package matched", { exit: -4 }); + } + + // Uninstall + if (resolvedOptions.uninstall) { + if (!(await repo.uninstall())) { + this.error("Uninstall failed", { exit: -8 }); + } + this.log("Uninstall completed"); + + if (!resolvedOptions.install) { + if (resolvedOptions.clean) { + this.warning("Skipping clean after uninstall"); + } else if (resolvedOptions.build) { + this.warning("Skipping build after uninstall"); + } + return; + } + } + + // Install + if (resolvedOptions.install) { + this.log("Installing packages"); + if (!(await repo.install())) { + this.error("Install failed", { exit: -5 }); + } + this.log("Install completed"); + } + + // Build + if (resolvedOptions.buildTaskNames.length > 0) { + let buildGraph; + this.verbose("Creating build graph..."); + try { + buildGraph = repo.createBuildGraph(resolvedOptions.buildTaskNames); + } catch (e: unknown) { + this.error((e as Error).message, { exit: -11 }); + } + this.verbose("Build graph created."); + + if (!(await buildGraph.checkInstall())) { + this.error("Dependency not installed. Use --install to fix.", { exit: -10 }); + } + + const { Timer } = await import("@fluidframework/build-tools/dist/common/timer.js"); + const timer = new Timer(flags.timer ?? false); + const { BuildResult } = await import( + "@fluidframework/build-tools/dist/fluidBuild/buildResult.js" + ); + + const buildResult = await buildGraph.build(timer); + + if (flags.timer) { + const totalElapsedTime = buildGraph.totalElapsedTime; + const elapsedTime = timer.time(); + const concurrency = totalElapsedTime / elapsedTime; + this.log( + `Execution time: ${totalElapsedTime.toFixed(3)}s, Concurrency: ${concurrency.toFixed(3)}, Queue Wait time: ${buildGraph.totalQueueWaitTime.toFixed(3)}s`, + ); + } + + if (buildResult === BuildResult.Failed) { + const summary = buildGraph.taskFailureSummary; + if (summary) { + this.log(`\n${summary}`); + } + this.error("Build failed", { exit: -1 }); + } + + this.log( + `Build ${buildResult === BuildResult.UpToDate ? chalk.cyanBright("up to date") : chalk.greenBright("succeeded")}`, + ); + } + + if (resolvedOptions.build === false) { + this.log("Other switches with no explicit build script, not building."); + } + } + + private resolveOptions( + flags: Record, + argv: string[], + ): ResolvedBuildOptions { + let build: boolean | undefined = undefined; + let force = (flags.force as boolean) ?? false; + let clean = false; + const install = (flags.install as boolean) || (flags.reinstall as boolean) || false; + const uninstall = (flags.uninstall as boolean) || (flags.reinstall as boolean) || false; + + if (flags.rebuild) { + force = true; + clean = true; + build = true; + } else if (flags.clean) { + force = true; + clean = true; + build = false; + } + + if (install && build === undefined) { + build = false; + } + if (uninstall && build === undefined) { + build = false; + } + + // Parse positional args as package regexes or paths + const match: string[] = []; + const dirs: string[] = []; + for (const arg of argv) { + if (typeof arg === "string" && !arg.startsWith("-")) { + const resolvedPath = path.resolve(arg); + if (existsSync(resolvedPath)) { + dirs.push(arg); + } else { + match.push(arg); + } + } + } + + const taskNames = (flags.task as string[] | undefined) ?? []; + + // Default task names + const buildTaskNames = [...taskNames]; + if (build !== false && buildTaskNames.length === 0) { + buildTaskNames.push("build"); + } + if (clean) { + buildTaskNames.push("clean"); + } + + const workerThreads = (flags.workerThreads as boolean) ?? false; + const worker = (flags.worker as boolean) || workerThreads; + const workerMemoryLimitMB = flags.workerMemoryLimitMB as number | undefined; + + return { + build, + force, + clean, + install, + uninstall, + match, + dirs, + releaseGroups: (flags.releaseGroup as string[] | undefined) ?? [], + all: (flags.all as boolean) ?? false, + matchedOnly: !(flags.dep as boolean), + buildTaskNames, + nolint: (flags.nolint as boolean) ?? false, + lintonly: (flags.lintonly as boolean) ?? false, + showExec: (flags.showExec as boolean) ?? false, + vscode: (flags.vscode as boolean) ?? false, + concurrency: (flags.concurrency as number | undefined) ?? os.cpus().length, + worker, + workerThreads, + workerMemoryLimit: workerMemoryLimitMB + ? workerMemoryLimitMB * 1024 * 1024 + : 2 * 1024 * 1024 * 1024, + }; + } +} + +interface ResolvedBuildOptions { + build: boolean | undefined; + force: boolean; + clean: boolean; + install: boolean; + uninstall: boolean; + match: string[]; + dirs: string[]; + releaseGroups: string[]; + all: boolean; + matchedOnly: boolean; + buildTaskNames: string[]; + nolint: boolean; + lintonly: boolean; + showExec: boolean; + vscode: boolean; + concurrency: number; + worker: boolean; + workerThreads: boolean; + workerMemoryLimit: number; +} diff --git a/build-tools/packages/build-tools/package.json b/build-tools/packages/build-tools/package.json index 7322055fce60..7130ac58748a 100644 --- a/build-tools/packages/build-tools/package.json +++ b/build-tools/packages/build-tools/package.json @@ -40,6 +40,7 @@ "tsc": "tsc" }, "dependencies": { + "@fluid-tools/build-infrastructure": "workspace:~", "@fluid-tools/version-tools": "workspace:~", "@manypkg/get-packages": "^2.2.2", "async": "^3.2.6", diff --git a/build-tools/packages/build-tools/src/common/typeTests.ts b/build-tools/packages/build-tools/src/common/typeTests.ts index 65121129c4cc..97f325102ce3 100644 --- a/build-tools/packages/build-tools/src/common/typeTests.ts +++ b/build-tools/packages/build-tools/src/common/typeTests.ts @@ -4,7 +4,6 @@ */ import path from "node:path"; -import type { Package } from "./npmPackage"; /** * Given a package, returns the name that should be used for the previous version of the package to generate type tests. @@ -15,7 +14,7 @@ import type { Package } from "./npmPackage"; * generation and the generation code that mostly lives in build-cli. Long term this function should move to build-cli * or a third library package and be used by fluid-build and build-cli. */ -export function getTypeTestPreviousPackageDetails(pkg: Package): { +export function getTypeTestPreviousPackageDetails(pkg: { name: string; directory: string }): { name: string; packageJsonPath: string; } { diff --git a/build-tools/packages/build-tools/src/fluidBuild/buildGraph.ts b/build-tools/packages/build-tools/src/fluidBuild/buildGraph.ts index ce661be5a275..ccf1009d0d46 100644 --- a/build-tools/packages/build-tools/src/fluidBuild/buildGraph.ts +++ b/build-tools/packages/build-tools/src/fluidBuild/buildGraph.ts @@ -11,13 +11,13 @@ import { Spinner } from "picospinner"; import * as semver from "semver"; import type { GitRepo } from "../common/gitRepo"; import { defaultLogger } from "../common/logging"; -import type { Package } from "../common/npmPackage"; import type { Timer } from "../common/timer"; import type { BuildContext } from "./buildContext"; import { BuildMetrics } from "./buildMetrics"; import { BuildResult, summarizeBuildResult } from "./buildResult"; import { FileHashCache } from "./fileHashCache"; import type { IFluidBuildConfig } from "./fluidBuildConfig"; +import type { FluidBuildPackage } from "./fluidBuildPackage"; import { getDefaultTaskDefinition, getTaskDefinitions, @@ -55,7 +55,7 @@ class BuildGraphContext implements BuildContext { public readonly repoRoot: string; public readonly gitRepo: GitRepo; constructor( - public readonly repoPackageMap: Map, + public readonly repoPackageMap: Map, readonly buildContext: BuildContext, public readonly workerPool?: WorkerPool, ) { @@ -83,7 +83,7 @@ export class BuildPackage { constructor( public readonly context: BuildGraphContext, - public readonly pkg: Package, + public readonly pkg: FluidBuildPackage, globalTaskDefinitions: TaskDefinitions, ) { this._taskDefinitions = getTaskDefinitions(this.pkg.packageJson, globalTaskDefinitions, { @@ -482,16 +482,16 @@ export class BuildPackage { */ export class BuildGraph { private matchedPackages = 0; - private readonly buildPackages = new Map(); + private readonly buildPackages = new Map(); private readonly context: BuildGraphContext; public constructor( - packages: Map, - releaseGroupPackages: Package[], + packages: Map, + releaseGroupPackages: FluidBuildPackage[], buildContext: BuildContext, private readonly buildTaskNames: string[], globalTaskDefinitions: TaskDefinitionsOnDisk | undefined, - getDepFilter: (pkg: Package) => (dep: Package) => boolean, + getDepFilter: (pkg: FluidBuildPackage) => (dep: FluidBuildPackage) => boolean, ) { this.context = new BuildGraphContext( packages, @@ -615,7 +615,7 @@ export class BuildGraph { } private getBuildPackage( - pkg: Package, + pkg: FluidBuildPackage, globalTaskDefinitions: TaskDefinitions, pendingInitDep: BuildPackage[], ): BuildPackage { @@ -637,10 +637,10 @@ export class BuildGraph { } private initializePackages( - packages: Map, - releaseGroupPackages: Package[], + packages: Map, + releaseGroupPackages: FluidBuildPackage[], globalTaskDefinitionsOnDisk: TaskDefinitionsOnDisk | undefined, - getDepFilter: (pkg: Package) => (dep: Package) => boolean, + getDepFilter: (pkg: FluidBuildPackage) => (dep: FluidBuildPackage) => boolean, ): void { const globalTaskDefinitions = normalizeGlobalTaskDefinitions(globalTaskDefinitionsOnDisk); const pendingInitDep: BuildPackage[] = []; @@ -667,13 +667,15 @@ export class BuildGraph { if (node === undefined) { break; } - if (node.pkg.isReleaseGroupRoot) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - for (const dep of node.pkg.monoRepo!.packages) { - traceGraph(`Package dependency: ${node.pkg.nameColored} => ${dep.nameColored}`); - node.dependentPackages.push( - this.getBuildPackage(dep, globalTaskDefinitions, pendingInitDep), - ); + if (node.pkg.isReleaseGroupRoot && node.pkg.releaseGroupObj) { + for (const innerPkg of node.pkg.releaseGroupObj.packages) { + const dep = packages.get(innerPkg.name); + if (dep && dep !== node.pkg) { + traceGraph(`Package dependency: ${node.pkg.nameColored} => ${dep.nameColored}`); + node.dependentPackages.push( + this.getBuildPackage(dep, globalTaskDefinitions, pendingInitDep), + ); + } } continue; } diff --git a/build-tools/packages/build-tools/src/fluidBuild/buildInfraTypes.ts b/build-tools/packages/build-tools/src/fluidBuild/buildInfraTypes.ts new file mode 100644 index 000000000000..c8acd0ac89d0 --- /dev/null +++ b/build-tools/packages/build-tools/src/fluidBuild/buildInfraTypes.ts @@ -0,0 +1,92 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +/** + * Local type definitions for the subset of @fluid-tools/build-infrastructure types used + * by fluid-build. + * + * build-infrastructure is an ESM-only package and build-tools emits CJS, so static imports + * are not possible with node16 module resolution. These structural types allow type-safe + * code without direct module imports. At runtime, the actual objects are loaded via dynamic + * `import()`. + */ + +/** + * Subset of {@link @fluid-tools/build-infrastructure#IPackage} used by fluid-build. + */ +export interface BuildInfraPackage { + readonly name: string; + readonly nameColored: string; + readonly directory: string; + readonly version: string; + readonly private: boolean; + readonly isWorkspaceRoot: boolean; + readonly isReleaseGroupRoot: boolean; + readonly releaseGroup: string; + readonly packageJsonFilePath: string; + readonly packageJson: Record; + readonly packageManager: { readonly name: string }; + readonly workspace: BuildInfraWorkspace; + readonly combinedDependencies: Generator; + getScript(name: string): string | undefined; + checkInstall(): Promise; + reload(): void; +} + +/** + * Subset of {@link @fluid-tools/build-infrastructure#PackageDependency} used by fluid-build. + */ +export interface BuildInfraPackageDependency { + name: string; + version: string; + depKind: "prod" | "dev" | "peer"; +} + +/** + * Subset of {@link @fluid-tools/build-infrastructure#IReleaseGroup} used by fluid-build. + */ +export interface BuildInfraReleaseGroup { + readonly name: string; + readonly version: string; + readonly rootPackage?: BuildInfraPackage; + readonly packages: BuildInfraPackage[]; + readonly workspace: BuildInfraWorkspace; +} + +/** + * Subset of {@link @fluid-tools/build-infrastructure#IWorkspace} used by fluid-build. + */ +export interface BuildInfraWorkspace { + readonly name: string; + readonly directory: string; + readonly rootPackage: BuildInfraPackage; + readonly packages: BuildInfraPackage[]; + readonly releaseGroups: Map; + install(updateLockfile: boolean): Promise; +} + +/** + * Subset of {@link @fluid-tools/build-infrastructure#IBuildProject} used by fluid-build. + */ +export interface BuildInfraProject { + readonly root: string; + readonly workspaces: Map; + readonly releaseGroups: Map; + readonly packages: Map; + relativeToRepo(p: string): string; +} + +/** + * Dynamically imports and calls loadBuildProject from @fluid-tools/build-infrastructure. + * + * @param searchPath - The path to search for the build project configuration. + * @returns The loaded build project. + */ +export async function loadBuildProjectAsync(searchPath: string): Promise { + const mod = await import("@fluid-tools/build-infrastructure"); + return ( + mod as unknown as { loadBuildProject: (path: string) => BuildInfraProject } + ).loadBuildProject(searchPath); +} diff --git a/build-tools/packages/build-tools/src/fluidBuild/fluidBuild.ts b/build-tools/packages/build-tools/src/fluidBuild/fluidBuild.ts index 290f0de2f8d5..557f2649f4d6 100644 --- a/build-tools/packages/build-tools/src/fluidBuild/fluidBuild.ts +++ b/build-tools/packages/build-tools/src/fluidBuild/fluidBuild.ts @@ -31,8 +31,12 @@ async function main(): Promise { : ""; log(`Build Root: ${resolvedRoot}${suffix}`); + // Load the repo layout from build-infrastructure (async for CJS/ESM interop) + const { loadBuildProjectAsync } = await import("./buildInfraTypes.js"); + const buildProject = await loadBuildProjectAsync(resolvedRoot); + // Load the packages - const repo = new FluidRepoBuild({ + const repo = new FluidRepoBuild(buildProject, { repoRoot: resolvedRoot, gitRepo: new GitRepo(resolvedRoot), fluidBuildConfig: fluidConfig, diff --git a/build-tools/packages/build-tools/src/fluidBuild/fluidBuildPackage.ts b/build-tools/packages/build-tools/src/fluidBuild/fluidBuildPackage.ts new file mode 100644 index 000000000000..aa55fc4d4e77 --- /dev/null +++ b/build-tools/packages/build-tools/src/fluidBuild/fluidBuildPackage.ts @@ -0,0 +1,136 @@ +/*! + * Copyright (c) Microsoft Corporation and contributors. All rights reserved. + * Licensed under the MIT License. + */ + +import { existsSync } from "node:fs"; +import * as path from "node:path"; + +import { defaultLogger } from "../common/logging"; +import type { PackageJson } from "../common/npmPackage"; +import type { + BuildInfraPackage, + BuildInfraPackageDependency, + BuildInfraReleaseGroup, +} from "./buildInfraTypes"; + +const { errorLog: error } = defaultLogger; + +/** + * Wraps a build-infrastructure IPackage with fluid-build-specific state such as matching + * and lock file lookup. + */ +export class FluidBuildPackage { + private _matched = false; + + public constructor( + /** + * The underlying IPackage from build-infrastructure. + */ + public readonly inner: BuildInfraPackage, + + /** + * The release group this package belongs to, if any. + * Undefined for independent packages (packages not in a release group). + */ + public readonly releaseGroupObj: BuildInfraReleaseGroup | undefined, + ) {} + + // --- Delegated properties --- + + public get name(): string { + return this.inner.name; + } + + public get nameColored(): string { + return this.inner.nameColored; + } + + public get directory(): string { + return this.inner.directory; + } + + public get version(): string { + return this.inner.version; + } + + public get isReleaseGroupRoot(): boolean { + return this.inner.isReleaseGroupRoot; + } + + public get releaseGroup(): string { + return this.inner.releaseGroup; + } + + public get packageJsonFilePath(): string { + return this.inner.packageJsonFilePath; + } + + public get packageManager(): string { + return this.inner.packageManager.name; + } + + /** + * The package.json contents, cast to build-tools' PackageJson type which includes the + * `fluidBuild` field. This cast is safe because the underlying JSON object contains all + * fields regardless of the TypeScript type used by build-infrastructure. + */ + public get packageJson(): PackageJson { + return this.inner.packageJson as unknown as PackageJson; + } + + public get combinedDependencies(): Generator { + return this.inner.combinedDependencies; + } + + public getScript(name: string): string | undefined { + return this.inner.getScript(name); + } + + // --- Build-specific state --- + + public get matched(): boolean { + return this._matched; + } + + public setMatched(): void { + this._matched = true; + } + + /** + * Get the full path to the lock file for this package. + * Looks in the workspace root directory (or the package directory for independent packages). + */ + public getLockFilePath(): string | undefined { + const directory = this.releaseGroupObj + ? this.releaseGroupObj.workspace.directory + : this.directory; + const lockFileNames = ["pnpm-lock.yaml", "yarn.lock", "package-lock.json"]; + for (const lockFileName of lockFileNames) { + const full = path.join(directory, lockFileName); + if (existsSync(full)) { + return full; + } + } + return undefined; + } + + /** + * Check if this package's dependencies are installed. + * + * @param print - If true, log errors for missing dependencies. + * @returns true if all dependencies are installed, false otherwise. + */ + public async checkInstall(print: boolean = true): Promise { + const result = await this.inner.checkInstall(); + if (result === true) { + return true; + } + if (print) { + for (const message of result) { + error(`${this.nameColored}: ${message}`); + } + } + return false; + } +} diff --git a/build-tools/packages/build-tools/src/fluidBuild/fluidRepoBuild.ts b/build-tools/packages/build-tools/src/fluidBuild/fluidRepoBuild.ts index 325a26856c66..143ea18069db 100644 --- a/build-tools/packages/build-tools/src/fluidBuild/fluidRepoBuild.ts +++ b/build-tools/packages/build-tools/src/fluidBuild/fluidRepoBuild.ts @@ -5,19 +5,22 @@ import { existsSync } from "node:fs"; import * as path from "node:path"; + import registerDebug from "debug"; import chalk from "picocolors"; import { defaultLogger } from "../common/logging"; -import { MonoRepo } from "../common/monoRepo"; -import { type Package, Packages } from "../common/npmPackage"; -import { type ExecAsyncResult, isSameFileOrDir, lookUpDirSync } from "../common/utils"; +import { + execWithErrorAsync, + isSameFileOrDir, + lookUpDirSync, + rimrafWithErrorAsync, +} from "../common/utils"; import type { BuildContext } from "./buildContext"; import { BuildGraph } from "./buildGraph"; -import { FluidRepo } from "./fluidRepo"; +import type { BuildInfraProject, BuildInfraReleaseGroup } from "./buildInfraTypes"; +import { FluidBuildPackage } from "./fluidBuildPackage"; import { getFluidBuildConfig } from "./fluidUtils"; -import { NpmDepChecker } from "./npmDepChecker"; -import { globFn } from "./tasks/taskUtils"; const traceInit = registerDebug("fluid-build:init"); @@ -30,24 +33,94 @@ export interface IPackageMatchedOptions { releaseGroups: string[]; } -export class FluidRepoBuild extends FluidRepo { - public constructor(protected context: BuildContext) { - super(context.repoRoot, context.fluidBuildConfig.repoPackages); +export class FluidRepoBuild { + public readonly packages: FluidBuildPackage[]; + private readonly packagesByName: Map; + private readonly releaseGroupsByName: Map; + + public constructor( + public readonly buildProject: BuildInfraProject, + protected context: BuildContext, + ) { + this.releaseGroupsByName = new Map(); + for (const [rgName, rg] of buildProject.releaseGroups) { + this.releaseGroupsByName.set(rgName, rg); + } + + this.packagesByName = new Map(); + this.packages = []; + for (const [, pkg] of buildProject.packages) { + const releaseGroupObj = this.releaseGroupsByName.get(pkg.releaseGroup); + const fbPkg = new FluidBuildPackage(pkg, releaseGroupObj); + this.packages.push(fbPkg); + this.packagesByName.set(fbPkg.name, fbPkg); + } + } + + public get resolvedRoot(): string { + return this.buildProject.root; } public async clean(): Promise { - return Packages.clean(this.packages.packages, false); + const cleanPromises: Promise<{ error?: unknown }>[] = []; + for (const pkg of this.packages) { + const cleanScript = pkg.getScript("clean"); + if (cleanScript) { + cleanPromises.push( + execWithErrorAsync( + cleanScript, + { + cwd: pkg.directory, + env: { + PATH: `${process.env["PATH"]}${path.delimiter}${path.join( + pkg.directory, + "node_modules", + ".bin", + )}`, + }, + }, + pkg.nameColored, + ), + ); + } + } + const results = await Promise.all(cleanPromises); + return !results.some((result) => result.error); } public async uninstall(): Promise { - const cleanPackageNodeModules = this.packages.cleanNodeModules(); - const removePromise: Promise[] = []; - for (const g of this.releaseGroups.values()) { - removePromise.push(g.uninstall()); + const removePromises: Promise<{ error?: unknown }>[] = []; + + // Remove node_modules for each package + for (const pkg of this.packages) { + removePromises.push( + rimrafWithErrorAsync(path.join(pkg.directory, "node_modules"), pkg.nameColored), + ); } - const r = await Promise.all([cleanPackageNodeModules, Promise.all(removePromise)]); - return r[0] && !r[1].some((ret) => ret?.error); + // Remove node_modules for each workspace root + for (const [, workspace] of this.buildProject.workspaces) { + removePromises.push( + rimrafWithErrorAsync(path.join(workspace.directory, "node_modules"), workspace.name), + ); + } + + const results = await Promise.all(removePromises); + return !results.some((result) => result.error); + } + + public async install(): Promise { + const installedWorkspaces = new Set(); + for (const [, workspace] of this.buildProject.workspaces) { + if (!installedWorkspaces.has(workspace.directory)) { + installedWorkspaces.add(workspace.directory); + const success = await workspace.install(true); + if (!success) { + return false; + } + } + } + return true; } public setMatched(options: IPackageMatchedOptions): boolean { @@ -69,7 +142,7 @@ export class FluidRepoBuild extends FluidRepo { }); options.releaseGroups.forEach((releaseGroupName) => { - const releaseGroup = this.releaseGroups.get(releaseGroupName); + const releaseGroup = this.releaseGroupsByName.get(releaseGroupName); if (releaseGroup === undefined) { throw new Error( `Release group '${releaseGroupName}' specified is not defined in the repo.`, @@ -90,32 +163,6 @@ export class FluidRepoBuild extends FluidRepo { return true; } - /** - * @deprecated depcheck-related functionality will be removed in an upcoming release. - */ - public async depcheck(fix: boolean): Promise { - for (const pkg of this.packages.packages) { - // Fluid specific - let checkFiles: string[]; - if (pkg.packageJson.dependencies) { - const tsFiles = await globFn(`${pkg.directory}/**/*.ts`, { - ignore: `${pkg.directory}/node_modules/**`, - }); - const tsxFiles = await globFn(`${pkg.directory}/**/*.tsx`, { - ignore: `${pkg.directory}/node_modules/**`, - }); - checkFiles = tsFiles.concat(tsxFiles); - } else { - checkFiles = []; - } - - const npmDepChecker = new NpmDepChecker(pkg, checkFiles); - if (await npmDepChecker.run(fix)) { - await pkg.savePackageJson(); - } - } - } - public createBuildGraph(buildTargetNames: string[]): BuildGraph { return new BuildGraph( this.createPackageMap(), @@ -123,25 +170,38 @@ export class FluidRepoBuild extends FluidRepo { this.context, buildTargetNames, getFluidBuildConfig(this.resolvedRoot)?.tasks, - (pkg: Package) => { - return (dep: Package) => { - return MonoRepo.isSame(pkg.monoRepo, dep.monoRepo); + (pkg: FluidBuildPackage) => { + return (dep: FluidBuildPackage) => { + return pkg.releaseGroup === dep.releaseGroup; }; }, ); } - private getReleaseGroupPackages(): Package[] { - const releaseGroupPackages: Package[] = []; - for (const releaseGroup of this.releaseGroups.values()) { - releaseGroupPackages.push(releaseGroup.pkg); + public createPackageMap(): Map { + return new Map(this.packagesByName); + } + + public relativeToRepo(p: string): string { + return path.relative(this.resolvedRoot, p).replace(/\\/g, "/"); + } + + private getReleaseGroupPackages(): FluidBuildPackage[] { + const releaseGroupPackages: FluidBuildPackage[] = []; + for (const [, releaseGroup] of this.releaseGroupsByName) { + if (releaseGroup.rootPackage) { + const fbPkg = this.packagesByName.get(releaseGroup.rootPackage.name); + if (fbPkg) { + releaseGroupPackages.push(fbPkg); + } + } } return releaseGroupPackages; } - private matchWithFilter(callback: (pkg: Package) => boolean): boolean { + private matchWithFilter(callback: (pkg: FluidBuildPackage) => boolean): boolean { let matched = false; - this.packages.packages.forEach((pkg) => { + this.packages.forEach((pkg) => { if (!pkg.matched && callback(pkg)) { this.setMatchedPackage(pkg); matched = true; @@ -158,43 +218,46 @@ export class FluidRepoBuild extends FluidRepo { throw new Error(`Unable to look up package in directory '${dir}'.`); } - for (const releaseGroup of this.releaseGroups.values()) { - if (isSameFileOrDir(releaseGroup.repoPath, pkgDir)) { + for (const [, releaseGroup] of this.releaseGroupsByName) { + if (isSameFileOrDir(releaseGroup.workspace.directory, pkgDir)) { log( - `Release group ${chalk.cyanBright(releaseGroup.kind)} matched (directory: ${dir})`, + `Release group ${chalk.cyanBright(releaseGroup.name)} matched (directory: ${dir})`, ); this.setMatchedReleaseGroup(releaseGroup); return; } } - const foundPackage = this.packages.packages.find((pkg) => - isSameFileOrDir(pkg.directory, pkgDir), - ); + const foundPackage = this.packages.find((pkg) => isSameFileOrDir(pkg.directory, pkgDir)); if (foundPackage === undefined) { throw new Error( `Package in '${pkgDir}' not part of the Fluid repo '${this.resolvedRoot}'.`, ); } - if (matchReleaseGroup && foundPackage.monoRepo !== undefined) { + if (matchReleaseGroup && foundPackage.releaseGroupObj !== undefined) { log( `\tRelease group ${chalk.cyanBright( - foundPackage.monoRepo.kind, + foundPackage.releaseGroup, )} matched (directory: ${dir})`, ); - this.setMatchedReleaseGroup(foundPackage.monoRepo); + this.setMatchedReleaseGroup(foundPackage.releaseGroupObj); } else { log(`\t${foundPackage.nameColored} matched (${dir})`); this.setMatchedPackage(foundPackage); } } - private setMatchedReleaseGroup(monoRepo: MonoRepo): void { - this.setMatchedPackage(monoRepo.pkg); + private setMatchedReleaseGroup(releaseGroup: BuildInfraReleaseGroup): void { + if (releaseGroup.rootPackage) { + const rootPkg = this.packagesByName.get(releaseGroup.rootPackage.name); + if (rootPkg) { + this.setMatchedPackage(rootPkg); + } + } } - private setMatchedPackage(pkg: Package): void { + private setMatchedPackage(pkg: FluidBuildPackage): void { traceInit(`${pkg.nameColored}: matched`); pkg.setMatched(); } diff --git a/build-tools/packages/build-tools/src/fluidBuild/tasks/leaf/flubTasks.ts b/build-tools/packages/build-tools/src/fluidBuild/tasks/leaf/flubTasks.ts index abb4a0558d27..76416b7797fe 100644 --- a/build-tools/packages/build-tools/src/fluidBuild/tasks/leaf/flubTasks.ts +++ b/build-tools/packages/build-tools/src/fluidBuild/tasks/leaf/flubTasks.ts @@ -30,7 +30,7 @@ export class FlubListTask extends LeafWithDoneFileTask { return undefined; } const packages = Array.from(this.node.context.repoPackageMap.values()).filter( - (pkg) => pkg.monoRepo?.kind === resourceGroup, + (pkg) => pkg.releaseGroup === resourceGroup, ); if (packages.length === 0) { return undefined; diff --git a/build-tools/packages/build-tools/src/fluidBuild/tasks/leaf/generateEntrypointsTask.ts b/build-tools/packages/build-tools/src/fluidBuild/tasks/leaf/generateEntrypointsTask.ts index aa50d08d57a9..71f9a5ea1099 100644 --- a/build-tools/packages/build-tools/src/fluidBuild/tasks/leaf/generateEntrypointsTask.ts +++ b/build-tools/packages/build-tools/src/fluidBuild/tasks/leaf/generateEntrypointsTask.ts @@ -9,7 +9,7 @@ import { TscDependentTask } from "./tscTask"; export class GenerateEntrypointsTask extends TscDependentTask { protected get taskSpecificConfigFiles(): string[] { // Add package.json, which tsc should also depend on, but currently doesn't. - return [this.node.pkg.packageJsonFileName]; + return [this.node.pkg.packageJsonFilePath]; } protected async getToolVersion(): Promise { diff --git a/build-tools/packages/build-tools/src/fluidBuild/tasks/task.ts b/build-tools/packages/build-tools/src/fluidBuild/tasks/task.ts index d2ed28a6fa3f..a639b3a54b54 100644 --- a/build-tools/packages/build-tools/src/fluidBuild/tasks/task.ts +++ b/build-tools/packages/build-tools/src/fluidBuild/tasks/task.ts @@ -6,9 +6,9 @@ import * as assert from "assert"; import { type AsyncPriorityQueue, priorityQueue } from "async"; import registerDebug from "debug"; -import type { Package } from "../../common/npmPackage"; import type { BuildContext } from "../buildContext"; import type { BuildPackage } from "../buildGraph"; +import type { FluidBuildPackage } from "../fluidBuildPackage"; import { BuildResult } from "../buildResult"; import { options } from "../options"; import type { LeafTask } from "./leaf/leafTask"; @@ -64,7 +64,7 @@ export abstract class Task { } } - public get package(): Package { + public get package(): FluidBuildPackage { return this.node.pkg; } diff --git a/build-tools/packages/build-tools/src/index.ts b/build-tools/packages/build-tools/src/index.ts index 9f99e083b35d..77657f22f20e 100644 --- a/build-tools/packages/build-tools/src/index.ts +++ b/build-tools/packages/build-tools/src/index.ts @@ -24,8 +24,10 @@ export type { } from "./common/typeCompatibility"; export { getTypeTestPreviousPackageDetails } from "./common/typeTests"; export { type IFluidBuildConfig } from "./fluidBuild/fluidBuildConfig"; +export { FluidBuildPackage } from "./fluidBuild/fluidBuildPackage"; export { type IFluidCompatibilityMetadata } from "./fluidBuild/fluidCompatMetadata"; export { FluidRepo } from "./fluidBuild/fluidRepo"; +export { FluidRepoBuild, type IPackageMatchedOptions } from "./fluidBuild/fluidRepoBuild"; // For repo policy check export { getTaskDefinitions, diff --git a/build-tools/pnpm-lock.yaml b/build-tools/pnpm-lock.yaml index b7340b271981..e5021a9757cc 100644 --- a/build-tools/pnpm-lock.yaml +++ b/build-tools/pnpm-lock.yaml @@ -537,6 +537,9 @@ importers: packages/build-tools: dependencies: + '@fluid-tools/build-infrastructure': + specifier: workspace:~ + version: link:../build-infrastructure '@fluid-tools/version-tools': specifier: workspace:~ version: link:../version-tools