docs: update CLI builder to be platform agnostic (#42371)

Fixes #35373.

This changes the example from "run an arbitrary process" to "copy a file". This should make it a bit easier to follow, require less background knowledge to understand, and not use any platform-specific commands that won't work for Windows users.

The most glaring issue with this change is that this doc does not explictly specify how to build and run a builder. I've updated some of the files to hint at this a bit more (such as the `"implementation": "./dist/my-builder.js"`), but another pass is required to figure out the best way to compile a builder and how we want to structure this example to best communicate that.

PR Close #42371
This commit is contained in:
Doug Parker 2021-05-26 14:56:27 -07:00 committed by Andrew Kushnir
parent 2a311c51e6
commit e0381a87c9
3 changed files with 88 additions and 92 deletions

View File

@ -1,9 +1,10 @@
// #docregion // #docregion
import { Architect } from '@angular-devkit/architect'; import { Architect } from '@angular-devkit/architect';
import { TestingArchitectHost } from '@angular-devkit/architect/testing'; import { TestingArchitectHost } from '@angular-devkit/architect/testing';
import { logging, schema } from '@angular-devkit/core'; import { schema } from '@angular-devkit/core';
import { promises as fs } from 'fs';
describe('Command Runner Builder', () => { describe('Copy File Builder', () => {
let architect: Architect; let architect: Architect;
let architectHost: TestingArchitectHost; let architectHost: TestingArchitectHost;
@ -21,17 +22,12 @@ describe('Command Runner Builder', () => {
await architectHost.addBuilderFromPackage('..'); await architectHost.addBuilderFromPackage('..');
}); });
it('can run node', async () => { it('can copy files', async () => {
// Create a logger that keeps an array of all messages that were logged.
const logger = new logging.Logger('');
const logs = [];
logger.subscribe(ev => logs.push(ev.message));
// A "run" can have multiple outputs, and contains progress information. // A "run" can have multiple outputs, and contains progress information.
const run = await architect.scheduleBuilder('@example/command-runner:command', { const run = await architect.scheduleBuilder('@example/copy-file:copy', {
command: 'node', source: 'package.json',
args: ['--print', '\'foo\''], destination: 'package-copy.json',
}, { logger }); // We pass the logger for checking later. });
// The "result" member (of type BuilderOutput) is the next output. // The "result" member (of type BuilderOutput) is the next output.
const output = await run.result; const output = await run.result;
@ -41,8 +37,10 @@ describe('Command Runner Builder', () => {
// to be scheduled. // to be scheduled.
await run.stop(); await run.stop();
// Expect that foo was logged // Expect that the copied file is the same as its source.
expect(logs).toContain('foo'); const sourceContent = await fs.readFile('package.json', 'utf8');
const destinationContent = await fs.readFile('package-copy.json', 'utf8');
expect(destinationContent).toBe(sourceContent);
}); });
}); });
// #enddocregion // #enddocregion

View File

@ -3,44 +3,42 @@
import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect'; import { BuilderContext, BuilderOutput, createBuilder } from '@angular-devkit/architect';
import { JsonObject } from '@angular-devkit/core'; import { JsonObject } from '@angular-devkit/core';
// #enddocregion builder-skeleton // #enddocregion builder-skeleton
import * as childProcess from 'child_process'; import { promises as fs } from 'fs';
// #docregion builder-skeleton // #docregion builder-skeleton
interface Options extends JsonObject { interface Options extends JsonObject {
command: string; source: string;
args: string[]; destination: string;
} }
export default createBuilder(commandBuilder); export default createBuilder(copyFileBuilder);
function commandBuilder( async function copyFileBuilder(
options: Options, options: Options,
context: BuilderContext, context: BuilderContext,
): Promise<BuilderOutput> { ): Promise<BuilderOutput> {
// #enddocregion builder, builder-skeleton, handling-output // #enddocregion builder, builder-skeleton, handling-output
// #docregion report-status // #docregion report-status
context.reportStatus(`Executing "${options.command}"...`); context.reportStatus(`Copying ${options.source} to ${options.destination}.`);
// #docregion builder, handling-output // #docregion builder, handling-output
const child = childProcess.spawn(options.command, options.args); try {
// #enddocregion builder, report-status await fs.copyFile(options.source, options.destination);
} catch (err) {
child.stdout.on('data', data => { // #enddocregion builder
context.logger.info(data.toString()); context.logger.error('Failed to copy file.');
});
child.stderr.on('data', data => {
context.logger.error(data.toString());
});
// #docregion builder // #docregion builder
return new Promise(resolve => { return {
// #enddocregion builder, handling-output success: false,
context.reportStatus(`Done.`); error: err.message,
// #docregion builder, handling-output };
child.on('close', code => { }
resolve({ success: code === 0 });
}); // #enddocregion builder, handling-output
}); context.reportStatus('Done.');
// #docregion builder-skeleton // #docregion builder, handling-output
return { success: true };
// #enddocregion report-status
// #docregion builder-skeleton
} }
// #enddocregion builder, builder-skeleton, handling-output, progress-reporting // #enddocregion builder, builder-skeleton, handling-output, progress-reporting

View File

@ -56,7 +56,7 @@ npm install @example/my-builder
## Creating a builder ## Creating a builder
As an example, let's create a builder that executes a shell command. As an example, let's create a builder that copies a file.
To create a builder, use the `createBuilder()` CLI Builder function, and return a `Promise<BuilderOutput>` object. To create a builder, use the `createBuilder()` CLI Builder function, and return a `Promise<BuilderOutput>` object.
<code-example <code-example
@ -65,9 +65,10 @@ To create a builder, use the `createBuilder()` CLI Builder function, and return
region="builder-skeleton"> region="builder-skeleton">
</code-example> </code-example>
Now lets add some logic to it. Now lets add some logic to it. The following code retrieves the source and destination file paths
The following code retrieves the command and arguments from the user options, spawns the new process, and waits for the process to finish. from user options and copies the file from the source to the destination (leveraging the
If the process is successful (returns a code of 0), it resolves the return value. [Promise version of the built-in NodeJS `copyFile()` function](https://nodejs.org/api/fs.html#fs_fspromises_copyfile_src_dest_mode)).
If the copy operation fails, it returns an error with a message about the underlying problem.
<code-example <code-example
path="cli-builder/src/my-builder.ts" path="cli-builder/src/my-builder.ts"
@ -77,11 +78,13 @@ If the process is successful (returns a code of 0), it resolves the return value
### Handling output ### Handling output
By default, the `spawn()` method outputs everything to the process standard output and error. By default, `copyFile()` does not print anything to the process standard output or error. If an
To make it easier to test and debug, we can forward the output to the CLI Builder logger instead. error occurs, it may be difficult to understand exactly what the builder was trying to do when the
This also allows the builder itself to be executed in a separate process, even if the standard output and error are deactivated (as in an [Electron app](https://electronjs.org/)). problem occurred. We can add some additional context by logging additional information using the
`Logger` API. This also allows the builder itself to be executed in a separate process, even if the
standard output and error are deactivated (as in an [Electron app](https://electronjs.org/)).
We can retrieve a Logger instance from the context. We can retrieve a `Logger` instance from the context.
<code-example <code-example
path="cli-builder/src/my-builder.ts" path="cli-builder/src/my-builder.ts"
@ -99,7 +102,7 @@ The status string is unmodified unless you pass in a new string value.
You can see an [example](https://github.com/angular/angular-cli/blob/ba21c855c0c8b778005df01d4851b5a2176edc6f/packages/angular_devkit/build_angular/src/tslint/index.ts#L107) of how the `tslint` builder reports progress. You can see an [example](https://github.com/angular/angular-cli/blob/ba21c855c0c8b778005df01d4851b5a2176edc6f/packages/angular_devkit/build_angular/src/tslint/index.ts#L107) of how the `tslint` builder reports progress.
In our example, the shell command either finishes or is still executing, so theres no need for a progress report, but we can report status so that a parent builder that called our builder would know whats going on. In our example, the copy operation either finishes or is still executing, so theres no need for a progress report, but we can report status so that a parent builder that called our builder would know whats going on.
Use the `context.reportStatus()` method to generate a status string of any length. Use the `context.reportStatus()` method to generate a status string of any length.
(Note that theres no guarantee that a long string will be shown entirely; it could be cut to fit the UI that displays it.) (Note that theres no guarantee that a long string will be shown entirely; it could be cut to fit the UI that displays it.)
Pass an empty string to remove the status. Pass an empty string to remove the status.
@ -121,23 +124,21 @@ You define builder inputs in a JSON schema associated with that builder.
The Architect tool collects the resolved input values into an `options` object, and validates their types against the schema before passing them to the builder function. The Architect tool collects the resolved input values into an `options` object, and validates their types against the schema before passing them to the builder function.
(The Schematics library does the same kind of validation of user input.) (The Schematics library does the same kind of validation of user input.)
For our example builder, we expect the `options` value to be a `JsonObject` with two keys: a `command` that is a string, and an `args` array of string values. For our example builder, we expect the `options` value to be a `JsonObject` with two keys: a
`source` and a `destination`, each of which are a string.
We can provide the following schema for type validation of these values. We can provide the following schema for type validation of these values.
<code-example language="json" header="command/schema.json"> <code-example language="json" header="src/schema.json">
{ {
"$schema": "http://json-schema.org/schema", "$schema": "http://json-schema.org/schema",
"type": "object", "type": "object",
"properties": { "properties": {
"command": { "source": {
"type": "string" "type": "string"
}, },
"args": { "destination": {
"type": "array", "type": "string"
"items": {
"type": "string"
}
} }
} }
} }
@ -159,10 +160,10 @@ Create a file named `builders.json` that looks like this:
{ {
"builders": { "builders": {
"command": { "copy": {
"implementation": "./command", "implementation": "./dist/my-builder.js",
"schema": "./command/schema.json", "schema": "./src/schema.json",
"description": "Runs any command line in the operating system." "description": "Copies a file."
} }
} }
} }
@ -174,21 +175,22 @@ In the `package.json` file, add a `builders` key that tells the Architect tool w
<code-example language="json" header="package.json"> <code-example language="json" header="package.json">
{ {
"name": "@example/command-runner", "name": "@example/copy-file",
"version": "1.0.0", "version": "1.0.0",
"description": "Builder for Command Runner", "description": "Builder for copying files",
"builders": "builders.json", "builders": "builders.json",
"devDependencies": { "dependencies": {
"@angular-devkit/architect": "^1.0.0" "@angular-devkit/architect": "~0.1200.0",
"@angular-devkit/core": "^12.0.0"
} }
} }
</code-example> </code-example>
The official name of our builder is now ` @example/command-runner:command`. The official name of our builder is now ` @example/copy-file:copy`.
The first part of this is the package name (resolved using node resolution), and the second part is the builder name (resolved using the `builders.json` file). The first part of this is the package name (resolved using node resolution), and the second part is the builder name (resolved using the `builders.json` file).
Using one of our `options` is very straightforward, we did this in the previous section when we accessed `options.command`. Using one of our `options` is very straightforward, we did this in the previous section when we accessed `options.source` and `options.destination`.
<code-example <code-example
path="cli-builder/src/my-builder.ts" path="cli-builder/src/my-builder.ts"
@ -288,7 +290,7 @@ We can publish the builder to npm (see [Publishing your Library](guide/creating-
<code-example language="sh"> <code-example language="sh">
npm install @example/command-runner npm install @example/copy-file
</code-example> </code-example>
@ -333,18 +335,18 @@ If we create a new project with `ng new builder-test`, the generated `angular.js
### Adding a target ### Adding a target
Let's add a new target that will run our builder to execute a particular command. Let's add a new target that will run our builder to copy a file.
This target will tell the builder to run `touch` on a file, in order to update its modified date. This target will tell the builder to copy the `package.json` file.
We need to update the `angular.json` file to add a target for this builder to the "architect" section of our new project. We need to update the `angular.json` file to add a target for this builder to the "architect" section of our new project.
* We'll add a new target section to the "architect" object for our project. * We'll add a new target section to the "architect" object for our project.
* The target named "touch" uses our builder, which we published to `@example/command-runner`. (See [Publishing your Library](guide/creating-libraries#publishing-your-library).) * The target named "copy-package" uses our builder, which we published to `@example/copy-file`. (See [Publishing your Library](guide/creating-libraries#publishing-your-library))
* The options object provides default values for the two inputs that we defined; `command`, which is the Unix command to execute, and `args`, an array that contains the file to operate on. * The options object provides default values for the two inputs that we defined; `source`, which is the existing file we are copying, and `destination`, the path we want to copy to.
* The configurations key is optional, we'll leave it out for now. * The `configurations` key is optional, we'll leave it out for now.
<code-example language="json" header="angular.json"> <code-example language="json" header="angular.json">
@ -352,13 +354,11 @@ We need to update the `angular.json` file to add a target for this builder to th
"projects": { "projects": {
"builder-test": { "builder-test": {
"architect": { "architect": {
"touch": { "copy-package": {
"builder": "@example/command-runner:command", "builder": "@example/copy-file:copy",
"options": { "options": {
"command": "touch", "source": "package.json",
"args": [ "destination": "package-copy.json"
"src/main.ts"
]
} }
}, },
"build": { "build": {
@ -393,27 +393,27 @@ We need to update the `angular.json` file to add a target for this builder to th
### Running the builder ### Running the builder
To run our builder with the new target's default configuration, use the following CLI command in a Linux shell. To run our builder with the new target's default configuration, use the following CLI command.
<code-example language="sh"> <code-example language="sh">
ng run builder-test:touch ng run builder-test:copy-package
</code-example> </code-example>
This will run the `touch` command on the `src/main.ts` file. This will copy the `package.json` file to `package-copy.json`.
You can use command-line arguments to override the configured defaults. You can use command-line arguments to override the configured defaults.
For example, to run with a different `command` value, use the following CLI command. For example, to run with a different `destination` value, use the following CLI command.
<code-example language="sh"> <code-example language="sh">
ng run builder-test:touch --command=ls ng run builder-test:copy-package --destination=package-other.json
</code-example> </code-example>
This will call the `ls` command instead of the `touch` command. This will copy the file to `package-other.json` instead of `package-copy.json`.
Because we did not override the *args* option, it will list information about the `src/main.ts` file (the default value provided for the target). Because we did not override the *source* option, it will copy from the `package.json` file (the default value provided for the target).
## Testing a builder ## Testing a builder
@ -421,10 +421,10 @@ Use integration testing for your builder, so that you can use the Architect sche
* In the builder source directory, we have created a new test file `my-builder.spec.ts`. The code creates new instances of `JsonSchemaRegistry` (for schema validation), `TestingArchitectHost` (an in-memory implementation of `ArchitectHost`), and `Architect`. * In the builder source directory, we have created a new test file `my-builder.spec.ts`. The code creates new instances of `JsonSchemaRegistry` (for schema validation), `TestingArchitectHost` (an in-memory implementation of `ArchitectHost`), and `Architect`.
* We've added a `builders.json` file next to the builder's [`package.json` file](https://github.com/mgechev/cli-builders-demo/blob/master/command-builder/builders.json), and modified the package file to point to it. * We've added a `builders.json` file next to the builder's `package.json` file, and modified the package file to point to it.
Heres an example of a test that runs the command builder. Heres an example of a test that runs the copy file builder.
The test uses the builder to run the `node --print 'foo'` command, then validates that the `logger` contains an entry for `foo`. The test uses the builder to copy the `package.json` file and validates that the copied file's contents are the same as the source.
<code-example <code-example
path="cli-builder/src/my-builder.spec.ts" path="cli-builder/src/my-builder.spec.ts"