Skip to content

Commit 2ffeb51

Browse files
committed
feat(commands): add a class form built on defineCommand
Command(meta) returns a base class whose static definition is a real defineCommand result: the handlers become methods, the instance is the setup result, and the adapter still only ever sees definitions. The definition is a static getter, so it resolves the subclass it is read through and caches on that constructor. Registration, the name-literal check and the extension manifest path take either form. COMMAND_CONTEXT is promoted to nativescript/contracts, which is what the base class reads in its field initializer.
1 parent c502143 commit 2ffeb51

7 files changed

Lines changed: 698 additions & 21 deletions

File tree

defining-commands.md

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ spread, so `{ ...baseDefinition, name: "widget|add2" }` is still recognised.
4848
`defineCommand` does not register anything by itself — see
4949
[Registering a definition](#registering-a-definition).
5050

51+
A command may also be written as a class, with the handlers as methods — see
52+
[Class form](#class-form). It is sugar over `defineCommand`: everything below
53+
describes both.
54+
5155
Validation happens where you can see it
5256
---------------------------------------
5357

@@ -518,6 +522,120 @@ Other flags
518522
Both are simply passed through to the command the CLI executes; omitting them
519523
leaves the CLI's defaults in place.
520524

525+
Class form
526+
----------
527+
528+
`Command(meta)` returns a base class to extend. It is sugar over
529+
`defineCommand` and nothing more: the class carries a `static definition` built
530+
by `defineCommand`, and that definition is the only thing the CLI ever
531+
executes.
532+
533+
```ts
534+
import { Command, inject, stringOption } from "nativescript/contracts";
535+
536+
export class PlatformCleanCommand extends Command({
537+
name: "platform|clean",
538+
description: "Removes and adds again the selected platform.",
539+
options: { frameworkPath: stringOption() },
540+
arguments: "any",
541+
}) {
542+
private $platformCommandHelper = inject<IPlatformCommandHelper>(
543+
"platformCommandHelper",
544+
);
545+
private $projectData = inject<IProjectData>("projectData");
546+
547+
constructor() {
548+
super();
549+
this.$projectData.initializeProjectData();
550+
}
551+
552+
public async run(): Promise<void> {
553+
await this.$platformCommandHelper.cleanPlatforms(
554+
this.args,
555+
this.$projectData,
556+
this.options.frameworkPath,
557+
);
558+
}
559+
}
560+
```
561+
562+
`meta` is the definition minus its handlers: `name`, `description`, `options`,
563+
`arguments`, `allowUnknownOptions`, `disableAnalytics` and `enableHooks`. The
564+
handlers are methods instead — `run` is required, and `canExecute`, `postRun`
565+
and `shortcuts` are optional, each with the same meaning and the same ordering
566+
as the fields of the same name. `postRun(result)` receives what `run` returned;
567+
`shortcuts()` returns the same table `shortcuts(ctx, setup)` does. A method the
568+
class does not declare is left out of the definition entirely, so a class
569+
without `postRun` gets no `postCommandAction`, exactly as an object without one
570+
does.
571+
572+
**Which form to use.** The class form is for a single named command. When a
573+
function generates variants of one command — the `run|ios` / `run|vision`
574+
family, one definition per platform — the object form is what fits, because
575+
the thing being parameterized is a value and definitions are values.
576+
Registering the same class twice under two names is not the equivalent: the
577+
class is one definition.
578+
579+
**The class is the setup.** One instance is constructed per invocation, as that
580+
invocation's `setup`, before `canExecute` runs. So field initializers and the
581+
constructor run inside the injection context: `inject()` in a field initializer
582+
resolves, and a constructor — optional, and if written it must call a bare
583+
`super()` — is where the work a legacy command did in its own constructor goes.
584+
Because construction is the setup, `inject()` is valid throughout it; after the
585+
first `await` inside a method, use `this.context.injector.get(token)` as
586+
[Injection, and the first `await`](#injection-and-the-first-await) describes.
587+
588+
**`this.context`, `this.options` and `this.args`** are the same context the
589+
object form's handlers receive, typed from the `options` the meta declares:
590+
`this.options.frameworkPath` is `string | undefined` above, and a name the
591+
schema does not declare is a compile error. `this.context` also carries
592+
`params`, `injector` and `fail`.
593+
594+
**Per-command providers see the invocation.** The context is provided to the
595+
invocation's own child injector under the `COMMAND_CONTEXT` token, which is how
596+
the base class reads it. A provider registered for one command — through the
597+
`providers` argument of `registerCommand` or `registerLazyCommand` — can inject
598+
it too, and resolves nothing outside a running invocation.
599+
600+
**Share through functions, not base classes.** Two commands that need the same
601+
services share an `inject()`-based helper, not a common ancestor:
602+
603+
```ts
604+
export function injectPlatformCommandServices() {
605+
const projectData = inject(ProjectData);
606+
projectData.initializeProjectData();
607+
return { projectData, platformHelper: inject(PlatformCommandHelper) };
608+
}
609+
610+
export class PlatformAddCommand extends Command({ name: "platform|add" }) {
611+
private services = injectPlatformCommandServices();
612+
// ...
613+
}
614+
```
615+
616+
A helper composes — a command can call two of them — and it stays readable
617+
without the reader walking a chain of files. A base class between `Command()`
618+
and the command does not: it is the pattern the legacy `ICommand` hierarchy
619+
used, and untangling it is most of why this API exists.
620+
621+
Registration takes the class itself; see
622+
[Registering a definition](#registering-a-definition):
623+
624+
```ts
625+
registerBuiltInCommand<
626+
typeof import("./commands/platform-clean").PlatformCleanCommand
627+
>(
628+
"platform|clean",
629+
() => require("./commands/platform-clean").PlatformCleanCommand,
630+
);
631+
```
632+
633+
`isCommandClass(value)` is the exported check, and `Ctor.definition` is the
634+
definition the class stands for — derived once per class, and derived for the
635+
subclass rather than for the base `Command()` returned. A class that implements
636+
no `run`, or a class that did not come from `Command()`, is refused with the
637+
same message shape a bad object gets.
638+
521639
Registering a definition
522640
------------------------
523641

@@ -533,6 +651,11 @@ registerCommand({
533651
});
534652
```
535653

654+
Every registration helper — `registerCommand`, `registerLazyCommand` and
655+
`registerBuiltInCommand` — takes a [class form](#class-form) command wherever
656+
it takes a definition, and reads the name it declares through its
657+
`static definition`.
658+
536659
It takes either a `DefinedCommand` — the result of `defineCommand`, marker and
537660
all — or the definition itself, which it defines on your behalf, so registering
538661
a command is one call. Either way the definition is validated before it reaches

lib/common/define-command.ts

Lines changed: 218 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
* lib/common/services/command-definition-adapter.
77
*/
88

9+
import { COMMAND_CONTEXT } from "./contracts/command-context";
910
import type { KeyShortcut } from "./contracts/key-shortcuts";
11+
import { inject } from "./di/inject";
1012
import type { Injector } from "./di/injector";
1113

1214
/**
@@ -255,7 +257,10 @@ const OPTION_TYPES: CommandOptionType[] = [
255257
const ACCEPTED_FORM =
256258
'defineCommand({ name: "widget|add", run(ctx) { ... } }) — with the ' +
257259
"optional fields description, options, arguments, allowUnknownOptions, " +
258-
"setup, canExecute, shortcuts, postRun, disableAnalytics and enableHooks.";
260+
"setup, canExecute, shortcuts, postRun, disableAnalytics and enableHooks. " +
261+
'Or the class form, class WidgetAdd extends Command({ name: "widget|add" }) ' +
262+
"{ run() { ... } }, which declares the same fields except the handlers and " +
263+
"implements run, and optionally canExecute, postRun and shortcuts, as methods.";
259264

260265
const describeDefinition = (definition: any): string => {
261266
const name = definition && definition.name;
@@ -556,12 +561,18 @@ export type CommandName = string | readonly string[];
556561
* be checked against them.
557562
*/
558563
export type CommandNamesOf<TDefinition> = TDefinition extends {
559-
name: infer TName;
564+
definition: infer TClassDefinition;
560565
}
561-
? TName extends readonly (infer TAlias)[]
562-
? TAlias
563-
: TName
564-
: never;
566+
? // A constructor's own `name` is Function.name, so the class form has to be
567+
// read through its static definition before the `name` branch sees it.
568+
CommandNamesOf<TClassDefinition>
569+
: TDefinition extends {
570+
name: infer TName;
571+
}
572+
? TName extends readonly (infer TAlias)[]
573+
? TAlias
574+
: TName
575+
: never;
565576

566577
export function defineCommand<
567578
TSchema extends CommandOptionsSchema = {},
@@ -583,3 +594,204 @@ export function isCommandDefinition(
583594
): value is DefinedCommand<any, any, any> {
584595
return !!value && (<any>value)[COMMAND_DEFINITION_MARKER] === true;
585596
}
597+
598+
/**
599+
* Marks a constructor produced by `Command()`. Same `Symbol.for` reasoning as
600+
* COMMAND_DEFINITION_MARKER, and the same reason it is read rather than
601+
* `instanceof`: an extension bundles its own copy of this module.
602+
*/
603+
export const COMMAND_CLASS_MARKER: unique symbol = Symbol.for(
604+
"nativescript:cli:commandClass",
605+
);
606+
607+
/** The meta `Command()` was called with, inherited by every subclass. */
608+
const COMMAND_CLASS_META = Symbol.for("nativescript:cli:commandClassMeta");
609+
610+
/** Per-constructor cache of the derived definition; own-property only. */
611+
const COMMAND_CLASS_DEFINITION = Symbol.for(
612+
"nativescript:command:classDefinition",
613+
);
614+
615+
/**
616+
* What the class form declares up front: a definition without the handlers,
617+
* which the class supplies as methods instead.
618+
*/
619+
export type CommandMeta<
620+
TName extends CommandName = CommandName,
621+
TSchema extends CommandOptionsSchema = {},
622+
> = Omit<
623+
CommandDefinition<TSchema, any, any>,
624+
"name" | "setup" | "canExecute" | "run" | "postRun" | "shortcuts"
625+
> & { name: TName };
626+
627+
/**
628+
* The instance side of the class form. Exported because it names the base of
629+
* every `Command()` class — a subclass's declaration emit refers to it — not
630+
* because anything should extend it directly.
631+
*/
632+
export abstract class CommandBase<
633+
TSchema extends CommandOptionsSchema = {},
634+
TResult = void,
635+
> {
636+
/**
637+
* The instance is built once per invocation, as that invocation's `setup`,
638+
* so the context captured here is the one its own run was handed.
639+
*/
640+
protected readonly context: CommandContext<TSchema> = inject(COMMAND_CONTEXT);
641+
642+
protected get options(): CommandOptionValues<TSchema> {
643+
return this.context.options;
644+
}
645+
646+
protected get args(): string[] {
647+
return this.context.args;
648+
}
649+
650+
abstract run(): Promise<TResult> | TResult;
651+
canExecute?(): Promise<boolean> | boolean;
652+
postRun?(result: Awaited<TResult>): Promise<void> | void;
653+
shortcuts?(): KeyShortcut[];
654+
}
655+
656+
/**
657+
* The static side. An abstract construct signature, so the compiler still
658+
* requires a subclass to implement `run`, and a named type, so declaration
659+
* emit for `class X extends Command({ ... })` has something to refer to.
660+
*/
661+
export type CommandClass<
662+
TName extends CommandName = CommandName,
663+
TSchema extends CommandOptionsSchema = {},
664+
TResult = void,
665+
> = (abstract new () => CommandBase<TSchema, TResult>) & {
666+
readonly definition: NamedCommand<
667+
TSchema,
668+
TResult,
669+
CommandBase<TSchema, TResult>,
670+
TName
671+
>;
672+
readonly [COMMAND_CLASS_MARKER]: true;
673+
};
674+
675+
/** Either accepted form of a command, as a registration site takes it. */
676+
export type RegisterableCommand =
677+
DefinedCommand<any, any, any> | CommandClass<any, any, any>;
678+
679+
export function isCommandClass(
680+
value: any,
681+
): value is CommandClass<any, any, any> {
682+
return (
683+
typeof value === "function" && (<any>value)[COMMAND_CLASS_MARKER] === true
684+
);
685+
}
686+
687+
const buildClassDefinition = (ctor: any): DefinedCommand<any, any, any> => {
688+
const meta = ctor[COMMAND_CLASS_META];
689+
const prototype = ctor.prototype;
690+
const implementsMethod = (method: string): boolean =>
691+
typeof prototype[method] === "function";
692+
693+
if (!implementsMethod("run")) {
694+
invalid(
695+
meta,
696+
`the class '${ctor.name || "<anonymous>"}' implements no 'run' method`,
697+
);
698+
}
699+
700+
// The instance IS the setup result, so every handler reaches it as the
701+
// second argument the adapter already threads through.
702+
const definition: any = {
703+
...meta,
704+
setup: () => new ctor(),
705+
run: (context: any, instance: any) => instance.run(),
706+
};
707+
708+
if (implementsMethod("canExecute")) {
709+
definition.canExecute = (context: any, instance: any) =>
710+
instance.canExecute();
711+
}
712+
713+
if (implementsMethod("postRun")) {
714+
definition.postRun = (context: any, result: any, instance: any) =>
715+
instance.postRun(result);
716+
}
717+
718+
if (implementsMethod("shortcuts")) {
719+
definition.shortcuts = (context: any, instance: any) =>
720+
instance.shortcuts();
721+
}
722+
723+
return defineCommand(definition);
724+
};
725+
726+
/**
727+
* The definition a `Command()` class stands for, cached on the constructor it
728+
* was read from. The cache entry is an own property so a class extending
729+
* another command class never serves its parent's definition.
730+
*/
731+
export function classCommandDefinition(
732+
ctor: any,
733+
): DefinedCommand<any, any, any> {
734+
if (!isCommandClass(ctor)) {
735+
throw new Error(
736+
`${describeDefinition(ctor)} is not a command class: it did not come ` +
737+
`from Command(). Accepted form: ${ACCEPTED_FORM}`,
738+
);
739+
}
740+
741+
const target: any = ctor;
742+
if (Object.prototype.hasOwnProperty.call(target, COMMAND_CLASS_DEFINITION)) {
743+
return target[COMMAND_CLASS_DEFINITION];
744+
}
745+
746+
const definition = buildClassDefinition(target);
747+
Object.defineProperty(target, COMMAND_CLASS_DEFINITION, {
748+
value: definition,
749+
});
750+
751+
return definition;
752+
}
753+
754+
/** The definition behind either form, or null for anything else. */
755+
export function toCommandDefinition(
756+
value: any,
757+
): DefinedCommand<any, any, any> | null {
758+
if (isCommandClass(value)) {
759+
return classCommandDefinition(value);
760+
}
761+
762+
return isCommandDefinition(value) ? value : null;
763+
}
764+
765+
/**
766+
* The class authoring form: sugar over defineCommand, not a second execution
767+
* path. The returned base carries a `definition` that reads the class it is
768+
* accessed through, so the subclass — not this base — is what `setup`
769+
* instantiates, and registration keeps taking definitions only.
770+
*
771+
* export class PlatformClean extends Command({
772+
* name: "platform|clean",
773+
* options: { frameworkPath: stringOption() },
774+
* }) {
775+
* private $helper = inject<IPlatformCommandHelper>("platformCommandHelper");
776+
* run() { return this.$helper.clean(this.args, this.options.frameworkPath); }
777+
* }
778+
*/
779+
export function Command<
780+
const TName extends CommandName,
781+
TSchema extends CommandOptionsSchema = {},
782+
TResult = void,
783+
>(meta: CommandMeta<TName, TSchema>): CommandClass<TName, TSchema, TResult> {
784+
abstract class Base extends CommandBase<TSchema, TResult> {
785+
// A getter, because `this` in a static accessor is the constructor the
786+
// property was read through: that is the only hook that resolves the
787+
// subclass without the subclass having to name itself.
788+
static get definition(): DefinedCommand<any, any, any> {
789+
return classCommandDefinition(this);
790+
}
791+
}
792+
793+
Object.defineProperty(Base, COMMAND_CLASS_MARKER, { value: true });
794+
Object.defineProperty(Base, COMMAND_CLASS_META, { value: meta });
795+
796+
return <any>Base;
797+
}

0 commit comments

Comments
 (0)