CLI

Command-line Interface (CLI) Programme sind Programme, die über das Terminal in Form von Text-Eingabe und Text-Ausgabe interagieren. Der Vorteil in dieser Variante mit der Anwendung zu interagieren, ist, dass lediglich ein Terminal entweder lokal oder über eine SSH-Verbindung bestehen muss.

Eine CLI-Anwendung in Deepkit hat den vollen Zugriff auf den DI-Container und kann so auf alle Provider und Konfigurationsoptionen zugreifen.

Die Argumente und Optionen der CLI-Anwendung werden über Methoden-Parameter via TypeScript Typen gesteuert und werden automatisch serialisiert und validiert.

CLI ist einer von drei Einstiegspunkten zu einer Deepkit Framework Anwendung. Im Deepkit Framework wird die Anwendung immer über ein CLI-Program gestartet, das selbst vom User in TypeScript geschrieben ist. Es gibt daher keine Deepkit spezifisches globales CLI tool, um eine Deepkit Anwendung zu starten. Auf diese Weise starten Sie den HTTP/RPC-Server, führen Migrationen aus oder führen eigene Befehle aus. Das alles geschieht über denselben Einstiegspunkt, dieselbe Datei. Sobald das Deepkit Framework durch den Import von FrameworkModule aus @deepkit/framework benutzt wird, erhält die Anwendung zusätzliche Commands für den Application Server, Migrations, und mehr.

Das CLI-Framework erlaubt es auf einfache Art eigene Commands zu registrieren und basiert dabei auf einfachen Klassen. Tatsächlich basiert es auf @deepkit/app, einem kleinen Paket, das nur für diesen Zweck gedacht ist und auch eigenständig ohne das Deepkit Framework verwendet werden kann. In diesem Paket finden sich Decorators, die benötigt werden, um die CLI-Controller-Klasse zu dekorieren.

Controller werden vom Dependency Injection Container verwaltet beziehungsweise instanziiert und können daher andere Provider verwenden. Siehe das Kapitel Dependency Injection für weitere Details.

Installation

Da CLI-Programme in Deepkit auf den Runtime Types basieren, ist es notwendig @deepkit/type bereits korrekt installiert zu haben. Siehe dazu Runtime Type Installation.

Falls dies erfolgreich geschehen ist, kann @deepkit/app installiert werden oder das Deepkit Framework welches die Library bereits unter der Haube benutzt.

npm install @deepkit/app

Zu beachten ist, dass @deepkit/app auf TypeScript-Decorators basiert und dieses Feature entsprechend mit experimentalDecorators aktiviert werden muss.

Datei: tsconfig.json

{
  "compilerOptions": {
    "module": "CommonJS",
    "target": "es6",
    "moduleResolution": "node",
    "experimentalDecorators": true
  },
  "reflection": true
}

Sobald die Library installiert ist, kann die API davon direkt benutzt werden.

Benutzung

Um einen Befehl für Ihre Anwendung zu erstellen, müssen Sie einen CLI-Controller erstellen. Dabei handelt es sich um eine einfache Klasse, die eine Methode exeecute hat und mit Informationen über den Befehl ausgestattet ist.

Datei: app.ts

#!/usr/bin/env ts-node-script
import { App, cli } from '@deepkit/app';

@cli.controller('test', {
    description: 'My first command'
})
class TestCommand {
    async execute() {
        console.log('Hello World')
    }
}

new App({
    controllers: [TestCommand]
}).run();

In dem Decorator @cli.controller wird als erstes Argument der eindeutige Name der CLI-Anwendung definiert. Weitere Optionen wie eine Beschreibung können im Objekt an der zweiten Stelle optional hinzufügt werden.

Dieser Code ist bereits eine komplette CLI-Anwendung und kann so gestartet werden:

$ ts-node ./app.ts
VERSION
  Node

USAGE
  $ ts-node app.ts [COMMAND]

COMMANDS
  test

Zu sehen ist, dass ein "test" Command verfügbar ist. Um dieses auszuführen, muss der Name als Argument übergeben werden:

$ ts-node ./app.ts test
Hello World

Es ist auch möglich, die Datei mittels chmod +x app.ts ausführbar zu machen, sodass der Command ./app.ts bereits ausreicht, um es zu starten. Zu beachten ist, dass dann ein sogenannter Shebang notwendig ist. Shebang bezeichnet die Zeichenkombination #! am Anfang eines Skriptprogramms. In dem Beispiel oben ist dies bereits vorhanden: #!/usr/bin/env ts-node-script und nutzt den Skript-Modus von ts-node.

$ ./app.ts test
Hello World

Auf diese Weise können beliebig viele Commands erstellt und registriert werden. Der in @cli.controller angegeben eindeutige Name sollte gut gewählt werden und erlaubt das Gruppieren von Commands mit dem : Zeichen (z.B. user:create, user:remove, etc).

Argumente

Um Argumente hinzuzufügen, werden neue Parameter auf die Methode execute hinzugefügt und mit dem Decorator @arg dekoriert.

import { cli, arg } from '@deepkit/app';

@cli.controller('test')
class TestCommand {
    async execute(
        @arg name: string
    ) {
        console.log('Hello', name);
    }
}

Wenn Sie diesen Befehl jetzt ausführen, ohne einen Namen anzugeben, wird ein Fehler ausgegeben:

$ ./app.ts test
RequiredArgsError: Missing 1 required arg:
name

Durch die Verwendung von --help erhalten Sie weitere Informationen über die erforderlichen Argumente:

$ ./app.ts test --help
USAGE
  $ ts-node-script app.ts test NAME

Sobald der Name als Argument übergeben wird, wird die Methode execute in TestCommand ausgeführt und der Name korrekt übergeben.

$ ./app.ts test "beautiful world"
Hello beautiful world

Flags

Flags sind eine weitere Möglichkeit, Ihrem Befehl Werte zu übergeben. Meist sind diese Optional, doch müssen es nicht sein. Parameter, die mit @flag name dekoriert sind, können via --name value oder --name=value übergeben werden.

import { flag } from '@deepkit/app';

class TestCommand {
    async execute(
        @flag id: number
    ) {
        console.log('id', id);
    }
}
$ ./app.ts test --help
USAGE
  $ ts-node app.ts test

OPTIONS
  --id=id  (required)

In der Hilfe-Ansicht ist in den "OPTIONS" nun zu sehen, dass ein --id Flag notwendig ist. Gibt man dieses Korrekt an, erhält der Command diesen Wert.

$ ./app.ts test --id 23
id 23

$ ./app.ts test --id=23
id 23

Boolean Flags

Flags haben den Vorteil, dass sie auch als wertlosen Flag verwendet werden können, um so zum Beispiel ein bestimmtes Verhalten zu aktivieren. Sobald ein Parameter als optionaler Boolean markiert ist, wird dieses Verhalten aktiviert.

import { flag } from '@deepkit/app';

class TestCommand {
    async execute(
        @flag remove: boolean = false
    ) {
        console.log('delete?', remove);
    }
}
$ ./app.ts test
delete? false

$ ./app.ts test --remove
delete? true

Multiple Flags

Um mehrere Werte demselben Flag zu übergeben, kann ein Flag als Array markiert werden.

import { flag } from '@deepkit/app';

class TestCommand {
    async execute(
        @flag id: number[] = []
    ) {
        console.log('ids', id);
    }
}
$ ./app.ts test
ids: []

$ ./app.ts test --id 12
ids: [12]

$ ./app.ts test --id 12 --id 23
ids: [12, 23]

Single Character Flags

Um einem Flag zu erlauben, auch als ein einzelner Charakter übergeben zu werden, kann @flag.char('x') genutzt werden.

import { flag } from '@deepkit/app';

class TestCommand {
    async execute(
        @flag.char('o') output: string
    ) {
        console.log('output: ', output);
    }
}
$ ./app.ts test --help
USAGE
  $ ts-node app.ts test

OPTIONS
  -o, --output=output  (required)


