@mittwald/cli 1.13.3 → 1.13.5

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.
Files changed (33) hide show
  1. package/dist/commands/app/download.js +1 -1
  2. package/dist/commands/app/exec.js +1 -0
  3. package/dist/commands/app/ssh.js +3 -2
  4. package/dist/commands/app/upload.js +1 -1
  5. package/dist/commands/container/cp.js +6 -1
  6. package/dist/commands/container/exec.js +1 -0
  7. package/dist/commands/container/port-forward.js +1 -0
  8. package/dist/commands/container/ssh.js +3 -2
  9. package/dist/commands/database/mysql/create.js +1 -1
  10. package/dist/commands/database/mysql/dump.js +1 -1
  11. package/dist/commands/database/mysql/import.js +1 -1
  12. package/dist/commands/database/mysql/port-forward.js +1 -0
  13. package/dist/commands/database/mysql/shell.js +1 -0
  14. package/dist/commands/database/redis/shell.js +1 -0
  15. package/dist/commands/ddev/init.js +8 -4
  16. package/dist/commands/ddev/render-config.js +4 -1
  17. package/dist/commands/project/ssh.js +6 -1
  18. package/dist/lib/apiutil/api_baseurl.d.ts +2 -0
  19. package/dist/lib/apiutil/api_baseurl.js +17 -0
  20. package/dist/lib/basecommands/BaseCommand.js +17 -0
  21. package/dist/lib/ddev/config_builder.d.ts +7 -1
  22. package/dist/lib/ddev/config_builder.js +39 -15
  23. package/dist/lib/ddev/config_builder.test.d.ts +1 -0
  24. package/dist/lib/ddev/config_builder.test.js +30 -0
  25. package/dist/lib/resources/app/sync.d.ts +1 -1
  26. package/dist/lib/resources/app/sync.js +6 -2
  27. package/dist/lib/resources/ssh/connection.d.ts +3 -1
  28. package/dist/lib/resources/ssh/connection.js +8 -2
  29. package/dist/lib/resources/ssh/exec.d.ts +1 -1
  30. package/dist/lib/resources/ssh/exec.js +2 -1
  31. package/dist/lib/resources/ssh/knownhosts.d.ts +36 -0
  32. package/dist/lib/resources/ssh/knownhosts.js +101 -0
  33. package/package.json +1 -1
@@ -54,7 +54,7 @@ export class Download extends ExecRenderBaseCommand {
54
54
  });
55
55
  const rsyncHost = buildRsyncConnectionString(connectionData, this.flags);
56
56
  const rsyncOpts = [
57
- ...appInstallationSyncFlagsToRsyncFlags(this.flags),
57
+ ...appInstallationSyncFlagsToRsyncFlags(this.flags, this.config.configDir),
58
58
  ...(await filterFileToRsyncFlagsIfPresent(target)),
59
59
  ];
60
60
  await spawnInProcess(p, "downloading app installation" + (dryRun ? " (dry-run)" : ""), "rsync", [...rsyncOpts, rsyncHost, target]);
@@ -58,6 +58,7 @@ export default class Exec extends ExtendedBaseCommand {
58
58
  execCommand += command;
59
59
  const sshArgs = buildSSHClientFlags(user, host, flags, {
60
60
  interactive: false,
61
+ configDir: this.config.configDir,
61
62
  });
62
63
  const wrappedExecCommand = shellEscape(["/bin/bash", "-c", execCommand]);
63
64
  this.debug("running ssh %o, with command %o", sshArgs, wrappedExecCommand);
@@ -50,7 +50,7 @@ export default class Ssh extends ExtendedBaseCommand {
50
50
  return;
51
51
  }
52
52
  this.log("connecting to %o as %o", host, user);
53
- const [cmd, args] = buildSSHCmdAndFlags(user, host, directory, this.flags);
53
+ const [cmd, args] = buildSSHCmdAndFlags(user, host, directory, this.flags, this.config.configDir);
54
54
  this.debug("running ssh %o, with command %o", args, cmd);
55
55
  if (flags.cd) {
56
56
  this.log("changing to %o; use --no-cd to disable this behaviour", directory);
@@ -60,10 +60,11 @@ export default class Ssh extends ExtendedBaseCommand {
60
60
  });
61
61
  }
62
62
  }
