diff --git a/packages/angular/cli/src/command-builder/schematics-command-module.ts b/packages/angular/cli/src/command-builder/schematics-command-module.ts index fa67597777ae..2e02dd4fecfe 100644 --- a/packages/angular/cli/src/command-builder/schematics-command-module.ts +++ b/packages/angular/cli/src/command-builder/schematics-command-module.ts @@ -17,7 +17,11 @@ import { relative } from 'node:path'; import { Argv } from 'yargs'; import { isPackageNameSafeForAnalytics } from '../analytics/analytics'; import { EventCustomDimension } from '../analytics/analytics-parameters'; -import { getProjectByCwd, getSchematicDefaults } from '../utilities/config'; +import { + getProjectByCwd, + getSchematicDefaults, + getSchematicWorkingDirectory, +} from '../utilities/config'; import { assertIsError } from '../utilities/error'; import { formatFiles } from '../utilities/formatting'; import { memoize } from '../utilities/memoize'; @@ -138,10 +142,14 @@ export abstract class SchematicsCommandModule workflow.registry.useXDeprecatedProvider((msg) => logger.warn(msg)); workflow.registry.addSmartDefaultProvider('projectName', () => this.getProjectName()); - const workingDir = devkitNormalize(relative(this.context.root, process.cwd())); - workflow.registry.addSmartDefaultProvider('workingDirectory', () => - workingDir === '' ? undefined : workingDir, - ); + const workingDirectory = this.context.workspace + ? getSchematicWorkingDirectory(this.context.workspace) + : (() => { + const dir = devkitNormalize(relative(root, process.cwd())); + + return dir === '' ? undefined : dir; + })(); + workflow.registry.addSmartDefaultProvider('workingDirectory', () => workingDirectory); workflow.engineHost.registerOptionsTransform(async (schematic, options) => { const { diff --git a/packages/angular/cli/src/utilities/config.ts b/packages/angular/cli/src/utilities/config.ts index 6aa762aa264f..1d9e2249b5a1 100644 --- a/packages/angular/cli/src/utilities/config.ts +++ b/packages/angular/cli/src/utilities/config.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import { json, workspaces } from '@angular-devkit/core'; +import { json, normalize as devkitNormalize, workspaces } from '@angular-devkit/core'; import { existsSync, promises as fs } from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -323,6 +323,35 @@ export function getProjectByCwd(workspace: AngularWorkspace): string | null { return null; } +/** + * Resolves the schematic `workingDirectory` smart default relative to the active project root. + * When the cwd is the project root, `undefined` is returned so schematics can apply their own + * default path (e.g. `src/app`). + */ +export function getSchematicWorkingDirectory( + workspace: AngularWorkspace, + cwd: string = process.cwd(), +): string | undefined { + const projectName = + workspace.projects.size === 1 + ? Array.from(workspace.projects.keys())[0] + : findProjectByPath(workspace, cwd); + + if (projectName) { + const project = workspace.projects.get(projectName); + if (project) { + const projectRoot = path.join(workspace.basePath, project.root); + const projectRelative = devkitNormalize(path.relative(projectRoot, cwd)); + + return projectRelative === '' ? undefined : projectRelative; + } + } + + const workspaceRelative = devkitNormalize(path.relative(workspace.basePath, cwd)); + + return workspaceRelative === '' ? undefined : workspaceRelative; +} + export async function getConfiguredPackageManager(): Promise { const getPackageManager = (source: json.JsonValue | undefined): PackageManager | null => { if (isJsonObject(source)) { diff --git a/packages/angular/cli/src/utilities/schematic-working-directory_spec.ts b/packages/angular/cli/src/utilities/schematic-working-directory_spec.ts new file mode 100644 index 000000000000..3820e2bdbb23 --- /dev/null +++ b/packages/angular/cli/src/utilities/schematic-working-directory_spec.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { workspaces } from '@angular-devkit/core'; +import { join, resolve } from 'node:path'; +import { AngularWorkspace, getSchematicWorkingDirectory } from './config'; + +describe('getSchematicWorkingDirectory', () => { + const workspaceRoot = resolve('/tmp/workspace'); + + function mockWorkspace( + projects: Record, + ): AngularWorkspace { + const collection = new workspaces.ProjectDefinitionCollection(); + for (const [name, { root }] of Object.entries(projects)) { + collection.set(name, { root } as workspaces.ProjectDefinition); + } + + return { + basePath: workspaceRoot, + projects: collection, + } as unknown as AngularWorkspace; + } + + it('returns undefined when cwd is a multi-project application root', () => { + const workspace = mockWorkspace({ + app1: { root: 'projects/app1' }, + app2: { root: 'projects/app2' }, + }); + const cwd = join(workspaceRoot, 'projects/app1'); + + expect(getSchematicWorkingDirectory(workspace, cwd)).toBeUndefined(); + }); + + it('returns a path relative to the project when cwd is inside the project tree', () => { + const workspace = mockWorkspace({ + app1: { root: 'projects/app1' }, + app2: { root: 'projects/app2' }, + }); + const cwd = join(workspaceRoot, 'projects/app1/src/app/features'); + + expect(getSchematicWorkingDirectory(workspace, cwd)).toBe('src/app/features'); + }); + + it('returns undefined for a single-project workspace when cwd is the workspace root', () => { + const workspace = mockWorkspace({ + app: { root: '' }, + }); + + expect(getSchematicWorkingDirectory(workspace, workspaceRoot)).toBeUndefined(); + }); +});