@mittwald/cli 1.7.0 → 1.8.1

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.
@@ -12,6 +12,7 @@ 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
14
  import semver from "semver";
15
+ import { validate as validateUuid } from "uuid";
15
16
  export class UpgradeApp extends ExecRenderBaseCommand {
16
17
  static description = "Upgrade app installation to target version";
17
18
  static args = {
@@ -39,7 +40,8 @@ export class UpgradeApp extends ExecRenderBaseCommand {
39
40
  ux.exit(1);
40
41
  }
41
42
  const currentAppVersion = await getAppVersionFromUuid(this.apiClient, currentApp.id, currentAppInstallation.appVersion.current);
42
- if (targetAppVersionCandidates.length == 0) {
43
+ if (targetAppVersionCandidates.length == 0 &&
44
+ !validateUuid(this.flags["target-version"])) {
43
45
  process.complete(_jsxs(Text, { children: ["Your ", currentApp.name, " ", currentAppVersion.externalVersion, " is already Up-To-Date. \u2705"] }));
44
46
  return;
45
47
  }
@@ -121,6 +123,10 @@ export class UpgradeApp extends ExecRenderBaseCommand {
121
123
  if (targetAppVersionString == "latest") {
122
124
  return await getLatestAvailableTargetAppVersionForAppVersionUpgradeCandidates(this.apiClient, currentApp.id, currentAppVersion.id);
123
125
  }
126
+ if (validateUuid(targetAppVersionString) &&
127
+ typeof targetAppVersionString === "string") {
128
+ return await getAppVersionFromUuid(this.apiClient, currentApp.id, targetAppVersionString);
129
+ }
124
130
  if (targetAppVersionString) {
125
131
  const exactVersionMatch = targetAppVersionCandidates.find((v) => v.externalVersion === targetAppVersionString);
126
132
  if (exactVersionMatch) {
@@ -0,0 +1,13 @@
1
+ import { RenderBaseCommand } from "../../lib/basecommands/RenderBaseCommand.js";
2
+ import React from "react";
3
+ export declare class VersionInfo extends RenderBaseCommand<typeof VersionInfo> {
4
+ static description: string;
5
+ static summary: string;
6
+ static args: {
7
+ app: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
8
+ version: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
9
+ };
10
+ protected render(): React.ReactNode;
11
+ private getAppVersion;
12
+ private getApp;
13
+ }
@@ -0,0 +1,78 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { RenderBaseCommand } from "../../lib/basecommands/RenderBaseCommand.js";
3
+ import { Args } from "@oclif/core";
4
+ import { assertStatus } from "@mittwald/api-client";
5
+ import { usePromise } from "@mittwald/react-use-promise";
6
+ import { Box, Text } from "ink";
7
+ import { SingleResult, SingleResultTable, } from "../../rendering/react/components/SingleResult.js";
8
+ import { Value } from "../../rendering/react/components/Value.js";
9
+ export class VersionInfo extends RenderBaseCommand {
10
+ static description = "show information about specific app versions";
11
+ static summary = "This command shows information about a specific app version. It is useful to get information about the user inputs that are required for the version to be deployed successfully.";
12
+ static args = {
13
+ app: Args.string({
14
+ description: "name of the app",
15
+ required: true,
16
+ }),
17
+ version: Args.string({
18
+ description: "version of the app",
19
+ required: true,
20
+ }),
21
+ };
22
+ render() {
23
+ const { app, version } = this.args;
24
+ const appData = usePromise(this.getApp.bind(this), [app]);
25
+ const appVersionData = usePromise(this.getAppVersion.bind(this), [
26
+ appData,
27
+ version,
28
+ ]);
29
+ const sections = [
30
+ _jsx(AppVersionDetails, { app: appData, appVersion: appVersionData }, "details"),
31
+ ];
32
+ if (appVersionData.userInputs) {
33
+ sections.push(_jsx(UserInputTable, { appVersion: appVersionData }, "inputs"));
34
+ }
35
+ return (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: sections }));
36
+ }
37
+ async getAppVersion(app, versionName) {
38
+ const appVersions = await this.apiClient.app.listAppversions({
39
+ appId: app.id,
40
+ });
41
+ assertStatus(appVersions, 200);
42
+ const version = appVersions.data.find((v) => v.externalVersion === versionName);
43
+ if (!version) {
44
+ throw new Error(`version ${this.args.version} not found`);
45
+ }
46
+ return version;
47
+ }
48
+ async getApp(appName) {
49
+ const appResult = await this.apiClient.app.listApps();
50
+ assertStatus(appResult, 200);
51
+ const app = appResult.data.find((a) => a.name.toLowerCase() === appName.toLowerCase());
52
+ if (!app) {
53
+ throw new Error(`app ${appName} not found`);
54
+ }
55
+ return app;
56
+ }
57
+ }
58
+ function UserInputValue({ input }) {
59
+ return (_jsxs(Text, { children: ["type=", _jsx(Value, { children: input.dataType }), input.defaultValue && (_jsxs(_Fragment, { children: [" ", "default=", _jsx(Value, { children: input.defaultValue })] }))] }));
60
+ }
61
+ function UserInputTable({ appVersion }) {
62
+ return (_jsx(SingleResult, { title: "USER INPUTS", rows: Object.fromEntries((appVersion.userInputs ?? []).map((u) => [
63
+ u.name,
64
+ _jsx(UserInputValue, { input: u }),
65
+ ])) }, "inputs"));
66
+ }
67
+ function AppVersionDetails({ app, appVersion, }) {
68
+ return (_jsx(SingleResult, { title: "APP VERSION DETAILS", rows: {
69
+ App: (_jsx(SingleResultTable, { rows: {
70
+ UID: _jsx(Value, { children: app.id }),
71
+ Name: _jsx(Value, { children: app.name }),
72
+ } })),
73
+ Version: (_jsx(SingleResultTable, { rows: {
74
+ UID: _jsx(Value, { children: appVersion.id }),
75
+ Version: _jsx(Value, { children: appVersion.externalVersion }),
76
+ } })),
77
+ } }, "details"));
78
+ }
@@ -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
+ }
@@ -45,7 +45,7 @@ export class PortForward extends ExecRenderBaseCommand {
45
45
  .map((p) => ["-L", `${p.localPort}:localhost:${p.remotePort}`])
46
46
  .flat(),
47
47
  });
48
- cp.spawnSync("ssh", [...sshArgs, "cat", "/dev/zero"], {
48
+ cp.spawnSync("ssh", [...sshArgs, "sleep", "infinity"], {
49
49
  stdio: ["ignore", process.stdout, process.stderr],
50
50
  });
51
51
  return {};
@@ -33,7 +33,7 @@ export class PortForward extends ExecRenderBaseCommand {
33
33
  interactive: false,
34
34
  additionalFlags: ["-L", `${port}:${hostname}:3306`],
35
35
  });
36
- cp.spawnSync("ssh", [...sshArgs, "cat", "/dev/zero"], {
36
+ cp.spawnSync("ssh", [...sshArgs, "sleep", "infinity"], {
37
37
  stdio: ["ignore", process.stdout, process.stderr],
38
38
  });
39
39
  return {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mittwald/cli",
3
- "version": "1.7.0",
3
+ "version": "1.8.1",
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",