@mittwald/cli 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -107,7 +107,7 @@ USAGE
107
107
  * [`mw app`](docs/app.md) - Manage apps, and app installations in your projects
108
108
  * [`mw autocomplete`](docs/autocomplete.md) - Display autocomplete installation instructions.
109
109
  * [`mw backup`](docs/backup.md) - Manage backups of your projects
110
- * [`mw container`](docs/container.md) - Delete a container
110
+ * [`mw container`](docs/container.md) - Manage containers
111
111
  * [`mw context`](docs/context.md) - Save certain environment parameters for later use
112
112
  * [`mw conversation`](docs/conversation.md) - Manage your support cases
113
113
  * [`mw cronjob`](docs/cronjob.md) - Manage cronjobs of your projects
@@ -14,5 +14,19 @@ export declare class UpgradeApp extends ExecRenderBaseCommand<typeof UpgradeApp,
14
14
  force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
15
  };
16
16
  protected exec(): Promise<void>;
17
+ /**
18
+ * Determines the target application version based on the provided input and
19
+ * available upgrade candidates.
20
+ *
21
+ * @param currentApp The current application instance.
22
+ * @param currentAppVersion The current version of the application.
23
+ * @param targetAppVersionCandidates List of potential target application
24
+ * versions.
25
+ * @param process The process renderer to handle user interactions and display
26
+ * information.
27
+ * @returns The determined target application version, or undefined if not
28
+ * resolved.
29
+ */
30
+ private determineTargetAppVersion;
17
31
  protected render(): ReactNode;
18
32
  }
@@ -11,7 +11,7 @@ import { Success } from "../../rendering/react/components/Success.js";
11
11
  import { waitUntilAppStateHasNormalized } from "../../lib/resources/app/wait.js";
12
12
  import { assertStatus } from "@mittwald/api-client-commons";
13
13
  import { waitFlags } from "../../lib/wait.js";
