Building the command
Once you have correctly set up your local environment, you can run npm run watch
in the background to test your changes live. This command enables live reloading, allowing you to see the effects of your modifications in real-time as you make edits to the code. This is especially useful for quickly iterating and verifying the behavior of your changes before finalizing them.
To start writing the logic for the command, you will need to create a new TypeScript file. All the command services can be found in the folder src/m365
. Here you will find all the services available within the CLI for Microsoft 365. Each service contains a subfolder named commands
where all the service commands are located. For our example issue, mentioned here, that will be src/m365/spo/commands
.
When the service has a lot of commands within the commands
folder, they will be split up into subfolders. For example, src/m365/spo/commands/group
.
When you are in the correct folder, you can create two new files: your command file group-get.ts
and a file for the unit tests group-get.spec.ts
.
Minimum command file
With our two new files created, we can start working on our group-get.ts
file. Each command in the CLI for Microsoft 365 is defined as a class extending the Command base class. At a minimum, a command must define name
, description
, and commandAction
:
import commands from '../../commands.js';
import { Logger } from '../../../../cli/Logger.js';
import SpoCommand from '../../../base/SpoCommand.js';
class SpoGroupGetCommand extends SpoCommand {
public get name(): string {
return commands.GROUP_GET;
}
public get description(): string {
return 'Gets site group';
}
public async commandAction(logger: Logger, args: CommandArgs): Promise<void> {
if (this.verbose) {
await logger.logToStderr(`Retrieving information for group in site ...`);
}
// Command implementation goes here
}
}
export default new SpoGroupGetCommand();
Depending on your command and the service for which you're building the command, there might be a base class that you can use to simplify the implementation. For example, for SPO, you can inherit from the SpoCommand base class. This class contains several helper methods to simplify your implementation.
Include command name
When you create the minimum file, you'll get an error about a nonexistent type within commands
. This is correct because we haven't defined the name of the command yet. Let's add this to the commands
export located in src/m365/spo/commands.ts
:
const prefix: string = 'spo';
export default {
// ...
GROUP_GET: `${prefix} group get`,
// ...
};
Next, to enhance our command with options, validators, telemetry, there are a bunch of methods already available for you. When you make use of a method we order them alphabetically.
Defining the options
When the command requires options to be passed along, we will define them in the interface Options
. This interface extends from our GlobalOptions
, where the common options query
, output
, debug
, and verbose
are defined. When an option is optional, let's make sure that it's also optional in the interface.
We will also define the options in the method #initOptions
. Here we pass along the option name, as a possible abbreviation for the option, to the this.options
object. In some occasions, the option will always require a predefined input (an option with a fixed amount of choices). When this is the case, we can define them under the property autocomplete
.
Required options are denoted as --required <required>
, optional options are denoted as --optional [optional]
, and flag options are denoted as --flag
.
// ...
import GlobalOptions from '../../../../GlobalOptions.js';
interface CommandArgs {
options: Options;
}
interface Options extends GlobalOptions {
webUrl: string;
id?: number;
name?: string;
associatedGroup?: string;
}
class SpoGroupGetCommand extends SpoCommand {
// ...
constructor() {
super();
this.#initOptions();
}
#initOptions(): void {
this.options.unshift(
{
option: '-u, --webUrl <webUrl>'
},
{
option: '-i, --id [id]'
},
{
option: '--name [name]'
},
{
option: '--associatedGroup [associatedGroup]',
autocomplete: ['Owner', 'Member', 'Visitor']
}
);
}
public async commandAction(logger: Logger, args: CommandArgs): Promise<void> {
if (this.verbose) {
await logger.logToStderr(`Retrieving information for group in site at ${args.options.webUrl}...`);
}
// Command implementation goes here
}
}
Option validation
The options that are passed along won't always be correct from the first attempt. So instead of passing faulty values to the API, we can write option validation that runs before commandAction
is executed. This can be done in the method #initValidators
. Conditions can be written here to validate the option values and return an error when they're faulty. Once again, there are already several validation methods you can make use of to check some common options, e.g., validation.isValidSharePointUrl(...)
.
// ...
import { validation } from '../../../../utils/validation.js';
class SpoExampleListCommand extends Command {
constructor() {
super();
// ...
this.#initValidators();
}
// ...
#initValidators(): void {
this.validators.push(
async (args: CommandArgs) => {
if (args.options.id && isNaN(args.options.id)) {
return `Specified id ${args.options.id} is not a number`;
}
if (args.options.associatedGroup && ['owner', 'member', 'visitor'].indexOf(args.options.associatedGroup.toLowerCase()) === -1) {
return `${args.options.associatedGroup} is not a valid associatedGroup value. Allowed values are Owner|Member|Visitor.`;
}
return validation.isValidSharePointUrl(args.options.webUrl);
}
);
}
}
Option sets
Option sets are used to ensure that only one option has a value from a set of options. When no option is used, the command will return an error, and the same goes when multiple of these options are used. To make use of the option sets, you can use the method #initOptionSets
.
class SpoExampleListCommand extends Command {
constructor() {
super();
// ...
this.#initOptionSets();
}
// ...
#initOptionSets(): void {
this.optionSets.push({ options: ['id', 'name', 'associatedGroup'] });
}
}
Option sets also allow you to define runsWhen
for a set of options. This allows you to define a condition when the option set should be executed. E.g., when the option title
is defined, either the option set ownerGroupId
and ownerGroupName
should be required.
#initOptionSets(): void {
this.optionSets.push(
{
options: ['id', 'title', 'rosterId']
},
{
options: ['ownerGroupId', 'ownerGroupName'],
runsWhen: (args) => args.options.title !== undefined
}
);
}
Telemetry
The CLI for Microsoft 365 tracks the usage of the different commands using Azure Application Insights. By default, for each command, the CLI tracks its name and whether it's been executed in debug/verbose mode or not. If your command has additional properties that should be included in the telemetry, you can define them by implementing the #initTelemetry
method and adding your properties to the this.telemetryProperties
object.
class SpoExampleListCommand extends Command {
constructor() {
super();
// ...
this.#initTelemetry();
}
// ...
#initTelemetry(): void {
this.telemetry.push((args: CommandArgs) => {
Object.assign(this.telemetryProperties, {
id: typeof args.options.id !== 'undefined',
name: typeof args.options.name !== 'undefined',
associatedGroup: !!args.options.associatedGroup
});
});
}
}
We are only listing the option options in #initTelemetry
. Since the required options are always used, we don't include them in the telemetry.
Command action
After everything is written for our options, we can start to write the logic required to execute the command. This will be done, as mentioned before, in the method commandAction
. The command will start with a verbose message explaining what we are about to do, and then we start writing the command logic. Here you can write several new methods to be called in commandAction
to keep the code a bit tidier.
When writing your code, there are a few pointers to keep in mind:
- It's recommended to add some verbose logging along the path of your command. This can keep the user informed about what you are doing.
- Async tasks will be written with async/await.
- When an endpoint errors, make sure that the output is returned to the user.
class SpoExampleListCommand extends Command {
// ...
public async commandAction(logger: Logger, args: CommandArgs): Promise<void> {
if (this.verbose) {
await logger.logToStderr(`Retrieving information for group in site at ${args.options.webUrl}...`);
}
// ...
// Command logic
// ...
try {
const groupInstance = await request.get(requestOptions);
await logger.log(groupInstance);
}
catch (err: any) {
this.handleRejectedODataJsonPromise(err);
}
}
}
After this, the new command will be fully functional. During development, it can be useful to have npm run watch
running in the background. This builds the entire project first. After this, a watcher will ensure that every time a file is saved, an incremental build is triggered. This means that not the entire project is rebuilt, but only the changed files. That way, you can easily apply new changes to the command and test it out locally.
In the end, your command file will look something like this: group-get.ts
Running it locally
Before creating a PR, you should test your code locally. This will help you catch bugs, errors, and performance issues early on and ensure that the code functions as intended before it is made available to real users. You can execute npm run watch
to start a live watcher. This will build the entire project first, and after that, a watcher will ensure that every time a file is saved, an incremental build is triggered. This means that not the entire project is rebuilt, but only the changed files. This makes it easy to make quick changes and test them immediately after saving them.
If this command fails, be sure to check if your environment has been set up correctly following the guidelines of Setting up your local project or if you use a dev container or GitHub Codespaces: GitHub Codespaces & Visual Studio Remote Development Container.
Next step
Now that the command is fully functional we will need to add some tests to ensure that the command works as expected. This will be explained in the next chapter: Unit Tests.