@mittwald/cli 1.9.1 → 1.11.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.
Files changed (110) hide show
  1. package/bin/run.js +4 -1
  2. package/dist/commands/app/create/node.d.ts +1 -3
  3. package/dist/commands/app/create/php-worker.d.ts +1 -3
  4. package/dist/commands/app/create/php.d.ts +1 -3
  5. package/dist/commands/app/create/python.d.ts +1 -3
  6. package/dist/commands/app/create/static.d.ts +1 -3
  7. package/dist/commands/app/dependency/update.js +2 -4
  8. package/dist/commands/app/download.js +2 -2
  9. package/dist/commands/app/exec.d.ts +17 -0
  10. package/dist/commands/app/exec.js +71 -0
  11. package/dist/commands/app/install/contao.d.ts +1 -3
  12. package/dist/commands/app/install/joomla.d.ts +1 -3
  13. package/dist/commands/app/install/matomo.d.ts +1 -3
  14. package/dist/commands/app/install/nextcloud.d.ts +1 -3
  15. package/dist/commands/app/install/shopware5.d.ts +1 -3
  16. package/dist/commands/app/install/shopware6.d.ts +1 -3
  17. package/dist/commands/app/install/typo3.d.ts +1 -3
  18. package/dist/commands/app/install/wordpress.d.ts +1 -3
  19. package/dist/commands/app/ssh.d.ts +1 -0
  20. package/dist/commands/app/ssh.js +15 -1
  21. package/dist/commands/app/update.js +4 -8
  22. package/dist/commands/app/upgrade.js +9 -9
  23. package/dist/commands/backup/create.js +1 -2
  24. package/dist/commands/backup/download.js +4 -5
  25. package/dist/commands/container/exec.d.ts +1 -7
  26. package/dist/commands/container/exec.js +11 -22
  27. package/dist/commands/container/run.d.ts +4 -2
  28. package/dist/commands/container/run.js +7 -6
  29. package/dist/commands/database/mysql/create.js +1 -2
  30. package/dist/commands/database/mysql/dump.js +1 -2
  31. package/dist/commands/database/mysql/import.js +1 -2
  32. package/dist/commands/ddev/init.js +2 -6
  33. package/dist/commands/extension/install.js +1 -3
  34. package/dist/commands/login/reset.js +1 -2
  35. package/dist/commands/mail/address/create.js +1 -4
  36. package/dist/commands/mail/deliverybox/create.js +1 -4
  37. package/dist/commands/project/create.js +4 -6
  38. package/dist/commands/registry/create.js +1 -2
  39. package/dist/commands/registry/update.js +1 -2
  40. package/dist/commands/user/api-token/create.js +1 -1
  41. package/dist/commands/user/ssh-key/create.js +3 -8
  42. package/dist/commands/user/ssh-key/import.js +2 -3
  43. package/dist/hooks/prerun/update-brew-check.js +1 -1
  44. package/dist/lib/basecommands/DeleteBaseCommand.js +4 -4
  45. package/dist/lib/ddev/init_assert.js +1 -6
  46. package/dist/lib/ddev/init_database.js +1 -7
  47. package/dist/lib/ddev/init_projecttype.js +2 -11
  48. package/dist/lib/intellij/config.d.ts +5 -0
  49. package/dist/lib/intellij/config.js +295 -0
  50. package/dist/lib/intellij/config.test.d.ts +1 -0
  51. package/dist/lib/intellij/config.test.js +262 -0
  52. package/dist/lib/intellij/config_xml_types.d.ts +72 -0
  53. package/dist/lib/intellij/config_xml_types.js +2 -0
  54. package/dist/lib/resources/app/Installer.d.ts +6 -2
  55. package/dist/lib/resources/app/Installer.js +13 -6
  56. package/dist/lib/resources/app/flags.js +9 -12
  57. package/dist/lib/resources/app/install.d.ts +2 -1
  58. package/dist/lib/resources/app/install.js +3 -1
  59. package/dist/lib/resources/app/versions.js +1 -4
  60. package/dist/lib/resources/app/wait.js +1 -3
  61. package/dist/lib/resources/mail/commons.js +1 -4
  62. package/dist/lib/resources/ssh/appinstall.d.ts +3 -1
  63. package/dist/lib/resources/ssh/appinstall.js +1 -0
  64. package/dist/lib/resources/ssh/environment.d.ts +7 -0
  65. package/dist/lib/resources/ssh/environment.js +21 -0
  66. package/dist/rendering/process/components/ProcessStateIcon.js +1 -1
  67. package/dist/rendering/process/process.d.ts +16 -16
  68. package/dist/rendering/process/process_exec.d.ts +1 -2
  69. package/dist/rendering/process/process_fancy.d.ts +9 -9
  70. package/dist/rendering/process/process_flags.js +4 -0
  71. package/dist/rendering/process/process_quiet.d.ts +3 -4
  72. package/dist/rendering/process/process_simple.d.ts +26 -0
  73. package/dist/rendering/process/process_simple.js +143 -0
  74. package/dist/rendering/process/process_simple.test.d.ts +1 -0
  75. package/dist/rendering/process/process_simple.test.js +149 -0
  76. package/dist/rendering/react/components/AppInstallation/AppBackendAccessHints.d.ts +15 -0
  77. package/dist/rendering/react/components/AppInstallation/AppBackendAccessHints.js +13 -0
  78. package/dist/rendering/react/components/AppInstallation/AppDomainConnectionHints.d.ts +12 -0
  79. package/dist/rendering/react/components/AppInstallation/AppDomainConnectionHints.js +21 -0
  80. package/dist/rendering/react/components/AppInstallation/AppManagementCommands.d.ts +12 -0
  81. package/dist/rendering/react/components/AppInstallation/AppManagementCommands.js +17 -0
  82. package/dist/rendering/react/components/AppInstallation/AppUsageHints.d.ts +17 -0
  83. package/dist/rendering/react/components/AppInstallation/AppUsageHints.js +23 -0
  84. package/dist/rendering/react/components/Container/CommandHint.d.ts +14 -0
  85. package/dist/rendering/react/components/Container/CommandHint.js +13 -0
  86. package/dist/rendering/react/components/Container/ContainerManagementCommands.d.ts +22 -0
  87. package/dist/rendering/react/components/Container/ContainerManagementCommands.js +23 -0
  88. package/dist/rendering/react/components/Container/ContainerUsageHints.d.ts +12 -0
  89. package/dist/rendering/react/components/Container/ContainerUsageHints.js +35 -0
  90. package/dist/rendering/react/components/Container/DomainConnectionHints.d.ts +12 -0
  91. package/dist/rendering/react/components/Container/DomainConnectionHints.js +21 -0
  92. package/dist/rendering/react/components/Container/InternalConnectionHints.d.ts +12 -0
  93. package/dist/rendering/react/components/Container/InternalConnectionHints.js +12 -0
  94. package/dist/rendering/react/components/Container/NoPortsUsageHints.d.ts +12 -0
  95. package/dist/rendering/react/components/Container/NoPortsUsageHints.js +12 -0
  96. package/dist/rendering/react/components/Container/PortConnectionHints.d.ts +13 -0
  97. package/dist/rendering/react/components/Container/PortConnectionHints.js +11 -0
  98. package/dist/rendering/react/components/Container/PortForwardingHints.d.ts +12 -0
  99. package/dist/rendering/react/components/Container/PortForwardingHints.js +18 -0
  100. package/dist/rendering/react/components/Container/types.d.ts +4 -0
  101. package/dist/rendering/react/components/Container/types.js +1 -0
  102. package/dist/rendering/react/components/Error/ErrorBox.d.ts +1 -1
  103. package/dist/rendering/react/components/Error/ErrorBox.js +3 -4
  104. package/dist/rendering/react/components/Error/GenericError.js +1 -1
  105. package/dist/rendering/react/components/ErrorBoundary.d.ts +1 -1
  106. package/dist/rendering/react/components/Success.d.ts +0 -1
  107. package/dist/rendering/react/components/Success.js +4 -2
  108. package/dist/rendering/react/styles/useDefaultBoxStyles.d.ts +11 -0
  109. package/dist/rendering/react/styles/useDefaultBoxStyles.js +23 -0
  110. package/package.json +6 -5
