Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down
31 changes: 30 additions & 1 deletion packages/angular/cli/src/utilities/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Comment on lines +344 to +346

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

In a single-project workspace where the project root is not the workspace root (e.g., projects/app), if the command is run from the workspace root, workspace.projects.size === 1 will eagerly select the single project. This causes path.relative(projectRoot, cwd) to resolve to a path escaping the project root (e.g., ../..). Returning this relative path will cause schematics to generate files outside the project root (at the workspace root).

To prevent this, we should check if the resolved relative path starts with .. or is absolute, and return undefined in those cases so that schematics fall back to their default paths.

      const projectRelative = devkitNormalize(path.relative(projectRoot, cwd));
      if (projectRelative.startsWith('..') || path.isAbsolute(projectRelative)) {
        return undefined;
      }

      return projectRelative === '' ? undefined : projectRelative;

}
}

const workspaceRelative = devkitNormalize(path.relative(workspace.basePath, cwd));

return workspaceRelative === '' ? undefined : workspaceRelative;
Comment on lines +350 to +352

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Similarly, if cwd is outside the workspace base path, workspaceRelative could start with .. or be absolute. We should guard against this to ensure we only return paths within the workspace.

  const workspaceRelative = devkitNormalize(path.relative(workspace.basePath, cwd));
  if (workspaceRelative.startsWith('..') || path.isAbsolute(workspaceRelative)) {
    return undefined;
  }

  return workspaceRelative === '' ? undefined : workspaceRelative;

}

export async function getConfiguredPackageManager(): Promise<PackageManager | null> {
const getPackageManager = (source: json.JsonValue | undefined): PackageManager | null => {
if (isJsonObject(source)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, { root: string }>,
): 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();
});
Comment on lines +50 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Add a unit test to verify that getSchematicWorkingDirectory returns undefined when the single project has a non-empty root and the command is run from the workspace root (outside the project root).

  it('returns undefined for a single-project workspace when cwd is the workspace root', () => {
    const workspace = mockWorkspace({
      app: { root: '' },
    });

    expect(getSchematicWorkingDirectory(workspace, workspaceRoot)).toBeUndefined();
  });

  it('returns undefined for a single-project workspace when cwd is outside the project root', () => {
    const workspace = mockWorkspace({
      app: { root: 'projects/app' },
    });

    expect(getSchematicWorkingDirectory(workspace, workspaceRoot)).toBeUndefined();
  });

});
Loading