14
- import semver from "semver/preload.js";
14
+ import semver from "semver";
15
15
  export class UpgradeApp extends ExecRenderBaseCommand {
16
16
  static description = "Upgrade app installation to target version";
17
17
  static args = {
@@ -19,11 +19,11 @@ export class UpgradeApp extends ExecRenderBaseCommand {
19
19
  };
20
20
  static flags = {
21
21
  "target-version": Flags.string({
22
- description: "target version to upgrade app to; if omitted, target version will be prompted interactively",
22
+ description: "target version to upgrade app to; if omitted, target version will be prompted interactively. May also be a semantic versioning range, e.g. ^1.0.0. If set to 'latest', the latest available version will be used.",
23
23
  }),
24
24
  force: Flags.boolean({
25
25
  char: "f",
26
- description: "Do not ask for confirmation.",
26
+ summary: "do not ask for confirmation.",
27
27
  }),
28
28
  ...projectFlags,
29
29
  ...processFlags,
@@ -43,25 +43,7 @@ export class UpgradeApp extends ExecRenderBaseCommand {
43
43
  process.complete(_jsxs(Text, { children: ["Your ", currentApp.name, " ", currentAppVersion.externalVersion, " is already Up-To-Date. \u2705"] }));
44
44
  return;
45
45
  }
46
- let targetAppVersion;
47
- if (this.flags["target-version"] == "latest") {
48
- targetAppVersion =
49
- (await getLatestAvailableTargetAppVersionForAppVersionUpgradeCandidates(this.apiClient, currentApp.id, currentAppVersion.id));
50
- }
51
- else if (this.flags["target-version"]) {
52
- const targetVersionMatchFromCandidates = targetAppVersionCandidates.find((targetAppVersionCandidate) => targetAppVersionCandidate.externalVersion ===
53
- this.flags["target-version"]);
54
- if (targetVersionMatchFromCandidates) {
55
- targetAppVersion = targetVersionMatchFromCandidates;
56
- }
57
- else {
58
- process.addInfo(_jsx(Text, { children: "The given target upgrade version does not seem to be a valid upgrade candidate." }));
59
- targetAppVersion = (await forceTargetVersionSelection(process, this.apiClient, targetAppVersionCandidates, currentApp, currentAppVersion));
60
- }
61
- }
62
- else {
63
- targetAppVersion = (await forceTargetVersionSelection(process, this.apiClient, targetAppVersionCandidates, currentApp, currentAppVersion));
64
- }
46
+ const targetAppVersion = await this.determineTargetAppVersion(currentApp, currentAppVersion, targetAppVersionCandidates, process);
65
47
  if (!targetAppVersion) {
66
48
  process.error("Target app version could not be determined properly.");
67
49
  ux.exit(1);
@@ -121,6 +103,37 @@ export class UpgradeApp extends ExecRenderBaseCommand {
121
103
  }
122
104
  await process.complete(_jsx(Success, { children: successText }));
123
105
  }
106
+ /**
107
+ * Determines the target application version based on the provided input and
108
+ * available upgrade candidates.
109
+ *
110
+ * @param currentApp The current application instance.
111
+ * @param currentAppVersion The current version of the application.
112
+ * @param targetAppVersionCandidates List of potential target application
113
+ * versions.
114
+ * @param process The process renderer to handle user interactions and display
115
+ * information.
116
+ * @returns The determined target application version, or undefined if not
117
+ * resolved.
118
+ */
119
+ async determineTargetAppVersion(currentApp, currentAppVersion, targetAppVersionCandidates, process) {
120
+ const targetAppVersionString = this.flags["target-version"];
121
+ if (targetAppVersionString == "latest") {
122
+ return await getLatestAvailableTargetAppVersionForAppVersionUpgradeCandidates(this.apiClient, currentApp.id, currentAppVersion.id);
123
+ }
124
+ if (targetAppVersionString) {
125
+ const exactVersionMatch = targetAppVersionCandidates.find((v) => v.externalVersion === targetAppVersionString);
126
+ if (exactVersionMatch) {
127
+ return exactVersionMatch;
128
+ }
129
+ const semverMatch = targetAppVersionCandidates.findLast((v) => semver.satisfies(v.externalVersion, targetAppVersionString));
130
+ if (semverMatch) {
131
+ return semverMatch;
132
+ }
133
+ process.addInfo(_jsx(Text, { children: "The given target upgrade version does not seem to be a valid upgrade candidate." }));
134
+ }
135
+ return await forceTargetVersionSelection(process, this.apiClient, targetAppVersionCandidates, currentApp, currentAppVersion);
136
+ }
124
137
  render() {
125
138
  return true;
126
139
  }
@@ -0,0 +1,23 @@
1
+ import { ExecRenderBaseCommand } from "../../lib/basecommands/ExecRenderBaseCommand.js";
2
+ import { ReactNode } from "react";
3
+ export default class Cp extends ExecRenderBaseCommand<typeof Cp, void> {
4
+ static summary: string;
5
+ static description: string;
6
+ static examples: string[];
7
+ static args: {
8
+ source: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
9
+ dest: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
10
+ };
11
+ static flags: {
12
+ archive: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
+ recursive: import("@oclif/core/interfaces").BooleanFlag<boolean>;
14
+ "project-id": import("@oclif/core/interfaces").OptionFlag<string>;
15
+ "ssh-user": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
16
+ "ssh-identity-file": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
17
+ quiet: import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
+ };
19
+ private parseContainerPath;
20
+ private buildScpCommand;
21
+ protected exec(): Promise<void>;
22
+ protected render(): ReactNode;
23
+ }
@@ -0,0 +1,130 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import * as fs from "fs";
3
+ import { Args, Flags } from "@oclif/core";
4
+ import { ExecRenderBaseCommand } from "../../lib/basecommands/ExecRenderBaseCommand.js";
5
+ import { getSSHConnectionForContainer } from "../../lib/resources/ssh/container.js";
6
+ import { sshConnectionFlags } from "../../lib/resources/ssh/flags.js";
7
+ import { withContainerAndStackId } from "../../lib/resources/container/flags.js";
8
+ import { projectFlags } from "../../lib/resources/project/flags.js";
9
+ import { makeProcessRenderer, processFlags, } from "../../rendering/process/process_flags.js";
10
+ import { spawnInProcess } from "../../rendering/process/process_exec.js";
11
+ import { Success } from "../../rendering/react/components/Success.js";
12
+ export default class Cp extends ExecRenderBaseCommand {
13
+ static summary = "Copy files/folders between a container and the local filesystem";
14
+ static description = `The syntax is similar to docker cp:
15
+ - Copy from container to host: mw container cp CONTAINER:SRC_PATH DEST_PATH
16
+ - Copy from host to container: mw container cp SRC_PATH CONTAINER:DEST_PATH
17
+
18
+ Where CONTAINER can be a container ID, short ID, or service name.`;
19
+ static examples = [
20
+ "# Copy a file from container to current directory\n<%= config.bin %> <%= command.id %> mycontainer:/app/config.json .",
21
+ "# Copy a file from host to container\n<%= config.bin %> <%= command.id %> ./local-file.txt mycontainer:/app/",
22
+ "# Copy a directory recursively\n<%= config.bin %> <%= command.id %> mycontainer:/var/log ./logs",
23
+ "# Copy with archive mode (preserve permissions)\n<%= config.bin %> <%= command.id %> -a mycontainer:/app/data ./backup",
24
+ ];
25
+ static args = {
26
+ source: Args.string({
27
+ description: "Source path (either local path or CONTAINER:PATH)",
28
+ required: true,
29
+ }),
30
+ dest: Args.string({
31
+ description: "Destination path (either local path or CONTAINER:PATH)",
32
+ required: true,
33
+ }),
34
+ };
35
+ static flags = {
36
+ ...processFlags,
37
+ ...sshConnectionFlags,
38
+ ...projectFlags,
39
+ archive: Flags.boolean({
40
+ char: "a",
41
+ summary: "archive mode (copy all uid/gid information)",
42
+ description: "Preserve file permissions and ownership when copying",
43
+ }),
44
+ recursive: Flags.boolean({
45
+ char: "r",
46
+ summary: "copy directories recursively",
47
+ }),
48
+ };
49
+ parseContainerPath(path) {
50
+ const colonIndex = path.indexOf(":");
51
+ if (colonIndex === -1) {
52
+ return { path };
53
+ }
54
+ const container = path.substring(0, colonIndex);
55
+ const containerPath = path.substring(colonIndex + 1);
56
+ // Ensure container path starts with / (absolute path)
57
+ const normalizedPath = containerPath.startsWith("/")
58
+ ? containerPath
59
+ : `/${containerPath}`;
60
+ return { container, path: normalizedPath };
61
+ }
62
+ buildScpCommand(source, dest, flags) {
63
+ const scpArgs = ["-o", "PasswordAuthentication=no"];
64
+ if (flags.archive) {
65
+ scpArgs.push("-p");
66
+ }
67
+ if (flags.recursive) {
68
+ scpArgs.push("-r");
69
+ }
70
+ scpArgs.push(source, dest);
71
+ return scpArgs;
72
+ }
73
+ async exec() {
74
+ const { args, flags } = await this.parse(Cp);
75
+ const sourceParsed = this.parseContainerPath(args.source);
76
+ const destParsed = this.parseContainerPath(args.dest);
77
+ // Validate that exactly one of source or dest is a container path
78
+ if (sourceParsed.container && destParsed.container) {
79
+ this.error("Cannot copy from container to container. One path must be local.");
80
+ }
81
+ if (!sourceParsed.container && !destParsed.container) {
82
+ this.error("At least one path must specify a container (CONTAINER:PATH format).");
83
+ }
84
+ const containerName = sourceParsed.container || destParsed.container;
85
+ const isDownload = !!sourceParsed.container;
86
+ const p = makeProcessRenderer(flags, `Copying files ${isDownload ? "from" : "to"} container`);
87
+ // Get container connection info
88
+ const [containerId, stackId] = await p.runStep("getting container connection info", async () => {
89
+ return withContainerAndStackId(this.apiClient, Cp, flags, { "container-id": containerName }, this.config);
90
+ });
91
+ const { host, user } = await p.runStep("establishing SSH connection", async () => {
92
+ return getSSHConnectionForContainer(this.apiClient, containerId, stackId, flags["ssh-user"]);
93
+ });
94
+ // Construct source and destination for SCP
95
+ let scpSource;
96
+ let scpDest;
97
+ if (isDownload) {
98
+ scpSource = `${user}@${host}:${sourceParsed.path}`;
99
+ scpDest = destParsed.path;
100
+ }
101
+ else {
102
+ scpSource = sourceParsed.path;
103
+ scpDest = `${user}@${host}:${destParsed.path}`;
104
+ }
105
+ // Automatically enable recursive for directories
106
+ const effectiveFlags = { ...flags };
107
+ if (!flags.recursive && !isDownload) {
108
+ try {
109
+ if (fs.existsSync(scpSource)) {
110
+ const stats = fs.statSync(scpSource);
111
+ if (stats.isDirectory()) {
112
+ effectiveFlags.recursive = true;
113
+ }
114
+ }
115
+ }
116
+ catch (ignored) {
117
+ // Ignore errors when checking if source is a directory
118
+ }
119
+ }
120
+ const scpCommand = this.buildScpCommand(scpSource, scpDest, effectiveFlags);
121
+ await spawnInProcess(p, `copying files ${isDownload ? "from" : "to"} container`, "/usr/bin/scp", scpCommand);
122
+ await p.complete(_jsx(CopySuccess, { isDownload: isDownload }));
123
+ }
124
+ render() {
125
+ return undefined;
126
+ }
127
+ }
128
+ function CopySuccess({ isDownload }) {
129
+ return (_jsxs(Success, { children: ["Files successfully ", isDownload ? "downloaded from" : "uploaded to", " ", "container! \uD83D\uDE80"] }));
130
+ }
@@ -0,0 +1,25 @@
1
+ import { ExtendedBaseCommand } from "../../lib/basecommands/ExtendedBaseCommand.js";
2
+ export default class Exec extends ExtendedBaseCommand<typeof Exec> {
3
+ static summary: string;
4
+ static description: string;
5
+ static args: {
6
+ "container-id": import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
7
+ command: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
8
+ };
9
+ static flags: {
10
+ workdir: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
+ env: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ shell: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
13
+ "project-id": import("@oclif/core/interfaces").OptionFlag<string>;
14
+ "ssh-user": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
15
+ "ssh-identity-file": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
16
+ };
17
+ /**
18
+ * Prepare environment variables for the SSH command
19
+ *
20
+ * @param envVars Array of environment variables in KEY=VALUE format
21
+ * @returns Formatted string with export commands
22
+ */
23
+ private prepareEnvironmentVariables;
24
+ run(): Promise<void>;
25
+ }
@@ -0,0 +1,93 @@
1
+ import * as child_process from "child_process";
2
+ import { Args, Flags } from "@oclif/core";
3
+ import { ExtendedBaseCommand } from "../../lib/basecommands/ExtendedBaseCommand.js";
4
+ import { getSSHConnectionForContainer } from "../../lib/resources/ssh/container.js";
5
+ import { sshConnectionFlags } from "../../lib/resources/ssh/flags.js";
6
+ import { sshUsageDocumentation } from "../../lib/resources/ssh/doc.js";
7
+ import { buildSSHClientFlags } from "../../lib/resources/ssh/connection.js";
8
+ import { withContainerAndStackId } from "../../lib/resources/container/flags.js";
9
+ import { projectFlags } from "../../lib/resources/project/flags.js";
10
+ import shellEscape from "shell-escape";
11
+ export default class Exec extends ExtendedBaseCommand {
12
+ static summary = "Execute a command in a container via SSH non-interactively.";
13
+ static description = sshUsageDocumentation;
14
+ static args = {
15
+ "container-id": Args.string({
16
+ description: "ID or short ID of the container to connect to",
17
+ required: true,
18
+ }),
19
+ command: Args.string({
20
+ description: "Command to execute in the container",
21
+ required: true,
22
+ }),
23
+ };
24
+ static flags = {
25
+ ...sshConnectionFlags,
26
+ ...projectFlags,
27
+ workdir: Flags.string({
28
+ char: "w",
29
+ summary: "working directory where the command will be executed",
30
+ default: undefined,
31
+ }),
32
+ env: Flags.string({
33
+ char: "e",
34
+ summary: "environment variables to set for the command (format: KEY=VALUE)",
35
+ multiple: true,
36
+ }),
37
+ shell: Flags.string({
38
+ summary: "shell to use for the SSH connection",
39
+ default: "/bin/sh",
40
+ }),
41
+ };
42
+ /**
43
+ * Prepare environment variables for the SSH command
44
+ *
45
+ * @param envVars Array of environment variables in KEY=VALUE format
46
+ * @returns Formatted string with export commands
47
+ */
48
+ prepareEnvironmentVariables(envVars) {
49
+ return (envVars
50
+ .map((env) => {
51
+ const eqIdx = env.indexOf("=");
52
+ if (eqIdx === -1) {
53
+ // If no '=', treat the whole string as key with empty value
54
+ return `export ${shellEscape([env])}=`;
55
+ }
56
+ const key = env.slice(0, eqIdx);
57
+ const value = env.slice(eqIdx + 1);
58
+ return `export ${shellEscape([key])}=${shellEscape([value])}`;
59
+ })
60
+ .join("; ") + "; ");
61
+ }
62
+ async run() {
63
+ const { args, flags } = await this.parse(Exec);
64
+ const [containerId, stackId] = await withContainerAndStackId(this.apiClient, Exec, flags, this.args, this.config);
65
+ const { host, user } = await getSSHConnectionForContainer(this.apiClient, containerId, stackId, flags["ssh-user"]);
66
+ this.log("executing command on %s as %s", host, user);
67
+ const command = args.command;
68
+ const workdir = flags.workdir;
69
+ // Build the command to execute
70
+ let execCommand = "";
71
+ // Add environment variables if provided
72
+ if (flags.env && flags.env.length > 0) {
73
+ execCommand += this.prepareEnvironmentVariables(flags.env);
74
+ }
75
+ // Change to working directory if specified
76
+ if (workdir !== undefined) {
77
+ execCommand += `cd ${shellEscape([workdir])} && `;
78
+ }
79
+ // Add the actual command
80
+ execCommand += command;
81
+ const sshArgs = buildSSHClientFlags(user, host, flags, {
82
+ interactive: false,
83
+ });
84
+ const wrappedExecCommand = shellEscape([flags.shell, "-c", execCommand]);
85
+ this.debug("running ssh %o, with command %o", sshArgs, wrappedExecCommand);
86
+ const result = child_process.spawnSync("/usr/bin/ssh", [...sshArgs, wrappedExecCommand], {
87
+ stdio: "inherit",
88
+ });
89
+ if (result.status !== 0) {
90
+ this.error(`Command failed with exit code ${result.status}`);
91
+ }
92
+ }
93
+ }
@@ -0,0 +1,20 @@
1
+ import { ExecRenderBaseCommand } from "../../lib/basecommands/ExecRenderBaseCommand.js";
2
+ import { ReactNode } from "react";
3
+ import PortMapping from "../../lib/units/PortMapping.js";
4
+ export declare class PortForward extends ExecRenderBaseCommand<typeof PortForward, Record<string, never>> {
5
+ static summary: string;
6
+ static description: string;
7
+ static flags: {
8
+ "project-id": import("@oclif/core/interfaces").OptionFlag<string>;
9
+ "ssh-user": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
+ "ssh-identity-file": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
+ quiet: import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
+ };
13
+ static args: {
14
+ "container-id": import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
15
+ port: import("@oclif/core/interfaces").Arg<PortMapping | undefined, Record<string, unknown>>;
16
+ };
17
+ protected exec(): Promise<Record<string, never>>;
18
+ private getPortMappings;
19
+ protected render(): ReactNode;
20
+ }
@@ -0,0 +1,68 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { ExecRenderBaseCommand } from "../../lib/basecommands/ExecRenderBaseCommand.js";
3
+ import { makeProcessRenderer, processFlags, } from "../../rendering/process/process_flags.js";
4
+ import * as cp from "child_process";
5
+ import { Box, Text } from "ink";
6
+ import { Value } from "../../rendering/react/components/Value.js";
7
+ import { Args } from "@oclif/core";
8
+ import { sshConnectionFlags } from "../../lib/resources/ssh/flags.js";
9
+ import { sshUsageDocumentation } from "../../lib/resources/ssh/doc.js";
10
+ import { buildSSHClientFlags } from "../../lib/resources/ssh/connection.js";
11
+ import { withContainerAndStackId } from "../../lib/resources/container/flags.js";
12
+ import { getSSHConnectionForContainer } from "../../lib/resources/ssh/container.js";
13
+ import { projectFlags } from "../../lib/resources/project/flags.js";
14
+ import PortMapping from "../../lib/units/PortMapping.js";
15
+ import { assertStatus } from "@mittwald/api-client";
16
+ export class PortForward extends ExecRenderBaseCommand {
17
+ static summary = "Forward a container port to a local port";
18
+ static description = "This command forwards a TCP port from a container to a local port on your machine. This allows you to connect to services running in the container as if they were running on your local machine.\n\n" +
19
+ sshUsageDocumentation;
20
+ static flags = {
21
+ ...processFlags,
22
+ ...sshConnectionFlags,
23
+ ...projectFlags,
24
+ };
25
+ static args = {
26
+ "container-id": Args.string({
27
+ description: "ID or short ID of the container to connect to",
28
+ required: true,
29
+ }),
30
+ port: PortMapping.arg({
31
+ summary: "Port mapping in the format 'local-port:container-port'",
32
+ description: "Specifies the port mapping between your local machine and the container. Format: 'local-port:container-port'. If not specified, available ports will be detected automatically.",
33
+ required: false,
34
+ }),
35
+ };
36
+ async exec() {
37
+ const [serviceId, stackId] = await withContainerAndStackId(this.apiClient, PortForward, this.flags, this.args, this.config);
38
+ const p = makeProcessRenderer(this.flags, "Port-forwarding a container");
39
+ const { host, user } = await getSSHConnectionForContainer(this.apiClient, serviceId, stackId, this.flags["ssh-user"]);
40
+ const portMappings = await this.getPortMappings(stackId, serviceId);
41
+ await p.complete(_jsxs(Box, { flexDirection: "column", children: [portMappings.map((p, idx) => (_jsxs(Text, { children: ["Forwarding container port ", _jsx(Value, { children: p.remotePort }), " to local port ", _jsx(Value, { children: p.localPort }), "."] }, idx))), _jsx(Text, { children: "Use CTRL+C to cancel." })] }));
42
+ const sshArgs = buildSSHClientFlags(user, host, this.flags, {
43
+ interactive: false,
44
+ additionalFlags: portMappings
45
+ .map((p) => ["-L", `${p.localPort}:localhost:${p.remotePort}`])
46
+ .flat(),
47
+ });
48
+ cp.spawnSync("ssh", [...sshArgs, "cat", "/dev/zero"], {
49
+ stdio: ["ignore", process.stdout, process.stderr],
50
+ });
51
+ return {};
52
+ }
53
+ async getPortMappings(stackId, serviceId) {
54
+ if (this.args.port) {
55
+ return [this.args.port];
56
+ }
57
+ const containerResponse = await this.apiClient.container.getService({
58
+ stackId,
59
+ serviceId,
60
+ });
61
+ assertStatus(containerResponse, 200);
62
+ const ports = containerResponse.data.deployedState.ports ?? [];
63
+ return ports.map((p) => PortMapping.fromPortAndProtocol(p));
64
+ }
65
+ render() {
66
+ return undefined;
67
+ }
68
+ }
@@ -5,6 +5,8 @@ type Result = {
5
5
  };
6
6
  export declare class Run extends ExecRenderBaseCommand<typeof Run, Result> {
7
7
  static summary: string;
8
+ static strict: boolean;
9
+ static usage: string;
8
10
  static flags: {
9
11
  env: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
10
12
  "env-file": import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
@@ -24,6 +26,16 @@ export declare class Run extends ExecRenderBaseCommand<typeof Run, Result> {
24
26
  };
25
27
  protected exec(): Promise<Result>;
26
28
  private addServiceToStack;
29
+ /**
30
+ * Builds and returns the container command based on the provided image
31
+ * metadata and arguments.
32
+ *
33
+ * @param imageMeta The configuration object containing the metadata of the
34
+ * container image, including the default command.
35
+ * @returns An array of strings representing the container command to execute,
36
+ * or undefined if no specific command is set.
37
+ */
38
+ private buildContainerCommand;
27
39
  /**
28
40
  * Builds a container service request from command line arguments and image
29
41
  * metadata
@@ -3,13 +3,16 @@ import { Args, Flags } from "@oclif/core";
3
3
  import { ExecRenderBaseCommand } from "../../lib/basecommands/ExecRenderBaseCommand.js";
4
4
  import { makeProcessRenderer, processFlags, } from "../../rendering/process/process_flags.js";
5
5
  import { projectFlags } from "../../lib/resources/project/flags.js";
6
+ import dockerNames from "docker-names";
7
+ import { assertStatus } from "@mittwald/api-client";
8
+ import { getImageMeta, getPortMappings, parseEnvironmentVariables, } from "../../lib/resources/container/containerconfig.js";
6
9
  import { Success } from "../../rendering/react/components/Success.js";
7
10
  import { Value } from "../../rendering/react/components/Value.js";
8
- import * as dockerNames from "docker-names";
9
- import { assertStatus } from "@mittwald/api-client";
10
- import { parseEnvironmentVariables, getPortMappings, getImageMeta, } from "../../lib/resources/container/containerconfig.js";
11
11
  export class Run extends ExecRenderBaseCommand {
12
12
  static summary = "Creates and starts a new container.";
13
+ static strict = false;
14
+ // Usage needs to be overwritten because the autogenerated one is incorrect due to the variadic arguments.
15
+ static usage = "container run [--token <value>] [-q] [-p <value>] [-e <value>...] [--env-file <value>...] [--description <value>] [--entrypoint <value>] [--name <value>] [-p <value>...] [-P] [-v <value>...] IMAGE [COMMAND] [ARGS...]";
13
16
  static flags = {
14
17
  ...processFlags,
15
18
  ...projectFlags,
@@ -45,11 +48,11 @@ export class Run extends ExecRenderBaseCommand {
45
48
  summary: "publish a container's port(s) to the host",
46
49
  description: "Map a container's port to a port on the host system. " +
47
50
  "Format: <host-port>:<container-port> or just <container-port> (in which case the host port will be automatically assigned). " +
48
- "For example, -p 8080:80 maps port 80 in the container to port 8080 on the host. " +
49
- "Use multiple -p flags to publish multiple ports.",
51
+ "For example, --publish 8080:80 maps port 80 in the container to port 8080 on the host. " +
52
+ "Use multiple --publish flags to publish multiple ports.\n\n" +
53
+ "NOTE: Please note that the usual shorthand -p is not supported for this flag, as it would conflict with the --project flag.",
50
54
  required: false,
51
55
  multiple: true,
52
- char: "p",
53
56
  }),
54
57
  "publish-all": Flags.boolean({
55
58
  summary: "publish all ports that are defined in the image",
@@ -85,7 +88,6 @@ export class Run extends ExecRenderBaseCommand {
85
88
  summary: "arguments to pass to the command",
86
89
  description: "These are the runtime arguments passed to the command specified by the command parameter or the container's default command, not to the container itself. For example, if the command is 'echo', the args might be 'hello world'.",
87
90
  required: false,
88
- variadic: true,
89
91
  }),
90
92
  };
91
93
  async exec() {
@@ -116,6 +118,23 @@ export class Run extends ExecRenderBaseCommand {
116
118
  assertStatus(resp, 200);
117
119
  return resp.data;
118
120
  }
121
+ /**
122
+ * Builds and returns the container command based on the provided image
123
+ * metadata and arguments.
124
+ *
125
+ * @param imageMeta The configuration object containing the metadata of the
126
+ * container image, including the default command.
127
+ * @returns An array of strings representing the container command to execute,
128
+ * or undefined if no specific command is set.
129
+ */
130
+ buildContainerCommand(imageMeta) {
131
+ if (!this.args.command) {
132
+ return imageMeta.command;
133
+ }
134
+ const firstArg = (this.argv.lastIndexOf(this.args.command) ?? Infinity) + 1;
135
+ const command = [this.args.command, ...this.argv.slice(firstArg)];
136
+ return command;
137
+ }
119
138
  /**
120
139
  * Builds a container service request from command line arguments and image
121
140
  * metadata
@@ -126,7 +145,7 @@ export class Run extends ExecRenderBaseCommand {
126
145
  * @returns A properly formatted container service request
127
146
  */
128
147
  async buildServiceRequest(image, imageMeta, serviceName) {
129
- const command = this.args.command ? [this.args.command] : imageMeta.command;
148
+ const command = this.buildContainerCommand(imageMeta);
130
149
  const entrypoint = this.flags.entrypoint
131
150
  ? [this.flags.entrypoint]
132
151
  : imageMeta.entrypoint;
@@ -0,0 +1,17 @@
1
+ import { ExtendedBaseCommand } from "../../lib/basecommands/ExtendedBaseCommand.js";
2
+ export default class Ssh extends ExtendedBaseCommand<typeof Ssh> {
3
+ static summary: string;
4
+ static description: string;
5
+ static args: {
6
+ "container-id": import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
7
+ };
8
+ static flags: {
9
+ info: import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
+ test: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ shell: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
12
+ "project-id": import("@oclif/core/interfaces").OptionFlag<string>;
13
+ "ssh-user": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
14
+ "ssh-identity-file": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
15
+ };
16
+ run(): Promise<void>;
17
+ }
@@ -0,0 +1,61 @@
1
+ import * as child_process from "child_process";
2
+ import { Args, Flags } from "@oclif/core";
3
+ import { ExtendedBaseCommand } from "../../lib/basecommands/ExtendedBaseCommand.js";
4
+ import { getSSHConnectionForContainer } from "../../lib/resources/ssh/container.js";
5
+ import { sshConnectionFlags, } from "../../lib/resources/ssh/flags.js";
6
+ import { sshWrapperDocumentation } from "../../lib/resources/ssh/doc.js";
7
+ import { buildSSHClientFlags } from "../../lib/resources/ssh/connection.js";
8
+ import { withContainerAndStackId } from "../../lib/resources/container/flags.js";
9
+ import { projectFlags } from "../../lib/resources/project/flags.js";
10
+ export default class Ssh extends ExtendedBaseCommand {
11
+ static summary = "Connect to a container via SSH";
12
+ static description = "Establishes an interactive SSH connection to a container.\n\n" +
13
+ sshWrapperDocumentation;
14
+ static args = {
15
+ "container-id": Args.string({
16
+ description: "ID or short ID of the container to connect to",
17
+ required: true,
18
+ }),
19
+ };
20
+ static flags = {
21
+ ...sshConnectionFlags,
22
+ ...projectFlags,
23
+ info: Flags.boolean({
24
+ summary: "only print connection information, without actually connecting",
25
+ }),
26
+ test: Flags.boolean({
27
+ summary: "test connection and exit",
28
+ }),
29
+ shell: Flags.string({
30
+ summary: "shell to use for the SSH connection",
31
+ default: "/bin/sh",
32
+ }),
33
+ };
34
+ async run() {
35
+ const { flags } = await this.parse(Ssh);
36
+ const [containerId, stackId] = await withContainerAndStackId(this.apiClient, Ssh, flags, this.args, this.config);
37
+ const { host, user, directory } = await getSSHConnectionForContainer(this.apiClient, containerId, stackId, flags["ssh-user"]);
38
+ if (flags.info) {
39
+ this.log("hostname: %o", host);
40
+ this.log("username: %o", user);
41
+ this.log("directory: %o", directory);
42
+ return;
43
+ }
44
+ this.log("connecting to %o as %o", host, user);
45
+ const [cmd, args] = buildSSHCmdAndFlags(user, host, flags);
46
+ this.debug("running ssh %o, with command %o", args, cmd);
47
+ child_process.spawnSync("/usr/bin/ssh", [...args, cmd], {
48
+ stdio: "inherit",
49
+ });
50
+ }
51
+ }
52
+ function buildSSHCmdAndFlags(user, host, flags) {
53
+ const args = buildSSHClientFlags(user, host, flags, {
54
+ interactive: true,
55
+ additionalFlags: flags.test ? ["-q"] : [],
56
+ });
57
+ if (flags.test) {
58
+ return ["/bin/true", args];
59
+ }
60
+ return [flags.shell, args];
61
+ }
@@ -6,6 +6,7 @@ import { Success } from "../../../rendering/react/components/Success.js";
6
6
  import { dnsZoneArgs, withDnsZoneId, } from "../../../lib/resources/domain/dnszone/flags.js";
7
7
  import { projectFlags } from "../../../lib/resources/project/flags.js";
8
8
  import { assertStatus } from "@mittwald/api-client-commons";
9
+ import assertSuccess from "../../../lib/apiutil/assert_success.js";
9
10
  export default class Update extends ExecRenderBaseCommand {
10
11
  static description = "Updates a record set of a DNS zone";
11
12
  static args = {
@@ -163,9 +164,9 @@ export default class Update extends ExecRenderBaseCommand {
163
164
  dnsZoneId,
164
165
  recordSet,
165
166
  });
166
- assertStatus(r, 204);
167
+ assertSuccess(r);
167
168
  });
168
- process.complete(_jsx(Success, { children: "DNS record set successfully reset to fully managed." }));
169
+ await process.complete(_jsx(Success, { children: "DNS record set successfully reset to fully managed." }));
169
170
  }
170
171
  render() {
171
172
  return undefined;
@@ -5,6 +5,7 @@ import { CoreBaseCommand } from "./CoreBaseCommand.js";
5
5
  import { configureAxiosLogging } from "../apiutil/api_logging.js";
6
6
  import { configureAxiosRetry } from "../apiutil/api_retry.js";
7
7
  import { configureConsistencyHandling } from "../apiutil/api_consistency.js";
8
+ import NoTokenFoundError from "../error/NoTokenFoundError.js";
8
9
  /** Base command class for authenticated commands that includes the --token flag. */
9
10
  export class BaseCommand extends CoreBaseCommand {
10
11
  static baseFlags = {
@@ -21,7 +22,7 @@ export class BaseCommand extends CoreBaseCommand {
21
22
  const { flags } = await this.parse();
22
23
  const token = await this.getEffectiveTokenWithFlag(flags);
23
24
  if (token === undefined) {
24
- throw new Error(`Could not get token from --token flag, MITTWALD_API_TOKEN env var, or config file (${getTokenFilename(this.config)}). Please run "mw login token" or use --token.`);
25
+ throw new NoTokenFoundError(getTokenFilename(this.config));
25
26
  }
26
27
  this.apiClient = MittwaldAPIV2Client.newWithToken(token);
27
28
  this.apiClient.axios.defaults.headers["User-Agent"] =
@@ -0,0 +1,11 @@
1
+ /**
2
+ * This class represents a custom error thrown when no token can be found. The
3
+ * error specifically occurs if the token is not provided via the --token flag,
4
+ * the MITTWALD_API_TOKEN environment variable, or the configuration file.
5
+ *
6
+ * This is a dedicated error class so that the error handler can check for this
7
+ * specific type of error and present it differently to the user.
8
+ */
9
+ export default class NoTokenFoundError extends Error {
10
+ constructor(tokenFilename: string);
11
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * This class represents a custom error thrown when no token can be found. The
3
+ * error specifically occurs if the token is not provided via the --token flag,
4
+ * the MITTWALD_API_TOKEN environment variable, or the configuration file.
5
+ *
6
+ * This is a dedicated error class so that the error handler can check for this
7
+ * specific type of error and present it differently to the user.
8
+ */
9
+ export default class NoTokenFoundError extends Error {
10
+ constructor(tokenFilename) {
11
+ super(`Could not get token from --token flag, MITTWALD_API_TOKEN env var, or config file (${tokenFilename}). Please run "mw login token" or use --token.`);
12
+ }
13
+ }
@@ -0,0 +1,3 @@
1
+ import { MittwaldAPIV2Client } from "@mittwald/api-client";
2
+ import { SSHConnectionData } from "./types.js";
3
+ export declare function getSSHConnectionForContainer(client: MittwaldAPIV2Client, containerId: string, stackId: string, sshUser: string | undefined): Promise<SSHConnectionData>;
@@ -0,0 +1,29 @@
1
+ import { assertStatus } from "@mittwald/api-client";
2
+ export async function getSSHConnectionForContainer(client, containerId, stackId, sshUser) {
3
+ // Get container details
4
+ const containerResponse = await client.container.getService({
5
+ stackId,
6
+ serviceId: containerId,
7
+ });
8
+ assertStatus(containerResponse, 200);
9
+ // Get project details
10
+ const projectResponse = await client.project.getProject({
11
+ projectId: containerResponse.data.projectId,
12
+ });
13
+ assertStatus(projectResponse, 200);
14
+ // If no SSH user is provided, use the current user's email
15
+ if (sshUser === undefined) {
16
+ const userResponse = await client.user.getUser({ userId: "self" });
17
+ assertStatus(userResponse, 200);
18
+ sshUser = userResponse.data.email;
19
+ }
20
+ // Construct the SSH connection data
21
+ const host = `ssh.${projectResponse.data.clusterID}.${projectResponse.data.clusterDomain}`;
22
+ const user = `${sshUser}@${containerResponse.data.shortId}`;
23
+ const directory = "/";
24
+ return {
25
+ host,
26
+ user,
27
+ directory,
28
+ };
29
+ }
@@ -0,0 +1,11 @@
1
+ /** Represents a mapping between a local port and a remote port. */
2
+ export default class PortMapping {
3
+ readonly localPort: number;
4
+ readonly remotePort: number;
5
+ constructor(localPort: number, remotePort: number);
6
+ private static validatePort;
7
+ static arg: import("@oclif/core/interfaces").ArgDefinition<PortMapping, Record<string, unknown>>;
8
+ /** @param str Port and protocol; example: `8080/tcp` */
9
+ static fromPortAndProtocol(str: string): PortMapping;
10
+ static fromString(str: string): PortMapping;
11
+ }
@@ -0,0 +1,38 @@
1
+ import { Args } from "@oclif/core";
2
+ /** Represents a mapping between a local port and a remote port. */
3
+ export default class PortMapping {
4
+ localPort;
5
+ remotePort;
6
+ constructor(localPort, remotePort) {
7
+ this.localPort = localPort;
8
+ this.remotePort = remotePort;
9
+ }
10
+ static validatePort(port) {
11
+ return !isNaN(port) && port > 0 && port <= 65535;
12
+ }
13
+ static arg = Args.custom({
14
+ parse: async (input) => PortMapping.fromString(input),
15
+ });
16
+ /** @param str Port and protocol; example: `8080/tcp` */
17
+ static fromPortAndProtocol(str) {
18
+ const [localPort, protocol] = str.split("/");
19
+ const portNum = parseInt(localPort);
20
+ if (!PortMapping.validatePort(portNum)) {
21
+ throw new Error("Invalid port number. Ports must be between 1 and 65535.");
22
+ }
23
+ if (protocol.toLowerCase() !== "tcp") {
24
+ throw new Error("Only TCP protocol is supported.");
25
+ }
26
+ return new PortMapping(portNum, portNum);
27
+ }
28
+ static fromString(str) {
29
+ const [localPort, remotePort] = str.split(":");
30
+ const localPortNum = parseInt(localPort);
31
+ const remotePortNum = parseInt(remotePort);
32
+ if (!PortMapping.validatePort(localPortNum) ||
33
+ !PortMapping.validatePort(remotePortNum)) {
34
+ throw new Error("Invalid port number. Ports must be between 1 and 65535.");
35
+ }
36
+ return new PortMapping(localPortNum, remotePortNum);
37
+ }
38
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "@jest/globals";
2
+ import PortMapping from "./PortMapping.js";
3
+ describe("PortMapping", () => {
4
+ // Test: Successfully create PortMapping instance using fromString
5
+ it("should correctly parse valid port mapping string", () => {
6
+ const result = PortMapping.fromString("8080:9090");
7
+ expect(result.localPort).toBe(8080);
8
+ expect(result.remotePort).toBe(9090);
9
+ });
10
+ // Test: Throws an error for invalid local port
11
+ it("should throw an error for invalid local port", () => {
12
+ expect(() => PortMapping.fromString("100000:8080")).toThrow("Invalid port number. Ports must be between 1 and 65535.");
13
+ });
14
+ // Test: Throws an error for invalid remote port
15
+ it("should throw an error for invalid remote port", () => {
16
+ expect(() => PortMapping.fromString("8080:70000")).toThrow("Invalid port number. Ports must be between 1 and 65535.");
17
+ });
18
+ // Test: Throws an error when input format is incorrect
19
+ it("should throw an error for invalid string format", () => {
20
+ expect(() => PortMapping.fromString("8080-9090")).toThrow("Invalid port number. Ports must be between 1 and 65535.");
21
+ });
22
+ // Test: Successfully assign local and remote ports via constructor
23
+ it("should correctly initialize PortMapping with valid ports", () => {
24
+ const portMapping = new PortMapping(3000, 4000);
25
+ expect(portMapping.localPort).toBe(3000);
26
+ expect(portMapping.remotePort).toBe(4000);
27
+ });
28
+ });
@@ -6,6 +6,7 @@ import GenericError from "./Error/GenericError.js";
6
6
  import APIError from "./Error/APIError.js";
7
7
  import UnexpectedShortIDPassedErrorBox from "./Error/UnexpectedShortIDPassedErrorBox.js";
8
8
  import { MissingArgError, MissingFlagError, } from "../../../lib/context/FlagSetBuilder.js";
9
+ import NoTokenFoundError from "../../../lib/error/NoTokenFoundError.js";
9
10
  /**
10
11
  * Render an error to the terminal.
11
12
  *
@@ -27,6 +28,9 @@ export const ErrorBox = ({ err }) => {
27
28
  err instanceof MissingFlagError) {
28
29
  return (_jsx(GenericError, { err: err, withStack: false, withIssue: false, title: "Input required" }));
29
30
  }
31
+ else if (err instanceof NoTokenFoundError) {
32
+ return (_jsx(GenericError, { err: err, withStack: false, withIssue: false, title: "Login required" }));
33
+ }
30
34
  else if (err instanceof UnexpectedShortIDPassedError) {
31
35
  return _jsx(UnexpectedShortIDPassedErrorBox, { err: err });
32
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mittwald/cli",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Hand-crafted CLI for the mittwald API",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -68,7 +68,7 @@
68
68
  "semver-parser": "^4.1.6",
69
69
  "shell-escape": "^0.2.0",
70
70
  "slice-ansi": "^7.1.0",
71
- "string-width": "^7.2.0",
71
+ "string-width": "^8.0.0",
72
72
  "tempfile": "^5.0.0",
73
73
  "uuid": "^11.0.3"
74
74
  },
@@ -94,7 +94,7 @@
94
94
  "oclif": "^4.14.31",
95
95
  "prettier": "~3.6.2",
96
96
  "prettier-plugin-jsdoc": "^1.3.2",
97
- "prettier-plugin-package": "^1.4.0",
97
+ "prettier-plugin-package": "^2.0.0",
98
98
  "prettier-plugin-sort-json": "^4.1.1",
99
99
  "rimraf": "^5.0.1",
100
100
  "ts-jest": "^29.2.5",
@@ -117,6 +117,9 @@
117
117
  "app": {
118
118
  "description": "Manage apps, and app installations in your projects",
119
119
  "subtopics": {
120
+ "create": {
121
+ "description": "Create new self-managed apps in your projects"
122
+ },
120
123
  "install": {
121
124
  "description": "Install apps in your projects"
122
125
  },
@@ -128,9 +131,6 @@
128
131
  "registry": {
129
132
  "description": "Manage container registries"
130
133
  },
131
- "article": {
132
- "description": "Query available hosting articles"
133
- },
134
134
  "backup": {
135
135
  "description": "Manage backups of your projects",
136
136
  "subtopics": {
@@ -139,12 +139,12 @@
139
139
  }
140
140
  }
141
141
  },
142
+ "container": {
143
+ "description": "Manage containers"
144
+ },
142
145
  "context": {
143
146
  "description": "Save certain environment parameters for later use"
144
147
  },
145
- "contract": {
146
- "description": "Manage your hosting contracts, and order new ones"
147
- },
148
148
  "conversation": {
149
149
  "description": "Manage your support cases"
150
150
  },
@@ -157,16 +157,42 @@
157
157
  }
158
158
  },
159
159
  "cronjob": {
160
- "description": "Manage cronjobs of your projects"
160
+ "description": "Manage cronjobs of your projects",
161
+ "subtopics": {
162
+ "execution": {
163
+ "description": "Manage individual cronjob executions"
164
+ }
165
+ }
161
166
  },
162
167
  "database": {
163
- "description": "Manage databases (like MySQL and Redis) in your projects"
168
+ "description": "Manage databases (like MySQL and Redis) in your projects",
169
+ "subtopics": {
170
+ "mysql": {
171
+ "description": "Manage MySQL databases in your projects",
172
+ "subtopics": {
173
+ "user": {
174
+ "description": "Manage MySQL database users"
175
+ }
176
+ }
177
+ },
178
+ "redis": {
179
+ "description": "Manage Redis databases in your projects"
180
+ }
181
+ }
164
182
  },
165
183
  "ddev": {
166
184
  "description": "Integrate your mittwald projects with DDEV"
167
185
  },
168
186
  "domain": {
169
- "description": "Manage domains, virtual hosts and DNS settings in your projects"
187
+ "description": "Manage domains, virtual hosts and DNS settings in your projects",
188
+ "subtopics": {
189
+ "dnszone": {
190
+ "description": "Manage DNS zones for your domains"
191
+ },
192
+ "virtualhost": {
193
+ "description": "Manage virtual hosts for your domains"
194
+ }
195
+ }
170
196
  },
171
197
  "extension": {
172
198
  "description": "Install and manage extensions in your organisations and projects"
@@ -175,10 +201,26 @@
175
201
  "description": "Manage your client authentication"
176
202
  },
177
203
  "mail": {
178
- "description": "Manage mailboxes and mail addresses in your projects"
204
+ "description": "Manage mailboxes and mail addresses in your projects",
205
+ "subtopics": {
206
+ "address": {
207
+ "description": "Manage mail addresses in your projects"
208
+ },
209
+ "deliverybox": {
210
+ "description": "Manage mail delivery boxes in your projects"
211
+ }
212
+ }
179
213
  },
180
214
  "org": {
181
- "description": "Manage your organizations, and also any kinds of user memberships concerning these organizations."
215
+ "description": "Manage your organizations, and also any kinds of user memberships concerning these organizations.",
216
+ "subtopics": {
217
+ "invite": {
218
+ "description": "Invite users to your organizations and manage their invitations"
219
+ },
220
+ "membership": {
221
+ "description": "Control who gets to work in your organizations, and who doesn't"
222
+ }
223
+ }
182
224
  },
183
225
  "project": {
184
226
  "description": "Manage your projects, and also any kinds of user memberships concerning these projects.",
@@ -209,6 +251,12 @@
209
251
  "user": {
210
252
  "description": "Manage your own user account",
211
253
  "subtopics": {
254
+ "api-token": {
255
+ "description": "Manage your API tokens"
256
+ },
257
+ "session": {
258
+ "description": "Manage your active sessions"
259
+ },
212
260
  "ssh-key": {
213
261
  "description": "Manage your SSH keys"
214
262
  }