Skip to content

Commit b7f1e26

Browse files
thekhegayalan-agius4
authored andcommitted
fix(@angular/cli): explain how to pick a project when a schematic cannot determine one
when project can't be resolved from cwd, like at root of workspace with no root project, schematics fail with raw `Data path "" must have required property 'project'` from schema validation now that error is replaced with message that says to pass `--project` or cd into project and lists available projects like architect commands do, empty workspace gets hint to generate application or libary first Fixes #25402
1 parent 125bbe0 commit b7f1e26

3 files changed

Lines changed: 216 additions & 3 deletions

File tree

‎packages/angular/cli/src/command-builder/schematics-command-module.ts‎

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,15 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import { JsonValue, normalize as devkitNormalize, schema } from '@angular-devkit/core';
9+
import {
10+
JsonObject,
11+
JsonValue,
12+
normalize as devkitNormalize,
13+
isJsonArray,
14+
isJsonObject,
15+
schema,
16+
strings,
17+
} from '@angular-devkit/core';
1018
import { Collection, UnsuccessfulWorkflowExecution, formats } from '@angular-devkit/schematics';
1119
import {
1220
FileSystemCollectionDescription,
@@ -17,13 +25,14 @@ import { relative } from 'node:path';
1725
import { Argv } from 'yargs';
1826
import { isPackageNameSafeForAnalytics } from '../analytics/analytics';
1927
import { EventCustomDimension } from '../analytics/analytics-parameters';
20-
import { getProjectByCwd, getSchematicDefaults } from '../utilities/config';
28+
import { AngularWorkspace, getProjectByCwd, getSchematicDefaults } from '../utilities/config';
2129
import { assertIsError } from '../utilities/error';
2230
import { memoize } from '../utilities/memoize';
2331
import { formatFiles } from '../utilities/prettier';
2432
import { isTTY } from '../utilities/tty';
2533
import {
2634
CommandModule,
35+
CommandModuleError,
2736
CommandModuleImplementation,
2837
CommandScope,
2938
Options,
@@ -131,6 +140,20 @@ export abstract class SchematicsCommandModule
131140
...current,
132141
};
133142
},
143+
// Fail before smart defaults and prompts are applied when a required option relies on the
144+
// `projectName` smart default and the project cannot be determined.
145+
(schematic, current) => {
146+
const { workspace } = this.context;
147+
const message =
148+
workspace && this.getProjectName() === undefined
149+
? getMissingProjectMessage(workspace, schematic.schemaJson, current)
150+
: undefined;
151+
if (message) {
152+
throw new CommandModuleError(message);
153+
}
154+
155+
return current ?? {};
156+
},
134157
],
135158
engineHostCreator: (options) => new SchematicEngineHost(options.resolvePaths),
136159
});
@@ -427,3 +450,60 @@ export abstract class SchematicsCommandModule
427450
[import.meta.dirname, process.cwd()];
428451
}
429452
}
453+
454+
/**
455+
* Returns an error message when a schematic requires an option that uses the `projectName` smart
456+
* default, the option has no value and the project cannot be determined from the current working
457+
* directory. Otherwise schema validation fails later with a generic "must have required property"
458+
* error, after prompting for other options.
459+
*/
460+
export function getMissingProjectMessage(
461+
workspace: AngularWorkspace,
462+
schemaJson: JsonObject | undefined,
463+
options: Record<string, unknown> | null,
464+
): string | undefined {
465+
if (!schemaJson) {
466+
return undefined;
467+
}
468+
469+
const { properties, required } = schemaJson;
470+
if (!isJsonObject(properties) || !isJsonArray(required)) {
471+
return undefined;
472+
}
473+
474+
const optionName = Object.keys(properties).find((name) => {
475+
const property = properties[name];
476+
477+
return (
478+
required.includes(name) &&
479+
options?.[name] === undefined &&
480+
isJsonObject(property) &&
481+
isJsonObject(property.$default) &&
482+
property.$default.$source === 'projectName' &&
483+
property['x-prompt'] === undefined
484+
);
485+
});
486+
487+
if (optionName === undefined) {
488+
return undefined;
489+
}
490+
491+
if (workspace.projects.size === 0) {
492+
return (
493+
'Cannot determine project for command.\n' +
494+
'This workspace does not contain any projects. ' +
495+
'Run "ng generate application" or "ng generate library" to create one.'
496+
);
497+
}
498+
499+
return (
500+
'Cannot determine project for command.\n' +
501+
'This is a multi-project workspace and the current working directory is not inside a single ' +
502+
`project. Use the "--${strings.dasherize(optionName)}" option to specify a project or change ` +
503+
'the current working directory to a project directory.\n\n' +
504+
`Available projects are:\n${[...workspace.projects.keys()]
505+
.sort()
506+
.map((p) => `- ${p}`)
507+
.join('\n')}`
508+
);
509+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { JsonObject, workspaces } from '@angular-devkit/core';
10+
import { AngularWorkspace } from '../utilities/config';
11+
import { getMissingProjectMessage } from './schematics-command-module';
12+
13+
describe('getMissingProjectMessage', () => {
14+
const projectOption = { type: 'string', $default: { $source: 'projectName' } };
15+
const schemaJson: JsonObject = {
16+
type: 'object',
17+
properties: { name: { type: 'string' }, project: projectOption },
18+
required: ['name', 'project'],
19+
};
20+
21+
function createWorkspace(...projectNames: string[]): AngularWorkspace {
22+
const projects = new workspaces.ProjectDefinitionCollection();
23+
for (const name of projectNames) {
24+
projects.add({ name, root: `projects/${name}` });
25+
}
26+
27+
return new AngularWorkspace({ extensions: {}, projects }, '/angular.json');
28+
}
29+
30+
it('should list the available projects when the project cannot be determined', () => {
31+
expect(getMissingProjectMessage(createWorkspace('second', 'first'), schemaJson, {})).toBe(
32+
'Cannot determine project for command.\n' +
33+
'This is a multi-project workspace and the current working directory is not inside a ' +
34+
'single project. Use the "--project" option to specify a project or change the current ' +
35+
'working directory to a project directory.\n\n' +
36+
'Available projects are:\n- first\n- second',
37+
);
38+
});
39+
40+
it('should explain how to create a project when the workspace has none', () => {
41+
expect(getMissingProjectMessage(createWorkspace(), schemaJson, {})).toBe(
42+
'Cannot determine project for command.\n' +
43+
'This workspace does not contain any projects. ' +
44+
'Run "ng generate application" or "ng generate library" to create one.',
45+
);
46+
});
47+
48+
it('should use the name of the option that relies on the smart default', () => {
49+
const message = getMissingProjectMessage(
50+
createWorkspace('first', 'second'),
51+
{ properties: { targetProject: projectOption }, required: ['targetProject'] },
52+
{},
53+
);
54+
55+
expect(message).toContain('Use the "--target-project" option');
56+
});
57+
58+
it('should not return a message when the option has a value', () => {
59+
expect(
60+
getMissingProjectMessage(createWorkspace('first', 'second'), schemaJson, {
61+
project: 'first',
62+
}),
63+
).toBeUndefined();
64+
});
65+
66+
it('should not return a message when the option is not required', () => {
67+
expect(
68+
getMissingProjectMessage(
69+
createWorkspace('first', 'second'),
70+
{ ...schemaJson, required: ['name'] },
71+
{},
72+
),
73+
).toBeUndefined();
74+
});
75+
76+
it('should not return a message when the option does not use the smart default', () => {
77+
expect(
78+
getMissingProjectMessage(
79+
createWorkspace('first', 'second'),
80+
{ ...schemaJson, properties: { project: { type: 'string' } } },
81+
{},
82+
),
83+
).toBeUndefined();
84+
});
85+
86+
it('should not return a message when the option can be prompted for', () => {
87+
expect(
88+
getMissingProjectMessage(
89+
createWorkspace('first', 'second'),
90+
{
91+
...schemaJson,
92+
properties: { project: { ...projectOption, 'x-prompt': 'Which project?' } },
93+
},
94+
{},
95+
),
96+
).toBeUndefined();
97+
});
98+
});

‎tests/e2e/tests/commands/project-cannot-be-determined-by-cwd.ts‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import assert from 'node:assert/strict';
22
import { join } from 'node:path';
3-
import { execAndWaitForOutputToMatch, ng } from '../../utils/process';
3+
import { execAndCaptureError, execAndWaitForOutputToMatch, ng } from '../../utils/process';
44
import { updateJsonFile } from '../../utils/project';
55
import { expectToFail } from '../../utils/utils';
66

@@ -23,6 +23,28 @@ export default async function () {
2323
const { message } = await expectToFail(() => ng('build'));
2424
assert.match(message, new RegExp(errorMessage));
2525

26+
// Schematics that require a project should explain how to select one
27+
const { message: generateMessage } = await expectToFail(() =>
28+
ng('generate', 'component', 'foo'),
29+
);
30+
assert.match(generateMessage, /Use the "--project" option to specify a project/);
31+
assert.match(generateMessage, /Available projects are:\n- second-app\n- third-app/);
32+
33+
// The project is checked before prompting for other options
34+
const { message: interactiveMessage } = await execAndCaptureError(
35+
'ng',
36+
['generate', 'component'],
37+
{
38+
...process.env,
39+
NG_FORCE_TTY: '1',
40+
NG_CLI_ANALYTICS: 'false',
41+
NG_FORCE_AUTOCOMPLETE: 'false',
42+
},
43+
'foo\n',
44+
);
45+
assert.match(interactiveMessage, /Use the "--project" option to specify a project/);
46+
assert.doesNotMatch(interactiveMessage, /What name would you like to use for the component/);
47+
2648
// Help should still work
2749
await execAndWaitForOutputToMatch('ng', ['build', '--help'], /--configuration/);
2850

@@ -31,8 +53,21 @@ export default async function () {
3153

3254
process.chdir(join(startCwd, 'projects/second-app'));
3355
await ng('build', '--configuration=development');
56+
await ng('generate', 'component', 'foo', '--dry-run');
3457
} finally {
3558
// Restore path
3659
process.chdir(startCwd);
3760
}
61+
62+
// A project from the workspace schematic options is used and other validation errors are kept
63+
await updateJsonFile('angular.json', (workspaceJson) => {
64+
workspaceJson.schematics ??= {};
65+
workspaceJson.schematics['@schematics/angular:component'] = { project: 'second-app' };
66+
});
67+
const { message: selectorMessage } = await expectToFail(() =>
68+
ng('generate', 'component', 'foo', '--selector=1foo'),
69+
);
70+
assert.match(selectorMessage, /must match format "html-selector"/);
71+
assert.doesNotMatch(selectorMessage, /Cannot determine project/);
72+
await ng('generate', 'component', 'foo', '--dry-run');
3873
}

0 commit comments

Comments
 (0)