$ ./app.ts test --output test.txt
output: test.txt

$ ./app.ts test -o test.txt
output: test.txt

Optional / Default

Die Signatur der Methode execute definiert, welche Argument oder Flags optional sind. Ist der Parameter als Optional markiert, so muss er nicht angegeben werden.

class TestCommand {
    async execute(
        @arg name?: string
    ) {
        console.log('Hello', name || 'nobody');
    }
}
$ ./app.ts test
Hello nobody

Dasselbe für Parameter mit einem Default-Wert:

class TestCommand {
    async execute(
        @arg name: string = 'body'
    ) {
        console.log('Hello', name);
    }
}
$ ./app.ts test
Hello nobody

Dies gilt auch für Flags in derselben Art und Weise.

Serialization / Validation

Alle Argumente und Flags werden automatisch basierend auf dessen Typen deserialisiert, validiert und können mit zusätzlichen Einschränkungen versehen werden.

So sind Argument, die als Number definiert sind, in dem Controller auch garantiert immer eine echte Nummer, obwohl das Command-Line Interface auf Text und somit Strings basiert. Die Umwandlung passiert dabei automatisch mit dem Feature Weiche Typenkonvertierung.

class TestCommand {
    async execute(
        @arg id: number
    ) {
        console.log('id', id, typeof id);
    }
}
$ ./app.ts test 123
id 123 number

Zusätzliche Einschränkungen können mit den Typen-Decorators aus @deepkit/type definiert werden.

import { Positive } from '@deepkit/type';

class TestCommand {
    async execute(
        @arg id: number & Positive
    ) {
        console.log('id', id, typeof id);
    }
}

Der Typ Postive bei id gibt an, dass nur positive Nummern gewollt sind. Übergibt der User nun eine negative Zahl, so wird der Code in execute gar nicht erst ausgeführt und es wird eine Fehlermeldung präsentiert.

$ ./app.ts test -123
Validation error in id: Number needs to be positive [positive]

Bei einer positiven Nummer funktioniert dies dann wieder wie zuvor. Durch diese zusätzliche sehr einfach zu bewerkstelligende Validierung, wird der Command deutlich robuster gegen Falscheingaben geschützt. Sieh dazu das Kapitel Validation für mehr Informationen.

Description

Um einen Flag oder Argument zu beschreiben, kann @flag.description beziehungsweise @arg.description genutzt werden.

import { Positive } from '@deepkit/type';

class TestCommand {
    async execute(
        @arg.description('The users identifier') id: number & Positive,
        @flag.description('Delete the user?') remove: boolean = false,
    ) {
        console.log('id', id, typeof id);
    }
}

In der Hilfe-Ansicht erscheint diese Beschreibung hinter dem Flag beziehungsweise Argument:

$ ./app.ts test --help
USAGE
  $ ts-node app.ts test ID

ARGUMENTS
  ID  The users identifier

OPTIONS
  --remove  Delete the user?

Exit code

Der Exit-Code ist standardmäßig 0, was bedeutet, dass der Befehl erfolgreich ausgeführt wurde. Um den Exit-Code zu ändern, sollten in der exucute-Methode eine Zahl ungleich 0 zurückgeben werden.

@cli.controller('test')
export class TestCommand {
    async execute() {
        console.error('Error :(');
        return 12;
    }
}
$ ./app.ts
Error :(
$ echo $?
12

Dependency Injection

Die Klasse des Commands wird vom DI Container verwaltet, sodass Abhängigkeiten definiert werden können, die über den DI Container aufgelöst werden.

#!/usr/bin/env ts-node-script
import { App, cli } from '@deepkit/app';
import { Logger, ConsoleTransport } from '@deepkit/logger';

@cli.controller('test', {
    description: 'My super first command'
})
class TestCommand {
    constructor(protected logger: Logger) {
    }

    async execute() {
        this.logger.log('Hello World!');
    }
}

new App({
    providers: [{provide: Logger, useValue: new Logger([new ConsoleTransport]}],
    controllers: [TestCommand]
}).run();