@@ -0,0 +1,295 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { randomUUID } from "crypto";
4
+ import { XMLBuilder, XMLParser } from "fast-xml-parser";
5
+ // Common XML configuration
6
+ const XML_PARSER_CONFIG = {
7
+ ignoreAttributes: false,
8
+ attributeNamePrefix: "@_",
9
+ parseAttributeValue: false,
10
+ };
11
+ const XML_BUILDER_CONFIG = {
12
+ ignoreAttributes: false,
13
+ attributeNamePrefix: "@_",
14
+ format: true,
15
+ suppressBooleanAttributes: false,
16
+ };
17
+ // Common XML document structure
18
+ const createXmlDocumentBase = () => ({
19
+ "?xml": { "@_version": "1.0", "@_encoding": "UTF-8" },
20
+ project: { "@_version": "4" },
21
+ });
22
+ export function generateIntellijConfigs(data, projectDir = ".") {
23
+ const ideaDir = path.join(projectDir, ".idea");
24
+ try {
25
+ if (!fs.existsSync(ideaDir)) {
26
+ fs.mkdirSync(ideaDir, { recursive: true });
27
+ }
28
+ }
29
+ catch (error) {
30
+ throw new Error(`Cannot create .idea directory: ${error instanceof Error ? error.message : error}`);
31
+ }
32
+ const webServerId = randomUUID();
33
+ // First, ensure SSH config exists and get its ID (existing or newly created)
34
+ const sshConfigId = ensureSshConfigExists(data, ideaDir);
35
+ // Then create web server and deployment configs using the correct SSH config ID
36
+ generateWebServersXml(data, webServerId, sshConfigId, ideaDir);
37
+ generateDeploymentXml(data, ideaDir);
38
+ }
39
+ // Common XML manipulation utility
40
+ function manipulateXmlFile(ideaDir, config, newItem) {
41
+ const configPath = path.join(ideaDir, config.filename);
42
+ const parser = new XMLParser(XML_PARSER_CONFIG);
43
+ const builder = new XMLBuilder(XML_BUILDER_CONFIG);
44
+ let xmlDoc = loadExistingXmlDocument(configPath, parser);
45
+ if (xmlDoc) {
46
+ const wasAdded = tryAddToExistingDocument(xmlDoc, newItem, config);
47
+ if (!wasAdded) {
48
+ return; // Item already exists
49
+ }
50
+ }
51
+ else {
52
+ xmlDoc = config.createNewDocumentFn(newItem);
53
+ }
54
+ const xmlContent = builder.build(xmlDoc);
55
+ fs.writeFileSync(configPath, xmlContent);
56
+ }
57
+ function loadExistingXmlDocument(configPath, parser) {
58
+ if (!fs.existsSync(configPath)) {
59
+ return null;
60
+ }
61
+ const content = fs.readFileSync(configPath, "utf8");
62
+ const xmlDoc = parser.parse(content);
63
+ if (!xmlDoc.project?.component) {
64
+ throw new Error("Invalid XML structure");
65
+ }
66
+ return xmlDoc;
67
+ }
68
+ function tryAddToExistingDocument(xmlDoc, newItem, config) {
69
+ // This function delegates to the specific addItemFn
70
+ // Return false if item already exists, true if added
71
+ return config.addItemFn(xmlDoc, newItem);
72
+ }
73
+ // Helper function to handle array/single element patterns
74
+ function ensureArray(item) {
75
+ return Array.isArray(item) ? item : [item];
76
+ }
77
+ // Specific type-safe helper functions for each XML structure
78
+ function addSshConfig(configs, newConfig) {
79
+ if (Array.isArray(configs.sshConfig)) {
80
+ configs.sshConfig.push(newConfig);
81
+ }
82
+ else if (configs.sshConfig) {
83
+ configs.sshConfig = [configs.sshConfig, newConfig];
84
+ }
85
+ else {
86
+ configs.sshConfig = newConfig;
87
+ }
88
+ }
89
+ function addWebServer(option, newServer) {
90
+ if (Array.isArray(option.webServer)) {
91
+ option.webServer.push(newServer);
92
+ }
93
+ else if (option.webServer) {
94
+ option.webServer = [option.webServer, newServer];
95
+ }
96
+ else {
97
+ option.webServer = newServer;
98
+ }
99
+ }
100
+ function addDeploymentPath(serverData, newPath) {
101
+ if (Array.isArray(serverData.paths)) {
102
+ serverData.paths.push(newPath);
103
+ }
104
+ else if (serverData.paths) {
105
+ serverData.paths = [serverData.paths, newPath];
106
+ }
107
+ else {
108
+ serverData.paths = newPath;
109
+ }
110
+ }
111
+ // Function to ensure SSH config exists and return its ID
112
+ function ensureSshConfigExists(data, ideaDir) {
113
+ const configPath = path.join(ideaDir, "sshConfigs.xml");
114
+ const parser = new XMLParser(XML_PARSER_CONFIG);
115
+ const existingXmlDoc = loadExistingXmlDocument(configPath, parser);
116
+ if (existingXmlDoc) {
117
+ // Check if SSH config for this host already exists
118
+ const configs = existingXmlDoc.project.component.configs;
119
+ if (configs?.sshConfig) {
120
+ const existingConfigs = ensureArray(configs.sshConfig);
121
+ const existingConfig = existingConfigs.find((config) => config["@_host"] === data.host);
122
+ if (existingConfig) {
123
+ // Return existing SSH config ID
124
+ return existingConfig["@_id"];
125
+ }
126
+ }
127
+ }
128
+ // SSH config doesn't exist, create it with a new ID
129
+ const newSshConfigId = randomUUID();
130
+ generateSshConfigsXml(data, newSshConfigId, ideaDir);
131
+ return newSshConfigId;
132
+ }
133
+ // SSH Config specific functions
134
+ function createSshConfigItem(host, username, configId) {
135
+ return {
136
+ "@_authType": "OPEN_SSH",
137
+ "@_host": host,
138
+ "@_id": configId,
139
+ "@_port": "22",
140
+ "@_nameFormat": "DESCRIPTIVE",
141
+ "@_username": username,
142
+ "@_useOpenSSHConfig": "true",
143
+ };
144
+ }
145
+ function generateSshConfigsXml(data, configId, ideaDir) {
146
+ const sshConfigItem = createSshConfigItem(data.host, data.user, configId);
147
+ const config = {
148
+ filename: "sshConfigs.xml",
149
+ componentName: "SshConfigs",
150
+ checkDuplicateFn: (existing, newItem) => existing.some((config) => config["@_host"] === newItem["@_host"]),
151
+ addItemFn: (xmlDoc, newItem) => {
152
+ const configs = xmlDoc.project.component.configs;
153
+ if (configs?.sshConfig) {
154
+ const existingConfigs = ensureArray(configs.sshConfig);
155
+ if (existingConfigs.some((config) => config["@_host"] === newItem["@_host"])) {
156
+ return false; // Already exists
157
+ }
158
+ addSshConfig(configs, newItem);
159
+ }
160
+ else {
161
+ if (!xmlDoc.project.component.configs) {
162
+ xmlDoc.project.component.configs = {};
163
+ }
164
+ xmlDoc.project.component.configs.sshConfig = newItem;
165
+ }
166
+ return true;
167
+ },
168
+ createNewDocumentFn: (newItem) => ({
169
+ ...createXmlDocumentBase(),
170
+ project: {
171
+ "@_version": "4",
172
+ component: {
173
+ "@_name": "SshConfigs",
174
+ configs: { sshConfig: newItem },
175
+ },
176
+ },
177
+ }),
178
+ };
179
+ manipulateXmlFile(ideaDir, config, sshConfigItem);
180
+ }
181
+ // Web Server specific functions
182
+ function createWebServerItem(serverId, appShortId, host, username, sshConfigId) {
183
+ return {
184
+ "@_id": serverId,
185
+ "@_name": appShortId,
186
+ fileTransfer: {
187
+ "@_accessType": "SFTP",
188
+ "@_host": host,
189
+ "@_port": "22",
190
+ "@_sshConfigId": sshConfigId,
191
+ "@_sshConfig": `${username}@${host}:22 agent`,
192
+ "@_authAgent": "true",
193
+ advancedOptions: {
194
+ advancedOptions: {
195
+ "@_dataProtectionLevel": "Private",
196
+ "@_keepAliveTimeout": "0",
197
+ "@_passiveMode": "true",
198
+ "@_shareSSLContext": "true",
199
+ },
200
+ },
201
+ },
202
+ };
203
+ }
204
+ function generateWebServersXml(data, serverId, sshConfigId, ideaDir) {
205
+ const webServerItem = createWebServerItem(serverId, data.appShortId, data.host, data.user, sshConfigId);
206
+ const config = {
207
+ filename: "webServers.xml",
208
+ componentName: "WebServers",
209
+ checkDuplicateFn: (existing, newItem) => existing.some((server) => server["@_name"] === newItem["@_name"]),
210
+ addItemFn: (xmlDoc, newItem) => {
211
+ const servers = xmlDoc.project.component.option;
212
+ if (servers?.webServer) {
213
+ const existingServers = ensureArray(servers.webServer);
214
+ if (existingServers.some((server) => server["@_name"] === newItem["@_name"])) {
215
+ return false; // Already exists
216
+ }
217
+ addWebServer(servers, newItem);
218
+ }
219
+ else {
220
+ if (!xmlDoc.project.component.option) {
221
+ xmlDoc.project.component.option = { "@_name": "servers" };
222
+ }
223
+ xmlDoc.project.component.option.webServer = newItem;
224
+ }
225
+ return true;
226
+ },
227
+ createNewDocumentFn: (newItem) => ({
228
+ ...createXmlDocumentBase(),
229
+ project: {
230
+ "@_version": "4",
231
+ component: {
232
+ "@_name": "WebServers",
233
+ option: {
234
+ "@_name": "servers",
235
+ webServer: newItem,
236
+ },
237
+ },
238
+ },
239
+ }),
240
+ };
241
+ manipulateXmlFile(ideaDir, config, webServerItem);
242
+ }
243
+ // Deployment specific functions
244
+ function createDeploymentPathItem(appShortId, directory) {
245
+ return {
246
+ "@_name": appShortId,
247
+ serverdata: {
248
+ mappings: {
249
+ mapping: {
250
+ "@_deploy": directory,
251
+ "@_local": "$PROJECT_DIR$",
252
+ "@_web": "/",
253
+ },
254
+ },
255
+ },
256
+ };
257
+ }
258
+ function generateDeploymentXml(data, ideaDir) {
259
+ const deploymentPathItem = createDeploymentPathItem(data.appShortId, data.directory);
260
+ const config = {
261
+ filename: "deployment.xml",
262
+ componentName: "PublishConfigData",
263
+ checkDuplicateFn: (existing, newItem) => existing.some((path) => path["@_name"] === newItem["@_name"]),
264
+ addItemFn: (xmlDoc, newItem) => {
265
+ const serverData = xmlDoc.project.component.serverData;
266
+ if (serverData?.paths) {
267
+ const existingPaths = ensureArray(serverData.paths);
268
+ if (existingPaths.some((path) => path["@_name"] === newItem["@_name"])) {
269
+ return false; // Already exists
270
+ }
271
+ addDeploymentPath(serverData, newItem);
272
+ }
273
+ else {
274
+ if (!xmlDoc.project.component.serverData) {
275
+ xmlDoc.project.component.serverData = {};
276
+ }
277
+ xmlDoc.project.component.serverData.paths = newItem;
278
+ }
279
+ return true;
280
+ },
281
+ createNewDocumentFn: (newItem) => ({
282
+ ...createXmlDocumentBase(),
283
+ project: {
284
+ "@_version": "4",
285
+ component: {
286
+ "@_name": "PublishConfigData",
287
+ "@_serverName": data.appShortId,
288
+ "@_remoteFilesAllowedToDisappearOnAutoupload": "false",
289
+ serverData: { paths: newItem },
290
+ },
291
+ },
292
+ }),
293
+ };
294
+ manipulateXmlFile(ideaDir, config, deploymentPathItem);
295
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,262 @@
1
+ import { describe, expect, test, beforeEach, afterEach } from "@jest/globals";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import * as os from "os";
5
+ import { XMLParser } from "fast-xml-parser";
6
+ import { generateIntellijConfigs } from "./config.js";
7
+ describe("IntelliJ Config Generator", () => {
8
+ let tempDir;
9
+ let testData;
10
+ const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_" });
11
+ beforeEach(() => {
12
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "intellij-test-"));
13
+ testData = {
14
+ host: "ssh.test.example.com",
15
+ user: "testuser@app123",
16
+ directory: "/var/www/html/app123",
17
+ appShortId: "app123",
18
+ };
19
+ });
20
+ afterEach(() => {
21
+ fs.rmSync(tempDir, { recursive: true, force: true });
22
+ });
23
+ describe("generateIntellijConfigs", () => {
24
+ test("should create .idea directory if it doesn't exist", () => {
25
+ generateIntellijConfigs(testData, tempDir);
26
+ const ideaDir = path.join(tempDir, ".idea");
27
+ expect(fs.existsSync(ideaDir)).toBe(true);
28
+ expect(fs.statSync(ideaDir).isDirectory()).toBe(true);
29
+ });
30
+ test("should generate all three configuration files", () => {
31
+ generateIntellijConfigs(testData, tempDir);
32
+ const ideaDir = path.join(tempDir, ".idea");
33
+ expect(fs.existsSync(path.join(ideaDir, "sshConfigs.xml"))).toBe(true);
34
+ expect(fs.existsSync(path.join(ideaDir, "webServers.xml"))).toBe(true);
35
+ expect(fs.existsSync(path.join(ideaDir, "deployment.xml"))).toBe(true);
36
+ });
37
+ test("should generate valid XML files", () => {
38
+ generateIntellijConfigs(testData, tempDir);
39
+ const ideaDir = path.join(tempDir, ".idea");
40
+ // Test that each file can be parsed without errors
41
+ const sshContent = fs.readFileSync(path.join(ideaDir, "sshConfigs.xml"), "utf8");
42
+ expect(() => parser.parse(sshContent)).not.toThrow();
43
+ const webContent = fs.readFileSync(path.join(ideaDir, "webServers.xml"), "utf8");
44
+ expect(() => parser.parse(webContent)).not.toThrow();
45
+ const deployContent = fs.readFileSync(path.join(ideaDir, "deployment.xml"), "utf8");
46
+ expect(() => parser.parse(deployContent)).not.toThrow();
47
+ });
48
+ });
49
+ describe("SSH Configs XML", () => {
50
+ test("should create SSH config with correct attributes", () => {
51
+ generateIntellijConfigs(testData, tempDir);
52
+ const configPath = path.join(tempDir, ".idea", "sshConfigs.xml");
53
+ const content = fs.readFileSync(configPath, "utf8");
54
+ const xmlDoc = parser.parse(content);
55
+ expect(xmlDoc.project["@_version"]).toBe("4");
56
+ expect(xmlDoc.project.component["@_name"]).toBe("SshConfigs");
57
+ const sshConfig = xmlDoc.project.component.configs.sshConfig;
58
+ expect(sshConfig["@_authType"]).toBe("OPEN_SSH");
59
+ expect(sshConfig["@_host"]).toBe("ssh.test.example.com");
60
+ expect(sshConfig["@_port"]).toBe("22");
61
+ expect(sshConfig["@_username"]).toBe("testuser@app123");
62
+ expect(sshConfig["@_useOpenSSHConfig"]).toBe("true");
63
+ expect(sshConfig["@_nameFormat"]).toBe("DESCRIPTIVE");
64
+ expect(sshConfig["@_id"]).toBeDefined();
65
+ });
66
+ test("should not add duplicate SSH configs for same host", () => {
67
+ generateIntellijConfigs(testData, tempDir);
68
+ generateIntellijConfigs(testData, tempDir); // Run twice
69
+ const configPath = path.join(tempDir, ".idea", "sshConfigs.xml");
70
+ const content = fs.readFileSync(configPath, "utf8");
71
+ const xmlDoc = parser.parse(content);
72
+ const sshConfig = xmlDoc.project.component.configs.sshConfig;
73
+ expect(Array.isArray(sshConfig)).toBe(false); // Should still be single config
74
+ });
75
+ test("should add multiple SSH configs for different hosts", () => {
76
+ generateIntellijConfigs(testData, tempDir);
77
+ const differentHostData = { ...testData, host: "ssh.other.example.com", appShortId: "app456" };
78
+ generateIntellijConfigs(differentHostData, tempDir);
79
+ const configPath = path.join(tempDir, ".idea", "sshConfigs.xml");
80
+ const content = fs.readFileSync(configPath, "utf8");
81
+ const xmlDoc = parser.parse(content);
82
+ const sshConfigs = xmlDoc.project.component.configs.sshConfig;
83
+ expect(Array.isArray(sshConfigs)).toBe(true);
84
+ expect(sshConfigs).toHaveLength(2);
85
+ expect(sshConfigs[0]["@_host"]).toBe("ssh.test.example.com");
86
+ expect(sshConfigs[1]["@_host"]).toBe("ssh.other.example.com");
87
+ });
88
+ test("should handle existing SSH config file correctly", () => {
89
+ const ideaDir = path.join(tempDir, ".idea");
90
+ fs.mkdirSync(ideaDir, { recursive: true });
91
+ const existingConfig = `<?xml version="1.0" encoding="UTF-8"?>
92
+ <project version="4">
93
+ <component name="SshConfigs">
94
+ <configs>
95
+ <sshConfig authType="OPEN_SSH" host="existing.example.com" id="existing-id" port="22" nameFormat="DESCRIPTIVE" username="existing" useOpenSSHConfig="true" />
96
+ </configs>
97
+ </component>
98
+ </project>`;
99
+ fs.writeFileSync(path.join(ideaDir, "sshConfigs.xml"), existingConfig);
100
+ generateIntellijConfigs(testData, tempDir);
101
+ const configPath = path.join(ideaDir, "sshConfigs.xml");
102
+ const content = fs.readFileSync(configPath, "utf8");
103
+ const xmlDoc = parser.parse(content);
104
+ const sshConfigs = xmlDoc.project.component.configs.sshConfig;
105
+ expect(Array.isArray(sshConfigs)).toBe(true);
106
+ expect(sshConfigs).toHaveLength(2);
107
+ });
108
+ test("should reuse existing SSH config ID in web server configuration", () => {
109
+ const ideaDir = path.join(tempDir, ".idea");
110
+ fs.mkdirSync(ideaDir, { recursive: true });
111
+ // Create existing SSH config with the same host as testData
112
+ const existingConfig = `<?xml version="1.0" encoding="UTF-8"?>
113
+ <project version="4">
114
+ <component name="SshConfigs">
115
+ <configs>
116
+ <sshConfig authType="OPEN_SSH" host="ssh.test.example.com" id="existing-ssh-id" port="22" nameFormat="DESCRIPTIVE" username="testuser@app123" useOpenSSHConfig="true" />
117
+ </configs>
118
+ </component>
119
+ </project>`;
120
+ fs.writeFileSync(path.join(ideaDir, "sshConfigs.xml"), existingConfig);
121
+ generateIntellijConfigs(testData, tempDir);
122
+ // Verify SSH config wasn't duplicated
123
+ const sshConfigPath = path.join(ideaDir, "sshConfigs.xml");
124
+ const sshContent = fs.readFileSync(sshConfigPath, "utf8");
125
+ const sshXmlDoc = parser.parse(sshContent);
126
+ const sshConfig = sshXmlDoc.project.component.configs.sshConfig;
127
+ expect(Array.isArray(sshConfig)).toBe(false); // Should still be single config
128
+ expect(sshConfig["@_id"]).toBe("existing-ssh-id");
129
+ // Verify web server config uses the existing SSH config ID
130
+ const webConfigPath = path.join(ideaDir, "webServers.xml");
131
+ const webContent = fs.readFileSync(webConfigPath, "utf8");
132
+ const webXmlDoc = parser.parse(webContent);
133
+ const webServer = webXmlDoc.project.component.option.webServer;
134
+ expect(webServer.fileTransfer["@_sshConfigId"]).toBe("existing-ssh-id");
135
+ });
136
+ });
137
+ describe("Web Servers XML", () => {
138
+ test("should create web server config with correct structure", () => {
139
+ generateIntellijConfigs(testData, tempDir);
140
+ const configPath = path.join(tempDir, ".idea", "webServers.xml");
141
+ const content = fs.readFileSync(configPath, "utf8");
142
+ const xmlDoc = parser.parse(content);
143
+ expect(xmlDoc.project["@_version"]).toBe("4");
144
+ expect(xmlDoc.project.component["@_name"]).toBe("WebServers");
145
+ expect(xmlDoc.project.component.option["@_name"]).toBe("servers");
146
+ const webServer = xmlDoc.project.component.option.webServer;
147
+ expect(webServer["@_name"]).toBe("app123");
148
+ expect(webServer["@_id"]).toBeDefined();
149
+ const fileTransfer = webServer.fileTransfer;
150
+ expect(fileTransfer["@_accessType"]).toBe("SFTP");
151
+ expect(fileTransfer["@_host"]).toBe("ssh.test.example.com");
152
+ expect(fileTransfer["@_port"]).toBe("22");
153
+ expect(fileTransfer["@_authAgent"]).toBe("true");
154
+ expect(fileTransfer["@_sshConfig"]).toBe("testuser@app123@ssh.test.example.com:22 agent");
155
+ const advancedOptions = fileTransfer.advancedOptions.advancedOptions;
156
+ expect(advancedOptions["@_dataProtectionLevel"]).toBe("Private");
157
+ expect(advancedOptions["@_keepAliveTimeout"]).toBe("0");
158
+ expect(advancedOptions["@_passiveMode"]).toBe("true");
159
+ expect(advancedOptions["@_shareSSLContext"]).toBe("true");
160
+ });
161
+ test("should not add duplicate web servers for same app", () => {
162
+ generateIntellijConfigs(testData, tempDir);
163
+ generateIntellijConfigs(testData, tempDir); // Run twice
164
+ const configPath = path.join(tempDir, ".idea", "webServers.xml");
165
+ const content = fs.readFileSync(configPath, "utf8");
166
+ const xmlDoc = parser.parse(content);
167
+ const webServer = xmlDoc.project.component.option.webServer;
168
+ expect(Array.isArray(webServer)).toBe(false); // Should still be single server
169
+ });
170
+ });
171
+ describe("Deployment XML", () => {
172
+ test("should create deployment config with correct mapping", () => {
173
+ generateIntellijConfigs(testData, tempDir);
174
+ const configPath = path.join(tempDir, ".idea", "deployment.xml");
175
+ const content = fs.readFileSync(configPath, "utf8");
176
+ const xmlDoc = parser.parse(content);
177
+ expect(xmlDoc.project["@_version"]).toBe("4");
178
+ expect(xmlDoc.project.component["@_name"]).toBe("PublishConfigData");
179
+ expect(xmlDoc.project.component["@_serverName"]).toBe("app123");
180
+ expect(xmlDoc.project.component["@_remoteFilesAllowedToDisappearOnAutoupload"]).toBe("false");
181
+ const paths = xmlDoc.project.component.serverData.paths;
182
+ expect(paths["@_name"]).toBe("app123");
183
+ const mapping = paths.serverdata.mappings.mapping;
184
+ expect(mapping["@_deploy"]).toBe("/var/www/html/app123");
185
+ expect(mapping["@_local"]).toBe("$PROJECT_DIR$");
186
+ expect(mapping["@_web"]).toBe("/");
187
+ });
188
+ test("should not add duplicate deployment configs for same app", () => {
189
+ generateIntellijConfigs(testData, tempDir);
190
+ generateIntellijConfigs(testData, tempDir); // Run twice
191
+ const configPath = path.join(tempDir, ".idea", "deployment.xml");
192
+ const content = fs.readFileSync(configPath, "utf8");
193
+ const xmlDoc = parser.parse(content);
194
+ const paths = xmlDoc.project.component.serverData.paths;
195
+ expect(Array.isArray(paths)).toBe(false); // Should still be single path
196
+ });
197
+ });
198
+ describe("SSH username handling", () => {
199
+ test("should use complete user string as SSH username", () => {
200
+ const userData = { ...testData, user: "m.helmich@mittwald.de@a-ce3rzc" };
201
+ generateIntellijConfigs(userData, tempDir);
202
+ const configPath = path.join(tempDir, ".idea", "sshConfigs.xml");
203
+ const content = fs.readFileSync(configPath, "utf8");
204
+ const xmlDoc = parser.parse(content);
205
+ const sshConfig = xmlDoc.project.component.configs.sshConfig;
206
+ expect(sshConfig["@_username"]).toBe("m.helmich@mittwald.de@a-ce3rzc");
207
+ // Also check web server config uses the same username
208
+ const webConfigPath = path.join(tempDir, ".idea", "webServers.xml");
209
+ const webContent = fs.readFileSync(webConfigPath, "utf8");
210
+ const webXmlDoc = parser.parse(webContent);
211
+ const webServer = webXmlDoc.project.component.option.webServer;
212
+ expect(webServer.fileTransfer["@_sshConfig"]).toBe("m.helmich@mittwald.de@a-ce3rzc@ssh.test.example.com:22 agent");
213
+ });
214
+ test("should handle simple usernames as-is", () => {
215
+ const userData = { ...testData, user: "plainuser" };
216
+ generateIntellijConfigs(userData, tempDir);
217
+ const configPath = path.join(tempDir, ".idea", "sshConfigs.xml");
218
+ const content = fs.readFileSync(configPath, "utf8");
219
+ const xmlDoc = parser.parse(content);
220
+ const sshConfig = xmlDoc.project.component.configs.sshConfig;
221
+ expect(sshConfig["@_username"]).toBe("plainuser");
222
+ });
223
+ });
224
+ describe("Error handling", () => {
225
+ test("should handle invalid directory gracefully", () => {
226
+ const invalidDir = "/root/nonexistent/readonly";
227
+ // This should throw an error for invalid directory
228
+ expect(() => {
229
+ generateIntellijConfigs(testData, invalidDir);
230
+ }).toThrow("Cannot create .idea directory");
231
+ });
232
+ test("should throw error for malformed existing XML files", () => {
233
+ const ideaDir = path.join(tempDir, ".idea");
234
+ fs.mkdirSync(ideaDir, { recursive: true });
235
+ // Create malformed XML
236
+ const malformedXml = "<?xml version='1.0'?><project><unclosed>";
237
+ fs.writeFileSync(path.join(ideaDir, "sshConfigs.xml"), malformedXml);
238
+ // Should throw an error for malformed XML
239
+ expect(() => {
240
+ generateIntellijConfigs(testData, tempDir);
241
+ }).toThrow();
242
+ });
243
+ });
244
+ describe("XML structure validation", () => {
245
+ test("should generate properly formatted XML with correct declarations", () => {
246
+ generateIntellijConfigs(testData, tempDir);
247
+ const ideaDir = path.join(tempDir, ".idea");
248
+ // Check SSH config
249
+ const sshContent = fs.readFileSync(path.join(ideaDir, "sshConfigs.xml"), "utf8");
250
+ expect(sshContent).toMatch(/^<\?xml version="1\.0" encoding="UTF-8"\?>/);
251
+ expect(sshContent).toContain('<project version="4">');
252
+ // Check web servers
253
+ const webContent = fs.readFileSync(path.join(ideaDir, "webServers.xml"), "utf8");
254
+ expect(webContent).toMatch(/^<\?xml version="1\.0" encoding="UTF-8"\?>/);
255
+ expect(webContent).toContain('<project version="4">');
256
+ // Check deployment
257
+ const deployContent = fs.readFileSync(path.join(ideaDir, "deployment.xml"), "utf8");
258
+ expect(deployContent).toMatch(/^<\?xml version="1\.0" encoding="UTF-8"\?>/);
259
+ expect(deployContent).toContain('<project version="4">');
260
+ });
261
+ });
262
+ });
@@ -0,0 +1,72 @@
1
+ export interface XmlDocument {
2
+ "?xml": {
3
+ "@_version": string;
4
+ "@_encoding": string;
5
+ };
6
+ project: XmlProject;
7
+ }
8
+ export interface XmlProject {
9
+ "@_version": string;
10
+ component: XmlComponent;
11
+ }
12
+ export interface XmlComponent {
13
+ "@_name": string;
14
+ configs?: XmlConfigs;
15
+ option?: XmlOption;
16
+ serverData?: XmlServerData;
17
+ "@_serverName"?: string;
18
+ "@_remoteFilesAllowedToDisappearOnAutoupload"?: string;
19
+ }
20
+ export interface XmlConfigs {
21
+ sshConfig?: XmlSshConfig | XmlSshConfig[];
22
+ }
23
+ export interface XmlSshConfig {
24
+ "@_authType": string;
25
+ "@_host": string;
26
+ "@_id": string;
27
+ "@_port": string;
28
+ "@_nameFormat": string;
29
+ "@_username": string;
30
+ "@_useOpenSSHConfig": string;
31
+ }
32
+ export interface XmlOption {
33
+ "@_name": string;
34
+ webServer?: XmlWebServer | XmlWebServer[];
35
+ }
36
+ export interface XmlWebServer {
37
+ "@_id": string;
38
+ "@_name": string;
39
+ fileTransfer: XmlFileTransfer;
40
+ }
41
+ export interface XmlFileTransfer {
42
+ "@_accessType": string;
43
+ "@_host": string;
44
+ "@_port": string;
45
+ "@_sshConfigId": string;
46
+ "@_sshConfig": string;
47
+ "@_authAgent": string;
48
+ advancedOptions: XmlAdvancedOptions;
49
+ }
50
+ export interface XmlAdvancedOptions {
51
+ advancedOptions: {
52
+ "@_dataProtectionLevel": string;
53
+ "@_keepAliveTimeout": string;
54
+ "@_passiveMode": string;
55
+ "@_shareSSLContext": string;
56
+ };
57
+ }
58
+ export interface XmlServerData {
59
+ paths?: XmlPath | XmlPath[];
60
+ }
61
+ export interface XmlPath {
62
+ "@_name": string;
63
+ serverdata: {
64
+ mappings: {
65
+ mapping: {
66
+ "@_deploy": string;
67
+ "@_local": string;
68
+ "@_web": string;
69
+ };
70
+ };
71
+ };
72
+ }
@@ -0,0 +1,2 @@
1
+ // XML Document Structure Types for IntelliJ IDEA configuration files
2
+ export {};
@@ -1,11 +1,15 @@
1
1
  import { OutputFlags } from "@oclif/core/interfaces";
2
2
  import { AvailableFlagName, RelevantFlagInput } from "./flags.js";
3
3
  import React from "react";
4
- import { MittwaldAPIV2Client } from "@mittwald/api-client";
4
+ import { MittwaldAPIV2, MittwaldAPIV2Client } from "@mittwald/api-client";
5
5
  import { Config } from "@oclif/core";
6
+ type AppVersion = MittwaldAPIV2.Components.Schemas.AppAppVersion;
7
+ type AppInstallation = MittwaldAPIV2.Components.Schemas.AppAppInstallation;
6
8
  type ImplicitDefaultFlag = "wait" | "wait-timeout" | "site-title";
7
9
  export interface AppInstallationResult {
8
- appInstallationId: string;
10
+ appInstallation: AppInstallation;
11
+ appVersion: AppVersion;
12
+ host?: string;
9
13
  }
10
14
  export declare class AppInstaller<TFlagName extends AvailableFlagName> {
11
15
  readonly appId: string;