@mittwald/cli 1.8.1 → 1.9.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
@@ -127,5 +127,6 @@ USAGE
127
127
  * [`mw stack`](docs/stack.md) - Manage container stacks
128
128
  * [`mw update`](docs/update.md) - update the mw CLI
129
129
  * [`mw user`](docs/user.md) - Manage your own user account
130
+ * [`mw volume`](docs/volume.md) - Manage volumes
130
131
 
131
132
  <!-- commandsstop -->
@@ -16,6 +16,7 @@ export declare class Run extends ExecRenderBaseCommand<typeof Run, Result> {
16
16
  publish: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
17
17
  "publish-all": import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
18
  volume: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
19
+ "create-volumes": import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
20
  "project-id": import("@oclif/core/interfaces").OptionFlag<string>;
20
21
  quiet: import("@oclif/core/interfaces").BooleanFlag<boolean>;
21
22
  };
@@ -26,6 +27,13 @@ export declare class Run extends ExecRenderBaseCommand<typeof Run, Result> {
26
27
  };
27
28
  protected exec(): Promise<Result>;
28
29
  private addServiceToStack;
30
+ /**
31
+ * Gets the list of named volumes that need to be created.
32
+ *
33
+ * @param stackId The stack ID to check existing volumes against
34
+ * @returns Array of volume names that need to be created
35
+ */
36
+ private getVolumesToCreate;
29
37
  /**
30
38
  * Builds and returns the container command based on the provided image
31
39
  * metadata and arguments.
@@ -21,12 +21,14 @@ export class Run extends ExecRenderBaseCommand {
21
21
  description: "Format: KEY=VALUE. Multiple environment variables can be specified with multiple --env flags.",
22
22
  required: false,
23
23
  multiple: true,
24
+ multipleNonGreedy: true,
24
25
  char: "e",
25
26
  }),
26
27
  "env-file": Flags.string({
27
28
  summary: "read environment variables from a file",
28
29
  description: "The file should contain lines in the format KEY=VALUE. Multiple files can be specified with multiple --env-file flags.",
29
30
  multiple: true,
31
+ multipleNonGreedy: true,
30
32
  required: false,
31
33
  }),
32
34
  description: Flags.string({
@@ -53,6 +55,7 @@ export class Run extends ExecRenderBaseCommand {
53
55
  "NOTE: Please note that the usual shorthand -p is not supported for this flag, as it would conflict with the --project flag.",
54
56
  required: false,
55
57
  multiple: true,
58
+ multipleNonGreedy: true,
56
59
  }),
57
60
  "publish-all": Flags.boolean({
58
61
  summary: "publish all ports that are defined in the image",
@@ -71,6 +74,13 @@ export class Run extends ExecRenderBaseCommand {
71
74
  required: false,
72
75
  char: "v",
73
76
  multiple: true,
77
+ multipleNonGreedy: true,
78
+ }),
79
+ "create-volumes": Flags.boolean({
80
+ summary: "automatically create named volumes that do not exist",
81
+ description: "When enabled, any named volumes referenced in --volume flags that do not already exist will be automatically created before starting the container.",
82
+ required: false,
83
+ default: false,
74
84
  }),
75
85
  };
76
86
  static args = {
@@ -107,17 +117,52 @@ export class Run extends ExecRenderBaseCommand {
107
117
  return { serviceId };
108
118
  }
109
119
  async addServiceToStack(stackId, serviceName, serviceRequest) {
120
+ const updateData = {
121
+ services: {
122
+ [serviceName]: serviceRequest,
123
+ },
124
+ };
125
+ // If create-volumes flag is enabled, add missing volumes to the same call
126
+ if (this.flags["create-volumes"] && this.flags.volume) {
127
+ const volumesToCreate = await this.getVolumesToCreate(stackId);
128
+ if (volumesToCreate.length > 0) {
129
+ updateData.volumes = Object.fromEntries(volumesToCreate.map((name) => [name, { name }]));
130
+ }
131
+ }
110
132
  const resp = await this.apiClient.container.updateStack({
111
133
  stackId,
112
- data: {
113
- services: {
114
- [serviceName]: serviceRequest,
115
- },
116
- },
134
+ data: updateData,
117
135
  });
118
136
  assertStatus(resp, 200);
119
137
  return resp.data;
120
138
  }
139
+ /**
140
+ * Gets the list of named volumes that need to be created.
141
+ *
142
+ * @param stackId The stack ID to check existing volumes against
143
+ * @returns Array of volume names that need to be created
144
+ */
145
+ async getVolumesToCreate(stackId) {
146
+ if (!this.flags.volume) {
147
+ return [];
148
+ }
149
+ // Get current stack state to check existing volumes
150
+ const currentStackResp = await this.apiClient.container.getStack({
151
+ stackId,
152
+ });
153
+ assertStatus(currentStackResp, 200);
154
+ const existingVolumes = (currentStackResp.data.volumes || []).map((v) => v.name);
155
+ // Parse volume flags to extract named volumes (not file paths)
156
+ const namedVolumes = new Set(this.flags.volume
157
+ .filter((volume) => {
158
+ const [source] = volume.split(":");
159
+ // Named volumes typically don't start with /
160
+ return source && !source.startsWith("/");
161
+ })
162
+ .map((volume) => volume.split(":")[0]));
163
+ // Find volumes that need to be created
164
+ return Array.from(namedVolumes).filter((volumeName) => !existingVolumes.includes(volumeName));
165
+ }
121
166
  /**
122
167
  * Builds and returns the container command based on the provided image
123
168
  * metadata and arguments.
@@ -150,7 +195,7 @@ export class Run extends ExecRenderBaseCommand {
150
195
  ? [this.flags.entrypoint]
151
196
  : imageMeta.entrypoint;
152
197
  const description = this.flags.description ?? serviceName;
153
- const envs = await parseEnvironmentVariables(this.flags.env, this.flags["env-file"]);
198
+ const environment = await parseEnvironmentVariables(this.flags.env, this.flags["env-file"]);
154
199
  const ports = getPortMappings(imageMeta, this.flags["publish-all"], this.flags.publish);
155
200
  const volumes = this.flags.volume;
156
201
  return {
@@ -158,7 +203,7 @@ export class Run extends ExecRenderBaseCommand {
158
203
  command,
159
204
  entrypoint,
160
205
  description,
161
- envs,
206
+ environment,
162
207
  ports,
163
208
  volumes,
164
209
  };
@@ -0,0 +1,20 @@
1
+ import { ExecRenderBaseCommand } from "../../lib/basecommands/ExecRenderBaseCommand.js";
2
+ import { ReactNode } from "react";
3
+ type Result = {
4
+ volumeName: string;
5
+ stackId: string;
6
+ };
7
+ export declare class Create extends ExecRenderBaseCommand<typeof Create, Result> {
8
+ static summary: string;
9
+ static description: string;
10
+ static flags: {
11
+ quiet: import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
+ "project-id": import("@oclif/core/interfaces").OptionFlag<string>;
13
+ };
14
+ static args: {
15
+ name: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
16
+ };
17
+ protected exec(): Promise<Result>;
18
+ protected render({ volumeName }: Result): ReactNode;
19
+ }
20
+ export {};
@@ -0,0 +1,57 @@
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 { Args } from "@oclif/core";
5
+ import { assertStatus } from "@mittwald/api-client-commons";
6
+ import { Success } from "../../rendering/react/components/Success.js";
7
+ import { Value } from "../../rendering/react/components/Value.js";
8
+ import { projectFlags } from "../../lib/resources/project/flags.js";
9
+ export class Create extends ExecRenderBaseCommand {
10
+ static summary = "Create a new volume";
11
+ static description = "Creates a new named volume in the project stack. The volume will be available for use by containers.";
12
+ static flags = {
13
+ ...projectFlags,
14
+ ...processFlags,
15
+ };
16
+ static args = {
17
+ name: Args.string({
18
+ description: "name of the volume to create",
19
+ required: true,
20
+ }),
21
+ };
22
+ async exec() {
23
+ const process = makeProcessRenderer(this.flags, "Creating a new volume");
24
+ const projectId = await this.withProjectId(Create);
25
+ const stackId = projectId; // In mStudio, project and stack are the same for volumes
26
+ const { name: volumeName } = this.args;
27
+ // Get current stack state
28
+ const currentStack = await process.runStep("getting current stack state", async () => {
29
+ const r = await this.apiClient.container.getStack({ stackId });
30
+ assertStatus(r, 200);
31
+ return r.data;
32
+ });
33
+ // Check if volume already exists
34
+ if ((currentStack.volumes || []).some((v) => v.name === volumeName)) {
35
+ throw new Error(`Volume "${volumeName}" already exists`);
36
+ }
37
+ // Update stack to include the new volume
38
+ await process.runStep("creating volume", async () => {
39
+ const r = await this.apiClient.container.updateStack({
40
+ stackId,
41
+ data: {
42
+ volumes: {
43
+ [volumeName]: { name: volumeName },
44
+ },
45
+ },
46
+ });
47
+ assertStatus(r, 200);
48
+ });
49
+ await process.complete(_jsxs(Success, { children: ["Volume ", _jsx(Value, { children: volumeName }), " was successfully created."] }));
50
+ return { volumeName, stackId };
51
+ }
52
+ render({ volumeName }) {
53
+ if (this.flags.quiet) {
54
+ return volumeName;
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,22 @@
1
+ import { ExecRenderBaseCommand } from "../../lib/basecommands/ExecRenderBaseCommand.js";
2
+ import { ReactNode } from "react";
3
+ type Result = {
4
+ volumeName: string;
5
+ };
6
+ export declare class Delete extends ExecRenderBaseCommand<typeof Delete, Result> {
7
+ static summary: string;
8
+ static description: string;
9
+ static aliases: string[];
10
+ static flags: {
11
+ force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
+ quiet: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
+ "project-id": import("@oclif/core/interfaces").OptionFlag<string>;
14
+ };
15
+ static args: {
16
+ name: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
17
+ };
18
+ protected exec(): Promise<Result>;
19
+ private checkVolumeInUse;
20
+ protected render({ volumeName }: Result): ReactNode;
21
+ }
22
+ export {};
@@ -0,0 +1,89 @@
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 { Args, Flags } from "@oclif/core";
5
+ import { assertStatus } from "@mittwald/api-client-commons";
6
+ import { Success } from "../../rendering/react/components/Success.js";
7
+ import { Value } from "../../rendering/react/components/Value.js";
8
+ import { projectFlags } from "../../lib/resources/project/flags.js";
9
+ export class Delete extends ExecRenderBaseCommand {
10
+ static summary = "Remove one or more volumes";
11
+ static description = "Removes named volumes from the project stack. Be careful as this will permanently delete the volume data.";
12
+ static aliases = ["volume:rm"];
13
+ static flags = {
14
+ ...projectFlags,
15
+ ...processFlags,
16
+ force: Flags.boolean({
17
+ summary: "force removal without confirmation",
18
+ char: "f",
19
+ default: false,
20
+ }),
21
+ };
22
+ static args = {
23
+ name: Args.string({
24
+ description: "name of the volume to remove",
25
+ required: true,
26
+ }),
27
+ };
28
+ async exec() {
29
+ const process = makeProcessRenderer(this.flags, "Removing volume");
30
+ const projectId = await this.withProjectId(Delete);
31
+ const stackId = projectId; // In mStudio, project and stack are the same for volumes
32
+ const { name: volumeName } = this.args;
33
+ // Get current stack state
34
+ const currentStack = await process.runStep("getting current stack state", async () => {
35
+ const r = await this.apiClient.container.getStack({ stackId });
36
+ assertStatus(r, 200);
37
+ return r.data;
38
+ });
39
+ // Check if volume exists
40
+ if (!(currentStack.volumes || []).some((v) => v.name === volumeName)) {
41
+ throw new Error(`Volume "${volumeName}" does not exist`);
42
+ }
43
+ // Check if volume is in use by any services
44
+ const volumeInUse = this.checkVolumeInUse(currentStack.services, volumeName);
45
+ if (volumeInUse && !this.flags.force) {
46
+ throw new Error(`Volume "${volumeName}" is in use by one or more containers. Use --force to remove anyway.`);
47
+ }
48
+ // Delete the actual volume data if it exists
49
+ await process.runStep("deleting volume", async () => {
50
+ const volumesResponse = await this.apiClient.container.listVolumes({
51
+ projectId,
52
+ });
53
+ assertStatus(volumesResponse, 200);
54
+ const volume = volumesResponse.data.find((v) => v.name === volumeName && v.stackId === stackId);
55
+ if (volume) {
56
+ const deleteResponse = await this.apiClient.container.deleteVolume({
57
+ stackId,
58
+ volumeId: volume.id,
59
+ });
60
+ assertStatus(deleteResponse, 204);
61
+ }
62
+ });
63
+ await process.complete(_jsxs(Success, { children: ["Volume ", _jsx(Value, { children: volumeName }), " was successfully removed."] }));
64
+ return { volumeName };
65
+ }
66
+ checkVolumeInUse(services, volumeName) {
67
+ if (!services) {
68
+ return false;
69
+ }
70
+ for (const service of services) {
71
+ const volumes = service.deployedState?.volumes;
72
+ if (volumes) {
73
+ for (const volumeMount of volumes) {
74
+ // Check if this is a named volume (not a bind mount)
75
+ if (volumeMount.includes(":") &&
76
+ volumeMount.split(":")[0] === volumeName) {
77
+ return true;
78
+ }
79
+ }
80
+ }
81
+ }
82
+ return false;
83
+ }
84
+ render({ volumeName }) {
85
+ if (this.flags.quiet) {
86
+ return volumeName;
87
+ }
88
+ }
89
+ }
@@ -0,0 +1,24 @@
1
+ import { Simplify } from "@mittwald/api-client-commons";
2
+ import { MittwaldAPIV2, MittwaldAPIV2Client } from "@mittwald/api-client";
3
+ import { ListBaseCommand } from "../../lib/basecommands/ListBaseCommand.js";
4
+ import { ListColumns } from "../../rendering/formatter/Table.js";
5
+ type ResponseItem = Simplify<MittwaldAPIV2.Paths.V2ProjectsProjectIdVolumes.Get.Responses.$200.Content.ApplicationJson[number]>;
6
+ type Response = Awaited<ReturnType<MittwaldAPIV2Client["container"]["listVolumes"]>>;
7
+ export declare class List extends ListBaseCommand<typeof List, ResponseItem, Response> {
8
+ static description: string;
9
+ static aliases: string[];
10
+ static args: {};
11
+ static flags: {
12
+ "project-id": import("@oclif/core/interfaces").OptionFlag<string>;
13
+ output: import("@oclif/core/interfaces").OptionFlag<"json" | "txt" | "yaml" | "csv" | "tsv">;
14
+ extended: import("@oclif/core/interfaces").BooleanFlag<boolean>;
15
+ "no-header": import("@oclif/core/interfaces").BooleanFlag<boolean>;
16
+ "no-truncate": import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
+ "no-relative-dates": import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
+ "csv-separator": import("@oclif/core/interfaces").OptionFlag<"," | ";">;
19
+ token: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
20
+ };
21
+ getData(): Promise<Response>;
22
+ protected getColumns(data: ResponseItem[]): ListColumns<ResponseItem>;
23
+ }
24
+ export {};
@@ -0,0 +1,51 @@
1
+ import { ListBaseCommand } from "../../lib/basecommands/ListBaseCommand.js";
2
+ import { projectFlags } from "../../lib/resources/project/flags.js";
3
+ import { makeDateRendererForFormat } from "../../rendering/textformat/formatDate.js";
4
+ import ByteQuantity from "../../lib/units/ByteQuantity.js";
5
+ export class List extends ListBaseCommand {
6
+ static description = "List volumes belonging to a project.";
7
+ static aliases = ["volume:ls"];
8
+ static args = {};
9
+ static flags = {
10
+ ...ListBaseCommand.baseFlags,
11
+ ...projectFlags,
12
+ };
13
+ async getData() {
14
+ const projectId = await this.withProjectId(List);
15
+ return this.apiClient.container.listVolumes({ projectId });
16
+ }
17
+ getColumns(data) {
18
+ const { id } = super.getColumns(data);
19
+ const dateFormatter = makeDateRendererForFormat(this.flags.output, !this.flags["no-relative-dates"]);
20
+ return {
21
+ id: {
22
+ ...id,
23
+ extended: true,
24
+ },
25
+ name: {
26
+ get: (volume) => volume.name,
27
+ },
28
+ stackId: {
29
+ header: "Stack",
30
+ extended: true,
31
+ exactWidth: 40,
32
+ },
33
+ usedIn: {
34
+ header: "Used in",
35
+ get: (volume) => {
36
+ if (volume.orphaned) {
37
+ return "orphaned";
38
+ }
39
+ return (volume.linkedServices ?? []).length + " services";
40
+ },
41
+ },
42
+ storageUsage: {
43
+ header: "Size",
44
+ get: (volume) => ByteQuantity.fromBytes(volume.storageUsageInBytes).format() +
45
+ " (updated " +
46
+ dateFormatter(volume.storageUsageInBytesSetAt) +
47
+ ")",
48
+ },
49
+ };
50
+ }
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mittwald/cli",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
4
4
  "description": "Hand-crafted CLI for the mittwald API",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -261,6 +261,9 @@
261
261
  "description": "Manage your SSH keys"
262
262
  }
263
263
  }
264
+ },
265
+ "volume": {
266
+ "description": "Manage volumes"
264
267
  }
265
268
  },
266
269
  "plugins": [