Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
// 'm365 spo site get --url /', you'd use:
// "args": ["spo", "site", "get", "--url", "/"]
// after debugging, revert changes so that they won't end up in your PR
"args": [],
"args": ["login"],
"console": "integratedTerminal",
"env": {
"NODE_OPTIONS": "--enable-source-maps"
Expand Down
129 changes: 64 additions & 65 deletions docs/docs/contribute/new-command/build-command-logic.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -71,33 +71,33 @@ Some commands require options. For example, when you want to get a group, you ne
Global CLI options, such as `query`, `output`, `debug` or `verbose` are defined in the `globalOptionsZod` property. To define options specific to your command, extend the global schema with your command-specific options.

```ts title="src/m365/spo/commands/group/group-get.ts"
import { z } from 'zod';
import { globalOptionsZod } from '../../Command.js';

export const options = globalOptionsZod
.extend({
webUrl: z.string(),
id: z.number().optional(),
name: z.string().optional(),
associatedGroup: z.string().optional()
})
.strict();
export const options = z.strictObject({
...globalOptionsZod,
webUrl: z.string(),
id: z.number().optional(),
name: z.string().optional(),
associatedGroup: z.string().optional()
});
```

In this example, we're adding 4 command options: `webUrl`, `id`, `name`, and `associatedGroup`. The `webUrl` option is required and must be a string. The `id`, `name`, and `associatedGroup` options are optional. Since our command doesn't support unknown options, we set the schema to strict.

Next, we define a TypeScript type for options and command args, which allows us to benefit from type-safety when working with options in the command logic.

```ts title="src/m365/spo/commands/group/group-get.ts"
import { z } from 'zod';
import { globalOptionsZod } from '../../Command.js';

export const options = globalOptionsZod
.extend({
webUrl: z.string(),
id: z.number().optional(),
name: z.string().optional(),
associatedGroup: z.string().optional()
})
.strict();
export const options = z.strictObject({
...globalOptionsZod,
webUrl: z.string(),
id: z.number().optional(),
name: z.string().optional(),
associatedGroup: z.string().optional()
});
declare type Options = z.infer<typeof options>;

interface CommandArgs {
Expand All @@ -108,16 +108,16 @@ interface CommandArgs {
Finally, we expose the options schema in the command class.

```ts title="src/m365/spo/commands/group/group-get.ts"
import { z } from 'zod';
import { globalOptionsZod } from '../../Command.js';

export const options = globalOptionsZod
.extend({
webUrl: z.string(),
id: z.number().optional(),
name: z.string().optional(),
associatedGroup: z.string().optional()
})
.strict();
export const options = z.strictObject({
...globalOptionsZod,
webUrl: z.string(),
id: z.number().optional(),
name: z.string().optional(),
associatedGroup: z.string().optional()
});
declare type Options = z.infer<typeof options>;

interface CommandArgs {
Expand All @@ -127,7 +127,7 @@ interface CommandArgs {
class SpoGroupGetCommand extends SpoCommand {
// ...

public get schema(): z.ZodTypeAny | undefined {
public get schema(): z.ZodType | undefined {
return options;
}

Expand All @@ -146,24 +146,24 @@ class SpoGroupGetCommand extends SpoCommand {
To simplify using the CLI, we often use aliases for options. For example, the `--webUrl` option can be shortened to `-u`. To define an alias for an option, we wrap the property in the `alias` function in the schema.

```ts title="src/m365/spo/commands/group/group-get.ts"
import { z } from 'zod';
import { globalOptionsZod } from '../../Command.js';
import { zod } from '../../utils/zod.js';

export const options = globalOptionsZod
.extend({
webUrl: zod.alias('u', z.string()),
id: zod.alias('i', z.number().optional()),
name: z.string().optional(),
associatedGroup: z.string().optional()
})
.strict();
export const options = z.strictObject({
...globalOptionsZod,
webUrl: z.string().alias('u'),
id: z.number().optional().alias('i'),
name: z.string().optional(),
associatedGroup: z.string().optional()
});
```

## Defining option autocomplete

Some options require predefined values. For example, the `associatedGroup` option can only have one of the following values: `Owner`, `Member`, or `Visitor`. To define the allowed values for an option, we use enums with the `coercedEnum` helper function. This function allows users to specify the values in a case-insensitive way. In the code, the value of the `associatedGroup` option will be expressed as an enum which allows us to benefit from type-safety and support refactoring.

```ts title="src/m365/spo/commands/group/group-get.ts"
import { z } from 'zod';
import { globalOptionsZod } from '../../Command.js';
import { zod } from '../../utils/zod.js';

Expand All @@ -173,14 +173,13 @@ enum AssociatedGroup {
Visitor = 'visitor'
};

export const options = globalOptionsZod
.extend({
webUrl: zod.alias('u', z.string()),
id: zod.alias('i', z.number().optional()),
name: z.string().optional(),
associatedGroup: zod.coercedEnum(AssociatedGroup).optional()
})
.strict();
export const options = z.strictObject({
...globalOptionsZod,
webUrl: z.string().alias('u'),
id: z.number().optional().alias('i'),
name: z.string().optional(),
associatedGroup: zod.coercedEnum(AssociatedGroup).optional()
});
```

## Option validation
Expand All @@ -190,6 +189,7 @@ The options that users specify won't always be correct. So instead of passing fa
Zod automatically validates primitive values, which means that you only need to add 'business' validation logic. Validation is implemented using Zod refinements on the specific property. For example, to validate that the specified URL is a SharePoint URL, use:

```ts title="src/m365/spo/commands/group/group-get.ts"
import { z } from 'zod';
import { globalOptionsZod } from '../../Command.js';
import { validation } from '../../utils/validation.js';
import { zod } from '../../utils/zod.js';
Expand All @@ -200,16 +200,15 @@ enum AssociatedGroup {
Visitor = 'visitor'
};

export const options = globalOptionsZod
.extend({
webUrl: zod.alias('u', z.string().refine(url => validation.isValidSharePointUrl(url) === true, url => ({
message: `Specified URL ${url} is not a valid SharePoint URL`,
}))),
id: zod.alias('i', z.number().optional()),
name: z.string().optional(),
associatedGroup: zod.coercedEnum(AssociatedGroup).optional()
})
.strict();
export const options = z.strictObject({
...globalOptionsZod,
webUrl: z.string().refine(url => validation.isValidSharePointUrl(url) === true, {
error: 'Invalid SharePoint URL',
}).alias('u'),
id: z.number().optional().alias('i'),
name: z.string().optional(),
associatedGroup: zod.coercedEnum(AssociatedGroup).optional()
});
```

A refinement takes two arguments: a predicate function and an error object. The predicate function is used to validate the input, and the error object is used to define the error message that will be displayed when the input is invalid.
Expand All @@ -219,6 +218,7 @@ A refinement takes two arguments: a predicate function and an error object. The
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 use option sets, add a Zod refinement on the whole schema, which gives you access to all schema properties.

```ts title="src/m365/spo/commands/group/group-get.ts"
import { z } from 'zod';
import { globalOptionsZod } from '../../Command.js';
import { validation } from '../../utils/validation.js';
import { zod } from '../../utils/zod.js';
Expand All @@ -229,16 +229,15 @@ enum AssociatedGroup {
Visitor = 'visitor'
};

export const options = globalOptionsZod
.extend({
webUrl: zod.alias('u', z.string().refine(url => validation.isValidSharePointUrl(url) === true, url => ({
message: `Specified URL ${url} is not a valid SharePoint URL`,
}))),
id: zod.alias('i', z.number().optional()),
name: z.string().optional(),
associatedGroup: zod.coercedEnum(AssociatedGroup).optional()
})
.strict();
export const options = z.strictObject({
...globalOptionsZod,
webUrl: z.string().refine(url => validation.isValidSharePointUrl(url) === true, {
error: 'Invalid SharePoint URL',
}).alias('u'),
id: z.number().optional().alias('i'),
name: z.string().optional(),
associatedGroup: zod.coercedEnum(AssociatedGroup).optional()
});
declare type Options = z.infer<typeof options>;

interface CommandArgs {
Expand All @@ -248,14 +247,14 @@ interface CommandArgs {
class SpoGroupGetCommand extends SpoCommand {
// ...

public get schema(): z.ZodTypeAny | undefined {
public get schema(): z.ZodType | undefined {
return options;
}

public getRefinedSchema(schema: typeof options): z.ZodEffects<any> | undefined {
public getRefinedSchema(schema: typeof options): z.ZodObject<any> | undefined {
return schema
.refine(options => options.id !== undefined || options.name !== undefined && !(options.id !== undefined && options.name !== undefined), {
message: `Either id or name is required, but not both.`
error: `Either id or name is required, but not both.`
});
}

Expand Down
15 changes: 11 additions & 4 deletions npm-shrinkwrap.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@
"uuid": "^13.0.0",
"yaml": "^2.8.1",
"yargs-parser": "^22.0.0",
"zod": "^3.25.76"
"zod": "^4.1.11"
},
"devDependencies": {
"@actions/core": "^1.11.1",
Expand Down
2 changes: 1 addition & 1 deletion src/Command.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ class MockCommandWithSchema extends Command {
return 'Mock command description';
}

public get schema(): z.ZodTypeAny | undefined {
public get schema(): z.ZodType | undefined {
return globalOptionsZod;
}

Expand Down
14 changes: 7 additions & 7 deletions src/Command.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import os from 'os';
import { ZodTypeAny, z } from 'zod';
import { ZodType, z } from 'zod';
import auth from './Auth.js';
import GlobalOptions from './GlobalOptions.js';
import { CommandInfo } from './cli/CommandInfo.js';
Expand All @@ -12,9 +12,9 @@ import { telemetry } from './telemetry.js';
import { accessToken } from './utils/accessToken.js';
import { md } from './utils/md.js';
import { GraphResponseError } from './utils/odata.js';
import { optionsUtils } from './utils/optionsUtils.js';
import { prompt } from './utils/prompt.js';
import { zod } from './utils/zod.js';
import { optionsUtils } from './utils/optionsUtils.js';

export interface CommandOption {
option: string;
Expand Down Expand Up @@ -48,7 +48,7 @@ interface ODataError {

export const globalOptionsZod = z.object({
query: z.string().optional(),
output: zod.alias('o', z.enum(['csv', 'json', 'md', 'text', 'none']).optional()),
output: z.enum(['csv', 'json', 'md', 'text', 'none']).optional().alias('o'),
debug: z.boolean().default(false),
verbose: z.boolean().default(false)
});
Expand Down Expand Up @@ -85,17 +85,17 @@ export default abstract class Command {

public abstract get name(): string;
public abstract get description(): string;
public get schema(): ZodTypeAny | undefined {
public get schema(): ZodType | undefined {
return undefined;
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
public getRefinedSchema(schema: ZodTypeAny): z.ZodEffects<any> | undefined {
public getRefinedSchema(schema: ZodType): z.ZodType | undefined {
return undefined;
}

public getSchemaToParse(): z.ZodTypeAny | undefined {
return this.getRefinedSchema(this.schema as z.ZodTypeAny) ?? this.schema;
public getSchemaToParse(): z.ZodType | undefined {
return this.getRefinedSchema(this.schema as z.ZodType) ?? this.schema;
}

// metadata for command's options
Expand Down
Loading