63
- function buildSSHCmdAndFlags(user, host, directory, flags) {
63
+ function buildSSHCmdAndFlags(user, host, directory, flags, configDir) {
64
64
  const args = buildSSHClientFlags(user, host, flags, {
65
65
  interactive: true,
66
66
  additionalFlags: flags.test ? ["-q"] : [],
67
+ configDir,
67
68
  });
68
69
  if (flags.test) {
69
70
  return ["/bin/true", args];
@@ -46,7 +46,7 @@ export class Upload extends ExecRenderBaseCommand {
46
46
  });
47
47
  const rsyncHost = buildRsyncConnectionString(connectionData, this.flags);
48
48
  const rsyncOpts = [
49
- ...appInstallationSyncFlagsToRsyncFlags(this.flags),
49
+ ...appInstallationSyncFlagsToRsyncFlags(this.flags, this.config.configDir),
50
50
  ...(await filterFileToRsyncFlagsIfPresent(source)),
51
51
  ];
52
52
  await spawnInProcess(p, "uploading app installation" + (dryRun ? " (dry-run)" : ""), "rsync", [...rsyncOpts, source, rsyncHost]);
@@ -4,6 +4,7 @@ import { Args, Flags } from "@oclif/core";
4
4
  import { ExecRenderBaseCommand } from "../../lib/basecommands/ExecRenderBaseCommand.js";
5
5
  import { getSSHConnectionForContainer } from "../../lib/resources/ssh/container.js";
6
6
  import { sshConnectionFlags } from "../../lib/resources/ssh/flags.js";
7
+ import { getSSHKnownHostsFlags } from "../../lib/resources/ssh/knownhosts.js";
7
8
  import { withContainerAndStackId } from "../../lib/resources/container/flags.js";
8
9
  import { projectFlags } from "../../lib/resources/project/flags.js";
9
10
  import { makeProcessRenderer, processFlags, } from "../../rendering/process/process_flags.js";
@@ -60,7 +61,11 @@ Where CONTAINER can be a container ID, short ID, or service name.`;
60
61
  return { container, path: normalizedPath };
61
62
  }
62
63
  buildScpCommand(source, dest, flags) {
63
- const scpArgs = ["-o", "PasswordAuthentication=no"];
64
+ const scpArgs = [
65
+ ...getSSHKnownHostsFlags(this.config.configDir),
66
+ "-o",
67
+ "PasswordAuthentication=no",
68
+ ];
64
69
  if (flags.archive) {
65
70
  scpArgs.push("-p");
66
71
  }
@@ -69,6 +69,7 @@ export default class Exec extends ExtendedBaseCommand {
69
69
  execCommand += command;
70
70
  const sshArgs = buildSSHClientFlags(user, host, flags, {
71
71
  interactive: false,
72
+ configDir: this.config.configDir,
72
73
  });
73
74
  const wrappedExecCommand = shellEscape([flags.shell, "-c", execCommand]);
74
75
  this.debug("running ssh %o, with command %o", sshArgs, wrappedExecCommand);
@@ -44,6 +44,7 @@ export class PortForward extends ExecRenderBaseCommand {
44
44
  additionalFlags: portMappings
45
45
  .map((p) => ["-L", `${p.localPort}:localhost:${p.remotePort}`])
46
46
  .flat(),
47
+ configDir: this.config.configDir,
47
48
  });
48
49
  cp.spawnSync("ssh", [...sshArgs, "sleep", "infinity"], {
49
50
  stdio: ["ignore", process.stdout, process.stderr],
@@ -42,17 +42,18 @@ export default class Ssh extends ExtendedBaseCommand {
42
42
  return;
43
43
  }
44
44
  this.log("connecting to %o as %o", host, user);
45
- const [cmd, args] = buildSSHCmdAndFlags(user, host, flags);
45
+ const [cmd, args] = buildSSHCmdAndFlags(user, host, flags, this.config.configDir);
46
46
  this.debug("running ssh %o, with command %o", args, cmd);
47
47
  spawnSync("/usr/bin/ssh", [...args, cmd], {
48
48
  stdio: "inherit",
49
49
  });
50
50
  }
51
51
  }
52
- function buildSSHCmdAndFlags(user, host, flags) {
52
+ function buildSSHCmdAndFlags(user, host, flags, configDir) {
53
53
  const args = buildSSHClientFlags(user, host, flags, {
54
54
  interactive: true,
55
55
  additionalFlags: flags.test ? ["-q"] : [],
56
+ configDir,
56
57
  });
57
58
  if (flags.test) {
58
59
  return ["/bin/true", args];
@@ -83,7 +83,7 @@ export class Create extends ExecRenderBaseCommand {
83
83
  const r = await this.apiClient.database.createMysqlDatabase({
84
84
  projectId,
85
85
  data: {
86
- database: { projectId, ...database },
86
+ database,
87
87
  user,
88
88
  },
89
89
  });
@@ -47,7 +47,7 @@ export class Dump extends ExecRenderBaseCommand {
47
47
  shell: `set -e -o pipefail > /dev/null ; mysqldump ${escapedArgs} | gzip`,
48
48
  };
49
49
  }
50
- await p.runStep(`starting mysqldump via SSH on project ${project.shortId}`, () => executeViaSSH(this.apiClient, this.flags, { projectId: connectionDetails.project.id }, cmd, { input: null, output: this.getOutputStream() }));
50
+ await p.runStep(`starting mysqldump via SSH on project ${project.shortId}`, () => executeViaSSH(this.apiClient, this.flags, { projectId: connectionDetails.project.id }, cmd, { input: null, output: this.getOutputStream() }, this.config.configDir));
51
51
  return connectionDetails.database;
52
52
  });
53
53
  await p.complete(_jsx(DumpSuccess, { database: databaseName, output: this.flags.output }));
@@ -47,7 +47,7 @@ export class Import extends ExecRenderBaseCommand {
47
47
  shell: `set -e -o pipefail > /dev/null ; gunzip | mysql ${escapedArgs}`,
48
48
  };
49
49
  }
50
- await p.runStep(`starting mysql via SSH on project ${project.shortId}`, () => executeViaSSH(this.apiClient, this.flags, { projectId: connectionDetails.project.id }, cmd, { input: this.getInputStream(), output: null }));
50
+ await p.runStep(`starting mysql via SSH on project ${project.shortId}`, () => executeViaSSH(this.apiClient, this.flags, { projectId: connectionDetails.project.id }, cmd, { input: this.getInputStream(), output: null }, this.config.configDir));
51
51
  return connectionDetails.database;
52
52
  });
53
53
  await p.complete(_jsx(ImportSuccess, { database: databaseName, input: this.flags.input }));
@@ -32,6 +32,7 @@ export class PortForward extends ExecRenderBaseCommand {
32
32
  const sshArgs = buildSSHClientFlags(sshUser, sshHost, this.flags, {
33
33
  interactive: false,
34
34
  additionalFlags: ["-L", `${port}:${hostname}:3306`],
35
+ configDir: this.config.configDir,
35
36
  });
36
37
  cp.spawnSync("ssh", [...sshArgs, "sleep", "infinity"], {
37
38
  stdio: ["ignore", process.stdout, process.stderr],
@@ -25,6 +25,7 @@ export class Shell extends ExecRenderBaseCommand {
25
25
  await p.complete(_jsx(Text, { children: "Starting MySQL shell -- get ready..." }));
26
26
  const sshArgs = buildSSHClientFlags(sshUser, sshHost, this.flags, {
27
27
  interactive: true,
28
+ configDir: this.config.configDir,
28
29
  });
29
30
  const mysqlArgs = ["-h", hostname, "-u", user, "-p" + password, database];
30
31
  cp.spawnSync("ssh", [...sshArgs, "mysql", ...mysqlArgs], {
@@ -24,6 +24,7 @@ export class Shell extends ExecRenderBaseCommand {
24
24
  await p.complete(_jsx(Text, { children: "Starting Redis shell -- get ready..." }));
25
25
  const sshArgs = buildSSHClientFlags(sshUser, sshHost, this.flags, {
26
26
  interactive: true,
27
+ configDir: this.config.configDir,
27
28
  });
28
29
  const redisArgs = ["-h", hostname];
29
30
  cp.spawnSync("ssh", [...sshArgs, "redis-cli", ...redisArgs], {
@@ -163,12 +163,16 @@ export class Init extends ExecRenderBaseCommand {
163
163
  }
164
164
  async writeMittwaldConfiguration(r, appInstallationId, databaseId, projectType) {
165
165
  const builder = new DDEVConfigBuilder(this.apiClient);
166
- return await r.runStep("creating mittwald-specific DDEV configuration", async () => {
167
- const config = await builder.build(appInstallationId, databaseId, projectType);
166
+ const { config, warnings } = await r.runStep("creating mittwald-specific DDEV configuration", async () => {
167
+ const result = await builder.build(appInstallationId, databaseId, projectType);
168
168
  const configFile = path.join(".ddev", "config.mittwald.yaml");
169
- await writeContentsToFile(configFile, renderDDEVConfig(appInstallationId, config));
170
- return config;
169
+ await writeContentsToFile(configFile, renderDDEVConfig(appInstallationId, result.config));
170
+ return result;
171
171
  });
172
+ for (const warning of warnings) {
173
+ r.addInfo(`Warning: ${warning}`);
174
+ }
175
+ return config;
172
176
  }
173
177
  }
174
178
  async function writeContentsToFile(filename, data) {
@@ -19,7 +19,10 @@ export class RenderConfig extends ExtendedBaseCommand {
19
19
  ? "none"
20
20
  : this.flags["database-id"];
21
21
  const ddevConfigBuilder = new DDEVConfigBuilder(this.apiClient);
22
- const ddevConfig = await ddevConfigBuilder.build(appInstallationId, databaseId, projectType);
22
+ const { config: ddevConfig, warnings } = await ddevConfigBuilder.build(appInstallationId, databaseId, projectType);
23
+ for (const warning of warnings) {
24
+ this.warn(warning);
25
+ }
23
26
  this.log(renderDDEVConfig(appInstallationId, ddevConfig));
24
27
  }
25
28
  }
@@ -4,6 +4,7 @@ import { ExtendedBaseCommand } from "../../lib/basecommands/ExtendedBaseCommand.
4
4
  import { sshConnectionFlags } from "../../lib/resources/ssh/flags.js";
5
5
  import { getSSHConnectionForProject } from "../../lib/resources/ssh/project.js";
6
6
  import { sshWrapperDocumentation } from "../../lib/resources/ssh/doc.js";
7
+ import { buildSSHClientFlags } from "../../lib/resources/ssh/connection.js";
7
8
  export default class Ssh extends ExtendedBaseCommand {
8
9
  static summary = "Connect to a project via SSH";
9
10
  static description = "Establishes an interactive SSH connection to a project.\n\n" +
@@ -16,7 +17,11 @@ export default class Ssh extends ExtendedBaseCommand {
16
17
  const projectId = await this.withProjectId(Ssh);
17
18
  const { user, host } = await getSSHConnectionForProject(this.apiClient, projectId, this.flags["ssh-user"]);
18
19
  this.log("connecting to %o as %o", host, user);
19
- spawnSync("/usr/bin/ssh", ["-l", user, host], {
20
+ const sshArgs = buildSSHClientFlags(user, host, this.flags, {
21
+ interactive: true,
22
+ configDir: this.config.configDir,
23
+ });
24
+ spawnSync("/usr/bin/ssh", sshArgs, {
20
25
  stdio: "inherit",
21
26
  });
22
27
  }
@@ -0,0 +1,2 @@
1
+ import { AxiosInstance } from "@mittwald/api-client-commons";
2
+ export declare function configureAxiosBaseURL(axios: AxiosInstance, baseURL: string): void;
@@ -0,0 +1,17 @@
1
+ export function configureAxiosBaseURL(axios, baseURL) {
2
+ const trimmedBaseURL = baseURL.trim();
3
+ if (!trimmedBaseURL) {
4
+ throw new Error("MITTWALD_API_BASE_URL is empty or contains only whitespace. Please provide a valid absolute URL.");
5
+ }
6
+ let parsedUrl;
7
+ try {
8
+ parsedUrl = new URL(trimmedBaseURL);
9
+ }
10
+ catch {
11
+ throw new Error(`MITTWALD_API_BASE_URL="${trimmedBaseURL}" is not a valid absolute URL. Please provide a value like "https://api.example.com".`);
12
+ }
13
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
14
+ throw new Error(`MITTWALD_API_BASE_URL="${trimmedBaseURL}" must use http or https scheme. Received protocol "${parsedUrl.protocol}".`);
15
+ }
16
+ axios.defaults.baseURL = parsedUrl.toString();
17
+ }
@@ -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 { configureAxiosBaseURL } from "../apiutil/api_baseurl.js";
8
9
  import NoTokenFoundError from "../error/NoTokenFoundError.js";
9
10
  /** Base command class for authenticated commands that includes the --token flag. */
10
11
  export class BaseCommand extends CoreBaseCommand {
@@ -27,6 +28,22 @@ export class BaseCommand extends CoreBaseCommand {
27
28
  this.apiClient = MittwaldAPIV2Client.newWithToken(token);
28
29
  this.apiClient.axios.defaults.headers["User-Agent"] =
29
30
  `mittwald-cli/${this.config.version}`;
31
+ // Allow overriding API base URL for local testing/mocking.
32
+ // NOTE: This is intentionally dangerous; be careful not to leak tokens
33
+ // to unintended hosts via a leftover environment variable.
34
+ const rawBaseUrlEnv = process.env.MITTWALD_API_BASE_URL;
35
+ const overriddenBaseUrl = typeof rawBaseUrlEnv === "string" ? rawBaseUrlEnv.trim() : "";
36
+ if (overriddenBaseUrl) {
37
+ try {
38
+ const parsed = new URL(overriddenBaseUrl);
39
+ // Warn the user that requests (including tokens) will be sent to a non-default host.
40
+ this.warn(`Using overridden API base URL from MITTWALD_API_BASE_URL (host: ${parsed.host}).`);
41
+ configureAxiosBaseURL(this.apiClient.axios, overriddenBaseUrl);
42
+ }
43
+ catch {
44
+ this.warn("Ignoring MITTWALD_API_BASE_URL because it is not a valid URL.");
45
+ }
46
+ }
30
47
  configureAxiosLogging(this.apiClient.axios);
31
48
  configureAxiosRetry(this.apiClient.axios);
32
49
  configureConsistencyHandling(this.apiClient.axios);
@@ -1,9 +1,13 @@
1
1
  import { MittwaldAPIV2Client } from "@mittwald/api-client";
2
2
  import { DDEVConfig } from "./config.js";
3
+ export type DDEVConfigBuildResult = {
4
+ config: Partial<DDEVConfig>;
5
+ warnings: string[];
6
+ };
3
7
  export declare class DDEVConfigBuilder {
4
8
  private apiClient;
5
9
  constructor(apiClient: MittwaldAPIV2Client);
6
- build(appInstallationId: string, databaseId: string | undefined, type: string): Promise<Partial<DDEVConfig>>;
10
+ build(appInstallationId: string, databaseId: string | undefined, type: string): Promise<DDEVConfigBuildResult>;
7
11
  private buildHooks;
8
12
  private determineDocumentRoot;
9
13
  private determineProjectType;
@@ -16,3 +20,5 @@ export declare class DDEVConfigBuilder {
16
20
  private getAppVersion;
17
21
  private getAppInstallation;
18
22
  }
23
+ export declare function hasExtendedSupportSuffix(version: string): boolean;
24
+ export declare function stripPatchLevelVersion(version: string): string;
@@ -10,17 +10,21 @@ export class DDEVConfigBuilder {
10
10
  const appInstallation = await this.getAppInstallation(appInstallationId);
11
11
  const systemSoftwares = await this.buildSystemSoftwareVersionMap(appInstallation);
12
12
  type = await this.determineProjectType(appInstallation, type);
13
+ const { version: phpVersion, warnings } = this.determinePHPVersion(systemSoftwares);
13
14
  return {
14
- type,
15
- webserver_type: "apache-fpm",
16
- php_version: this.determinePHPVersion(systemSoftwares),
17
- database: await this.determineDatabaseVersion(databaseId),
18
- docroot: await this.determineDocumentRoot(appInstallation),
19
- web_environment: [
20
- `MITTWALD_APP_INSTALLATION_ID=${appInstallation.shortId}`,
21
- `MITTWALD_DATABASE_ID=${databaseId ?? ""}`,
22
- ],
23
- hooks: this.buildHooks(type),
15
+ config: {
16
+ type,
17
+ webserver_type: "apache-fpm",
18
+ php_version: phpVersion,
19
+ database: await this.determineDatabaseVersion(databaseId),
20
+ docroot: await this.determineDocumentRoot(appInstallation),
21
+ web_environment: [
22
+ `MITTWALD_APP_INSTALLATION_ID=${appInstallation.shortId}`,
23
+ `MITTWALD_DATABASE_ID=${databaseId ?? ""}`,
24
+ ],
25
+ hooks: this.buildHooks(type),
26
+ },
27
+ warnings,
24
28
  };
25
29
  }
26
30
  buildHooks(type) {
@@ -82,10 +86,22 @@ export class DDEVConfigBuilder {
82
86
  }
83
87
  determinePHPVersion(systemSoftwareVersions) {
84
88
  if (!("php" in systemSoftwareVersions)) {
85
- return undefined;
89
+ return { version: undefined, warnings: [] };
90
+ }
91
+ const originalVersion = systemSoftwareVersions["php"];
92
+ const normalizedVersion = stripPatchLevelVersion(originalVersion);
93
+ if (hasExtendedSupportSuffix(originalVersion)) {
94
+ return {
95
+ version: normalizedVersion,
96
+ warnings: [
97
+ `The PHP version used by this project (${originalVersion}) is an extended support version ` +
98
+ "that is not directly supported by DDEV. " +
99
+ `Falling back to PHP ${normalizedVersion}. ` +
100
+ "This may cause unintended side effects.",
101
+ ],
102
+ };
86
103
  }
87
- const version = systemSoftwareVersions["php"];
88
- return stripPatchLevelVersion(version);
104
+ return { version: normalizedVersion, warnings: [] };
89
105
  }
90
106
  async buildSystemSoftwareVersionMap(inst) {
91
107
  const versionMap = {};
@@ -133,7 +149,15 @@ function hasCustomDocumentRoot(inst) {
133
149
  function stripLeadingSlash(input) {
134
150
  return input.replace(/^\//, "");
135
151
  }
136
- function stripPatchLevelVersion(version) {
152
+ const extendedSupportSuffixPattern = /-es$/;
153
+ export function hasExtendedSupportSuffix(version) {
154
+ return extendedSupportSuffixPattern.test(version);
155
+ }
156
+ export function stripPatchLevelVersion(version) {
137
157
  const [major, minor] = version.split(".");
138
- return `${major}.${minor}`;
158
+ // Strip any extended support suffix (e.g. "-es") from the minor version
159
+ const cleanMinor = minor
160
+ ? minor.replace(extendedSupportSuffixPattern, "")
161
+ : minor;
162
+ return `${major}.${cleanMinor}`;
139
163
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "@jest/globals";
2
+ import { hasExtendedSupportSuffix, stripPatchLevelVersion, } from "./config_builder.js";
3
+ describe("hasExtendedSupportSuffix", () => {
4
+ it("returns true for extended support versions", () => {
5
+ expect(hasExtendedSupportSuffix("7.2-es")).toBe(true);
6
+ expect(hasExtendedSupportSuffix("7.1-es")).toBe(true);
7
+ expect(hasExtendedSupportSuffix("8.0-es")).toBe(true);
8
+ });
9
+ it("returns false for regular versions", () => {
10
+ expect(hasExtendedSupportSuffix("7.2")).toBe(false);
11
+ expect(hasExtendedSupportSuffix("8.3")).toBe(false);
12
+ expect(hasExtendedSupportSuffix("7.2.1")).toBe(false);
13
+ expect(hasExtendedSupportSuffix("7.2-lts")).toBe(false);
14
+ });
15
+ });
16
+ describe("stripPatchLevelVersion", () => {
17
+ it("strips patch level from regular versions", () => {
18
+ expect(stripPatchLevelVersion("7.2.5")).toBe("7.2");
19
+ expect(stripPatchLevelVersion("8.3.1")).toBe("8.3");
20
+ });
21
+ it("strips extended support suffix from versions", () => {
22
+ expect(stripPatchLevelVersion("7.2-es")).toBe("7.2");
23
+ expect(stripPatchLevelVersion("7.1-es")).toBe("7.1");
24
+ expect(stripPatchLevelVersion("8.0-es")).toBe("8.0");
25
+ });
26
+ it("returns major.minor unchanged for already-stripped versions", () => {
27
+ expect(stripPatchLevelVersion("7.2")).toBe("7.2");
28
+ expect(stripPatchLevelVersion("8.3")).toBe("8.3");
29
+ });
30
+ });
@@ -26,4 +26,4 @@ export declare function filterFileToRsyncFlagsIfPresent(targetDir: string, filte
26
26
  * sync
27
27
  */
28
28
  export declare function buildRsyncConnectionString({ host, directory, user }: SSHConnectionData, { "remote-sub-directory": subDirectory }: AppInstallationSyncFlags): string;
29
- export declare function appInstallationSyncFlagsToRsyncFlags(f: AppInstallationSyncFlags & SSHConnectionFlags): string[];
29
+ export declare function appInstallationSyncFlagsToRsyncFlags(f: AppInstallationSyncFlags & SSHConnectionFlags, configDir: string): string[];
@@ -1,6 +1,7 @@
1
1
  import { Flags } from "@oclif/core";
2
2
  import path from "path";
3
3
  import { pathExists } from "../../util/fs/pathExists.js";
4
+ import { getSSHKnownHostsArgsString } from "../ssh/knownhosts.js";
4
5
  export const defaultRsyncFilterFile = ".mw-rsync-filter";
5
6
  export const appInstallationSyncFlags = (direction) => ({
6
7
  exclude: Flags.string({
@@ -53,7 +54,7 @@ export function buildRsyncConnectionString({ host, directory, user }, { "remote-
53
54
  }
54
55
  return `${user}@${host}:${directory}/`;
55
56
  }
56
- export function appInstallationSyncFlagsToRsyncFlags(f) {
57
+ export function appInstallationSyncFlagsToRsyncFlags(f, configDir) {
57
58
  const { "dry-run": dryRun, "ssh-identity-file": sshIdentityFile, exclude, } = f;
58
59
  const rsyncOpts = [
59
60
  "--archive",
@@ -68,9 +69,12 @@ export function appInstallationSyncFlagsToRsyncFlags(f) {
68
69
  if (f.delete) {
69
70
  rsyncOpts.push("--delete");
70
71
  }
72
+ // Build the SSH command for rsync with known hosts and optional identity file
73
+ const sshArgs = [getSSHKnownHostsArgsString(configDir)];
71
74
  if (sshIdentityFile) {
72
- rsyncOpts.push("--rsh", `ssh -i ${sshIdentityFile}`);
75
+ sshArgs.push(`-i ${sshIdentityFile}`);
73
76
  }
77
+ rsyncOpts.push("--rsh", `ssh ${sshArgs.join(" ")}`);
74
78
  if (exclude?.length > 0) {
75
79
  rsyncOpts.push(...exclude.map((e) => `--exclude=${e}`));
76
80
  }
@@ -7,6 +7,8 @@ export interface SSHClientFlagOptions {
7
7
  interactive: boolean;
8
8
  /** Additional flags to pass to the SSH client invocation. */
9
9
  additionalFlags: string[];
10
+ /** The oclif config directory for storing the known_hosts file. */
11
+ configDir: string;
10
12
  }
11
13
  /**
12
14
  * Builds the flags and arguments for an SSH client invocation.
@@ -19,4 +21,4 @@ export interface SSHClientFlagOptions {
19
21
  * @param opts Additional (optional) options for the SSH client invocation.
20
22
  * @see https://linux.die.net/man/1/ssh
21
23
  */
22
- export declare function buildSSHClientFlags(username: string, hostname: string, inputFlags: SSHConnectionFlags, opts: Partial<SSHClientFlagOptions>): string[];
24
+ export declare function buildSSHClientFlags(username: string, hostname: string, inputFlags: SSHConnectionFlags, opts: Partial<SSHClientFlagOptions> & Pick<SSHClientFlagOptions, "configDir">): string[];
@@ -1,3 +1,4 @@
1
+ import { getSSHKnownHostsFlags } from "./knownhosts.js";
1
2
  /**
2
3
  * Builds the flags and arguments for an SSH client invocation.
3
4
  *
@@ -10,8 +11,13 @@
10
11
  * @see https://linux.die.net/man/1/ssh
11
12
  */
12
13
  export function buildSSHClientFlags(username, hostname, inputFlags, opts) {
13
- const { interactive, additionalFlags = [] } = opts;
14
- const flags = ["-l", username, interactive ? "-t" : "-T"];
14
+ const { interactive, additionalFlags = [], configDir } = opts;
15
+ const flags = [
16
+ ...getSSHKnownHostsFlags(configDir),
17
+ "-l",
18
+ username,
19
+ interactive ? "-t" : "-T",
20
+ ];
15
21
  if (inputFlags["ssh-identity-file"]) {
16
22
  flags.push("-i", inputFlags["ssh-identity-file"]);
17
23
  }
@@ -15,4 +15,4 @@ export interface RunIO {
15
15
  input: NodeJS.ReadableStream | null;
16
16
  output: NodeJS.WritableStream | null;
17
17
  }
18
- export declare function executeViaSSH(client: MittwaldAPIV2Client, sshConnectionFlags: SSHConnectionFlags, target: RunTarget, command: RunCommand, { input, output }: RunIO): Promise<void>;
18
+ export declare function executeViaSSH(client: MittwaldAPIV2Client, sshConnectionFlags: SSHConnectionFlags, target: RunTarget, command: RunCommand, { input, output }: RunIO, configDir: string): Promise<void>;
@@ -2,13 +2,14 @@ import cp from "child_process";
2
2
  import { getSSHConnectionForAppInstallation } from "./appinstall.js";
3
3
  import { getSSHConnectionForProject } from "./project.js";
4
4
  import { buildSSHClientFlags } from "./connection.js";
5
- export async function executeViaSSH(client, sshConnectionFlags, target, command, { input = null, output = null }) {
5
+ export async function executeViaSSH(client, sshConnectionFlags, target, command, { input = null, output = null }, configDir) {
6
6
  const { user, host } = await connectionDataForTarget(client, target, sshConnectionFlags["ssh-user"]);
7
7
  const sshCommandArgs = "shell" in command
8
8
  ? ["bash", "-c", command.shell]
9
9
  : [command.command, ...command.args];
10
10
  const sshArgs = buildSSHClientFlags(user, host, sshConnectionFlags, {
11
11
  interactive: false,
12
+ configDir,
12
13
  });
13
14
  const ssh = cp.spawn("ssh", [...sshArgs, ...sshCommandArgs], {
14
15
  stdio: [input ? "pipe" : "ignore", output ? "pipe" : "ignore", "pipe"],
@@ -0,0 +1,36 @@
1
+ /** Returns the known hosts content in SSH known_hosts format. */
2
+ export declare function getKnownHostsContent(): string;
3
+ /**
4
+ * Ensures that the mittwald known_hosts file exists and is up-to-date. Returns
5
+ * the path to the file.
6
+ *
7
+ * @param configDir The oclif config directory (typically ~/.config/mw on
8
+ * Linux/macOS)
9
+ */
10
+ export declare function ensureKnownHostsFile(configDir: string): string;
11
+ /**
12
+ * Returns SSH command-line options to use the embedded known hosts.
13
+ *
14
+ * These options configure SSH to use our embedded known hosts file as a
15
+ * "global" known hosts file, which is checked in addition to the user's own
16
+ * ~/.ssh/known_hosts file.
17
+ *
18
+ * @param configDir The oclif config directory (typically ~/.config/mw on
19
+ * Linux/macOS)
20
+ */
21
+ export declare function getSSHKnownHostsFlags(configDir: string): string[];
22
+ /**
23
+ * Returns the SSH options string for use with rsync's --rsh flag or scp.
24
+ *
25
+ * @example
26
+ * // For rsync:
27
+ * `--rsh "ssh ${getSSHKnownHostsArgsString(configDir)}"`;
28
+ *
29
+ * @example
30
+ * // For scp:
31
+ * `scp ${getSSHKnownHostsArgsString(configDir)} source dest`;
32
+ *
33
+ * @param configDir The oclif config directory (typically ~/.config/mw on
34
+ * Linux/macOS)
35
+ */
36
+ export declare function getSSHKnownHostsArgsString(configDir: string): string;
@@ -0,0 +1,101 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ /**
4
+ * Known SSH host keys for mittwald clusters.
5
+ *
6
+ * These host keys are embedded in the CLI to allow SSH connections to succeed
7
+ * even in CI/CD environments where the host keys are not pre-populated in
8
+ * ~/.ssh/known_hosts.
9
+ *
10
+ * @see https://github.com/mittwald/cli/issues/1260
11
+ */
12
+ /**
13
+ * Map of SSH hostnames to their RSA public keys. Format: "hostname" -> "ssh-rsa
14
+ * AAAA..."
15
+ *
16
+ * These keys are hardcoded to have an authentic source that is independent of
17
+ * the user's environment, so that SSH connections to mittwald clusters can
18
+ * succeed even if the user has not previously connected to those hosts and thus
19
+ * does not have their keys in their own known_hosts file. This is especially
20
+ * important for CI/CD environments where the user's home directory may not be
21
+ * persistent or pre-populated with known host keys.
22
+ *
23
+ * The hostnames used here (e.g. "ssh.fabbenstedt.project.host") are the ones
24
+ * that users will see in SSH connection strings, and they must match the
25
+ * hostnames used in the cluster connection instructions and the SSH config
26
+ * generated by the CLI.
27
+ */
28
+ const knownHostKeys = {
29
+ "ssh.fabbenstedt.project.host": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC3X+n//34lLBZQsxkqgs1BrUdbqpU3KeF9n+hF4XHIiq2tXm7w6mBVWZlebUjFzKyLMDsaz/1AnUajWPp5CjCA1brEGBrCVoC7nwgmsTw5CMN/66Kb/sr3DQLlqGl5ylv+hF9RVmcqKyBbkPIHCGJm1eG+rwEWX0QMNpTyeQxDzxBLTtvcYebgkrxNEo/bs7YvTFoR+yWHt2MqJMnVDbzy/sm0mZCOFgE/jZ2RwyGmPWat63cZIFWucTZ/C73dmwwOyX/RH9kUSaxBm77UTtNpM23dFLDYyh7dw9I0q/7beTNnMplIlo4YX5+mh2288qcFa6v7N/u/KvlzBmkRcRk7",
30
+ "ssh.schmalge.project.host": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC8DvrhtCe2FQnMPf0caDgTTeyFYMjC/N6xJK9X7OkUgA0A5UPKYQm9/rurJsv9/1TziFYypGF+IMJT3X9/5O9DZMed2hPMmDjEJYYHXZdHT5vSeFf0WuSMWhDEQD757XVA+4ySbyw5+SKsF8HFtuao9moXYMijM+iHp3TWC8be3eJdGkooZundveGIK2xlcgTr3y2LfE9DFMvpx2q4WgDbLrrplvQdJE8eY3Vaz0o251c1PEoOkwCEGQdZxc9XzXHC+SoGV3YPx8WJqhqkM1ayxhOCKJohoN4Nm9H6fcl0tYJCX62+RPi7RRobPzmwbjre8lA2Ibfl3iJVvB830ipj",
31
+ "ssh.vehlage.project.host": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDTi2M1JMT+yL8F6TVyMvVWpDKQJT9tIAfm32gt5FZoJBMI2NM0REh8eTrI0d5nTiaTQSFskKIXMP6kv1UONaFAWIeh6r5+l3GntyOxcakoE9hqsBzQECXyWGMbav47bkLG3JYaTJchlQeeaV05j53LYvVIjw3mdGQKhnhvyJ7T5TjGCZYU2vnzqCtdXHCXMKAcdfh0XtMLjHBn/GJOolH9jDIzjZo2WbwT5S11tZcOnARFoNxelgIuSHwepz+gbgAILEw8UxjwII0M6kXXM0XgyjL/blOjfbkgiy36qMhDwjsQvA2u336R+UfbONajvBs8heygdVmgPsVc5Uz1PqAD",
32
+ "ssh.gestringen.project.host": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCv2Ojw4TM0+jFyxUgd9EzAAb8mXIzG24yS4V0Md7yphrl9vgZOyEKFhy8KfXAN1qnT1lMQKwfRqthVnfQbbSBGLSrwF1ecbnNtcXX5eAPIP7ycI+PAJJsLmV2099DpXEh9bOrf+vai29OBwtrq1ukFXatocJbC4nKSPWaFuC6yPcgqTTGMZM3ZxVhl6ONtWyao/IoGgTIHiPGxvKqcmqYYU8Zs6VSnYqQ0Sp+v1S5lgECZsDZf+EvBUe53QW8hzqm7z54ef4Qwhn13ODXU7L75LbfKbe0NJdz5higqaiz9BuIMjfbOsBHSTau5suP5fg/3nWsB3KTjl+P1B4Ipow53",
33
+ "ssh.fiestel.project.host": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCmLLYqOR4nTxPpUbayAdhf8BxcbjCdzllS4ERhAOb8tmvm29dKrI9RAoFBwHYZL6fh/GnO3lIXpJI0hPtFifsi7WfdtPxK2rk/D8Ijgp19fqhuVQPnDp8WDbPU6dW7xqlWHsPXLIBdfLvPdgkuyC2FBUYG705P6n048DyC8RTJOA57LZ79W5MiaEfxxSqns1l5amhky1qEUmiuW20Y/QJ3aKxliz+Gw6jdSKfM66mAH/3+JhRyB4oGSPoR3IFTjiba6PLhUYzCj1J9lyGxrNAe8vlJeu/wxFjjCqAC0BIyWE4wuxZdjeCjgCKOFIQAuB3ECFg7Qda5tGLPgG58Ni31",
34
+ "ssh.frotheim.project.host": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDffzlp4b+mFwmVN2aZrm5pwYj/q6VU6P2NKQWsuft3wgroWbo74p2DAqxG904DrcSZJ1ZBbG9up8hBewbMDgMX5pr+A4Nq0nFS7C3+ctrFfpaRXTGOcqxwKlNlrkqhOHDTRvNcZoFd8DseX07YdM5E9vXcRrEFcO4MNuO6jEKtFXE5KRo4SzMUvHrFpDrL608uvv5LTJynkRGu9zrK8AMgURrIGs4GuhsE1/sSYdtu1+r5sMm4tgCrkfxgEi6weJVG4ZCap6tokg/JojMnExNNnhFvNQEg3QiXHMn4vPh45jldezusodhad51mILMPqSmHlDLwuB0KWmfLQidzT+6j",
35
+ "ssh.isenstedt.project.host": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDA4BkiBXadL4ZCixqcOywUp+l4RnzNEtTTC+Gr+w1dQkXuGg5b6RGJmu+KodFgMyOTPwQMnhj0y0ZQKeHSQVQ4xYLO4kAZNc5AgGPuR9a1cozdLisL8E52fl6YP0ytqOtuH/hsKoIskz1Zl8xUP6mtgVqOT3sZtG29kh3JhngP+JBw94yUs0bOIO84ZPpFbEQ9hmkHMrkHgVoCYpgbV5hnY7tOSyKxWVEQChgXwWe11vpmZzv4XZtnP39bwLbiy4mnOkGqLreXb7kCAljF9hqCOyTaC+mSDdAMsM+qdy7A4SHj6RqCd77QHkmzHJ9gBUnGNX8xMN7+9Rlz3qxK6bqD",
36
+ };
37
+ /**
38
+ * Path to the mittwald CLI known_hosts file.
39
+ *
40
+ * @param configDir The oclif config directory (typically ~/.config/mw on
41
+ * Linux/macOS)
42
+ */
43
+ function getKnownHostsFilePath(configDir) {
44
+ return path.join(configDir, "known_hosts");
45
+ }
46
+ /** Returns the known hosts content in SSH known_hosts format. */
47
+ export function getKnownHostsContent() {
48
+ return Object.entries(knownHostKeys)
49
+ .map(([host, key]) => `${host} ${key}`)
50
+ .join("\n");
51
+ }
52
+ /**
53
+ * Ensures that the mittwald known_hosts file exists and is up-to-date. Returns
54
+ * the path to the file.
55
+ *
56
+ * @param configDir The oclif config directory (typically ~/.config/mw on
57
+ * Linux/macOS)
58
+ */
59
+ export function ensureKnownHostsFile(configDir) {
60
+ const filePath = getKnownHostsFilePath(configDir);
61
+ // Ensure the directory exists
62
+ if (!fs.existsSync(configDir)) {
63
+ fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
64
+ }
65
+ // Write the known hosts file
66
+ const content = getKnownHostsContent();
67
+ fs.writeFileSync(filePath, content + "\n", { mode: 0o600 });
68
+ return filePath;
69
+ }
70
+ /**
71
+ * Returns SSH command-line options to use the embedded known hosts.
72
+ *
73
+ * These options configure SSH to use our embedded known hosts file as a
74
+ * "global" known hosts file, which is checked in addition to the user's own
75
+ * ~/.ssh/known_hosts file.
76
+ *
77
+ * @param configDir The oclif config directory (typically ~/.config/mw on
78
+ * Linux/macOS)
79
+ */
80
+ export function getSSHKnownHostsFlags(configDir) {
81
+ const knownHostsFile = ensureKnownHostsFile(configDir);
82
+ return ["-o", `GlobalKnownHostsFile=${knownHostsFile}`];
83
+ }
84
+ /**
85
+ * Returns the SSH options string for use with rsync's --rsh flag or scp.
86
+ *
87
+ * @example
88
+ * // For rsync:
89
+ * `--rsh "ssh ${getSSHKnownHostsArgsString(configDir)}"`;
90
+ *
91
+ * @example
92
+ * // For scp:
93
+ * `scp ${getSSHKnownHostsArgsString(configDir)} source dest`;
94
+ *
95
+ * @param configDir The oclif config directory (typically ~/.config/mw on
96
+ * Linux/macOS)
97
+ */
98
+ export function getSSHKnownHostsArgsString(configDir) {
99
+ const knownHostsFile = ensureKnownHostsFile(configDir);
100
+ return `-o GlobalKnownHostsFile=${knownHostsFile}`;
101
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mittwald/cli",
3
- "version": "1.13.3",
3
+ "version": "1.13.5",
4
4
  "description": "Hand-crafted CLI for the mittwald API",
5
5
  "license": "MIT",
6
6
  "repository": {