Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add commands to manage extensions #850

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ USAGE
* [`mw database`](docs/database.md) - Manage databases (like MySQL and Redis) in your projects
* [`mw ddev`](docs/ddev.md) - Integrate your mittwald projects with DDEV
* [`mw domain`](docs/domain.md) - Manage domains, virtual hosts and DNS settings in your projects
* [`mw extension`](docs/extension.md) - Install and manage extensions in your organisations and projects
* [`mw help`](docs/help.md) - Display help for mw.
* [`mw login`](docs/login.md) - Manage your client authentication
* [`mw mail`](docs/mail.md) - Manage mailboxes and mail addresses in your projects
Expand Down
108 changes: 108 additions & 0 deletions docs/extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
`mw extension`
==============

Install and manage extensions in your organisations and projects

* [`mw extension install EXTENSION-ID`](#mw-extension-install-extension-id)
* [`mw extension list`](#mw-extension-list)
* [`mw extension list-installed`](#mw-extension-list-installed)
* [`mw extension uninstall EXTENSION-INSTANCE-ID`](#mw-extension-uninstall-extension-instance-id)

## `mw extension install EXTENSION-ID`

Install an extension in a project or organization

```
USAGE
$ mw extension install EXTENSION-ID [-q] [--org-id <value>] [--project-id <value>] [--consent]

ARGUMENTS
EXTENSION-ID the ID of the extension to install

FLAGS
-q, --quiet suppress process output and only display a machine-readable summary.
--consent consent to the extension having access to the requested scopes
--org-id=<value> the ID of the organization to install the extension in
--project-id=<value> the ID of the project to install the extension in

DESCRIPTION
Install an extension in a project or organization

FLAG DESCRIPTIONS
-q, --quiet suppress process output and only display a machine-readable summary.

This flag controls if you want to see the process output or only a summary. When using mw non-interactively (e.g. in
scripts), you can use this flag to easily get the IDs of created resources for further processing.
```

## `mw extension list`

Get all available extensions.

```
USAGE
$ mw extension list -o txt|json|yaml|csv|tsv [-x] [--no-header] [--no-truncate] [--no-relative-dates]
[--csv-separator ,|;]

FLAGS
-o, --output=<option> (required) [default: txt] output in a more machine friendly format
<options: txt|json|yaml|csv|tsv>
-x, --extended show extended information
--csv-separator=<option> [default: ,] separator for CSV output (only relevant for CSV output)
<options: ,|;>
--no-header hide table header
--no-relative-dates show dates in absolute format, not relative (only relevant for txt output)
--no-truncate do not truncate output (only relevant for txt output)

DESCRIPTION
Get all available extensions.
```

## `mw extension list-installed`

List installed extensions in an organization or project.

```
USAGE
$ mw extension list-installed -o txt|json|yaml|csv|tsv [-x] [--no-header] [--no-truncate] [--no-relative-dates]
[--csv-separator ,|;] [--org-id <value>] [--project-id <value>]

FLAGS
-o, --output=<option> (required) [default: txt] output in a more machine friendly format
<options: txt|json|yaml|csv|tsv>
-x, --extended show extended information
--csv-separator=<option> [default: ,] separator for CSV output (only relevant for CSV output)
<options: ,|;>
--no-header hide table header
--no-relative-dates show dates in absolute format, not relative (only relevant for txt output)
--no-truncate do not truncate output (only relevant for txt output)
--org-id=<value> the ID of the organization to install the extension in
--project-id=<value> the ID of the project to install the extension in

DESCRIPTION
List installed extensions in an organization or project.
```

## `mw extension uninstall EXTENSION-INSTANCE-ID`

Remove an extension from an organization

```
USAGE
$ mw extension uninstall EXTENSION-INSTANCE-ID [-q]

ARGUMENTS
EXTENSION-INSTANCE-ID the ID of the extension instance to uninstall

FLAGS
-q, --quiet suppress process output and only display a machine-readable summary.

DESCRIPTION
Remove an extension from an organization

FLAG DESCRIPTIONS
-q, --quiet suppress process output and only display a machine-readable summary.

This flag controls if you want to see the process output or only a summary. When using mw non-interactively (e.g. in
scripts), you can use this flag to easily get the IDs of created resources for further processing.
```
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@
"domain": {
"description": "Manage domains, virtual hosts and DNS settings in your projects"
},
"extension": {
"description": "Install and manage extensions in your organisations and projects"
},
"login": {
"description": "Manage your client authentication"
},
Expand Down
118 changes: 118 additions & 0 deletions src/commands/extension/install.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import React from "react";
import { ExecRenderBaseCommand } from "../../lib/basecommands/ExecRenderBaseCommand.js";
import {
makeProcessRenderer,
processFlags,
} from "../../rendering/process/process_flags.js";
import { Args, Flags } from "@oclif/core";
import { assertStatus } from "@mittwald/api-client";
import { Text } from "ink";
import { contextIDNormalizers } from "../../lib/context/Context.js";

type InstallResult = {
extensionInstanceId: string;
};

export default class Install extends ExecRenderBaseCommand<
typeof Install,
InstallResult
> {
static description = "Install an extension in a project or organization";

static flags = {
...processFlags,
"org-id": Flags.string({
description: "the ID of the organization to install the extension in",
exactlyOne: ["org-id", "project-id"],
}),
"project-id": Flags.string({
description: "the ID of the project to install the extension in",
exactlyOne: ["org-id", "project-id"],
}),
consent: Flags.boolean({
description:
"consent to the extension having access to the requested scopes",
}),
};

static args = {
"extension-id": Args.string({
description: "the ID of the extension to install",
required: true,
}),
};

protected async exec(): Promise<InstallResult> {
const { "extension-id": extensionId } = this.args;
const { consent } = this.flags;
let { "org-id": orgId, "project-id": projectId } = this.flags;

const p = makeProcessRenderer(this.flags, "Installing extension");

const ext = await p.runStep("Loading extension", async () => {
const response = await this.apiClient.marketplace.extensionGetExtension({
extensionId,
});

assertStatus(response, 200);

return response.data;
});

if (orgId !== undefined) {
const normalizer = contextIDNormalizers["org-id"]!;
orgId = await normalizer(this.apiClient, orgId);
}

if (projectId !== undefined) {
const normalizer = contextIDNormalizers["project-id"]!;
projectId = await normalizer(this.apiClient, projectId);
}

if (!consent) {
p.addInfo(
<Text>
This extension requires access to the following scopes:{" "}
{ext.scopes.join(", ")}. Please confirm your consent, or run the
command with the --consent flag.
</Text>,
);
const consentedInteractively = await p.addConfirmation(
"Consent to requested scopes?",
);

if (!consentedInteractively) {
throw new Error(
"Consent was not given; skipping extension installation",
);
}
}

const result = await p.runStep("installing extension", async () => {
const resp =
await this.apiClient.marketplace.extensionCreateExtensionInstance({
data: {
extensionId,
context: orgId ? "customer" : "project",
contextId: (orgId ?? projectId)!,
consentedScopes: ext.scopes,
},
});

assertStatus(resp, 201);
return resp;
});

return {
extensionInstanceId: result.data.id,
};
}

protected render(executionResult: InstallResult): React.ReactNode {
if (this.flags.quiet) {
return executionResult.extensionInstanceId;
}

return undefined;
}
}
120 changes: 120 additions & 0 deletions src/commands/extension/list-installed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { assertStatus, Simplify } from "@mittwald/api-client-commons";
import { MittwaldAPIV2, MittwaldAPIV2Client } from "@mittwald/api-client";
import { ListBaseCommand } from "../../lib/basecommands/ListBaseCommand.js";
import { ListColumns } from "../../rendering/formatter/Table.js";
import { SuccessfulResponse } from "../../lib/apiutil/SuccessfulResponse.js";
import { Flags } from "@oclif/core";
import { contextIDNormalizers } from "../../lib/context/Context.js";

type Extension = MittwaldAPIV2.Components.Schemas.MarketplaceExtension;

type ResponseItem = Simplify<
MittwaldAPIV2.Paths.V2ExtensionInstances.Get.Responses.$200.Content.ApplicationJson[number]
>;
type Response = Awaited<
ReturnType<
MittwaldAPIV2Client["marketplace"]["extensionListExtensionInstances"]
>
>;

type ExtendedResponseItem = ResponseItem & {
extension: Extension;
};

export class ListInstalled extends ListBaseCommand<
typeof ListInstalled,
ResponseItem,
Response
> {
static description =
"List installed extensions in an organization or project.";

static args = {};
static flags = {
...ListBaseCommand.baseFlags,
"org-id": Flags.string({
description: "the ID of the organization to install the extension in",
exactlyOne: ["org-id", "project-id"],
}),
"project-id": Flags.string({
description: "the ID of the project to install the extension in",
exactlyOne: ["org-id", "project-id"],
}),
};

public async getData(): Promise<Response> {
let { "org-id": orgId, "project-id": projectId } = this.flags;

if (orgId) {
const normalizer = contextIDNormalizers["org-id"]!;
orgId = await normalizer(this.apiClient, orgId);
}

if (projectId) {
const normalizer = contextIDNormalizers["project-id"]!;
projectId = await normalizer(this.apiClient, projectId);
}

return await this.apiClient.marketplace.extensionListExtensionInstances({
queryParameters: {
context: orgId ? "customer" : "project",
contextId: (orgId ?? projectId)!,
},
});
}

protected mapData(
data: SuccessfulResponse<Response, 200>["data"],
): Promise<ExtendedResponseItem[]> {
return Promise.all(
data.map(async (item) => {
const resp = await this.apiClient.marketplace.extensionGetExtension({
extensionId: item.extensionId,
});

assertStatus(resp, 200);
const extension = resp.data;

return {
...item,
extension,
};
}),
);
}

protected getColumns(data: ExtendedResponseItem[]) {
return this.getColumnsExtended(data) as ListColumns<ResponseItem>;
}

protected getColumnsExtended(
data: ExtendedResponseItem[],
): ListColumns<ExtendedResponseItem> {
const { id } = super.getColumns(data, {});
return {
id,
extension: {
header: "Extension",
get: (row) => row.extension.name,
},
state: {
header: "State",
get: (row) => {
// Temporary "as any" cast, because the API response is not typed correctly
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((row as any).pendingInstallation) {
return "installing";
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((row as any).pendingRemoval) {
return "removing";
}
if (row.disabled) {
return "disabled";
}
return "enabled";
},
},
};
}
}
32 changes: 32 additions & 0 deletions src/commands/extension/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Simplify } from "@mittwald/api-client-commons";
import { MittwaldAPIV2, MittwaldAPIV2Client } from "@mittwald/api-client";
import { ListBaseCommand } from "../../lib/basecommands/ListBaseCommand.js";
import { ListColumns } from "../../rendering/formatter/Table.js";

type ResponseItem = Simplify<
MittwaldAPIV2.Paths.V2Extensions.Get.Responses.$200.Content.ApplicationJson[number]
>;
type Response = Awaited<
ReturnType<MittwaldAPIV2Client["marketplace"]["extensionListExtensions"]>
>;

export class List extends ListBaseCommand<typeof List, ResponseItem, Response> {
static description = "Get all available extensions.";

static args = {};
static flags = {
...ListBaseCommand.baseFlags,
};

public async getData(): Promise<Response> {
return await this.apiClient.marketplace.extensionListExtensions();
}

protected getColumns(data: ResponseItem[]): ListColumns<ResponseItem> {
const { id } = super.getColumns(data, {});
return {
id,
name: {},
};
}
}
Loading