@mittwald/cli 1.11.2 → 1.13.1-beta.6

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 (64) hide show
  1. package/README.md +5 -0
  2. package/bin/dev.js +0 -0
  3. package/dist/commands/app/copy.d.ts +1 -0
  4. package/dist/commands/app/copy.js +6 -1
  5. package/dist/commands/app/create/node.d.ts +1 -1
  6. package/dist/commands/app/create/php-worker.d.ts +1 -1
  7. package/dist/commands/app/create/php.d.ts +1 -1
  8. package/dist/commands/app/create/python.d.ts +1 -1
  9. package/dist/commands/app/create/static.d.ts +1 -1
  10. package/dist/commands/app/dependency/update.js +4 -6
  11. package/dist/commands/app/dependency/versions.js +3 -3
  12. package/dist/commands/app/install/contao.d.ts +1 -1
  13. package/dist/commands/app/install/joomla.d.ts +1 -1
  14. package/dist/commands/app/install/matomo.d.ts +1 -1
  15. package/dist/commands/app/install/nextcloud.d.ts +1 -1
  16. package/dist/commands/app/install/shopware5.d.ts +1 -1
  17. package/dist/commands/app/install/shopware6.d.ts +1 -1
  18. package/dist/commands/app/install/typo3.d.ts +1 -1
  19. package/dist/commands/app/install/wordpress.d.ts +1 -1
  20. package/dist/commands/app/open.d.ts +3 -0
  21. package/dist/commands/app/open.js +35 -9
  22. package/dist/commands/container/logs.d.ts +1 -0
  23. package/dist/commands/container/logs.js +8 -0
  24. package/dist/commands/container/port-forward.js +2 -2
  25. package/dist/commands/container/run.d.ts +9 -0
  26. package/dist/commands/container/run.js +42 -4
  27. package/dist/commands/container/update.js +2 -2
  28. package/dist/commands/context/get.js +1 -0
  29. package/dist/commands/database/mysql/create.d.ts +1 -1
  30. package/dist/commands/database/mysql/create.js +3 -2
  31. package/dist/commands/ddev/init.js +2 -1
  32. package/dist/commands/org/invite/list-own.d.ts +1 -0
  33. package/dist/commands/org/membership/list-own.d.ts +1 -0
  34. package/dist/commands/project/create.js +12 -3
  35. package/dist/commands/stack/deploy.d.ts +2 -0
  36. package/dist/commands/stack/deploy.js +49 -6
  37. package/dist/lib/context/FlagSetBuilder.d.ts +1 -0
  38. package/dist/lib/context/FlagSetBuilder.js +7 -1
  39. package/dist/lib/intellij/config.test.js +9 -2
  40. package/dist/lib/resources/app/Installer.d.ts +1 -1
  41. package/dist/lib/resources/app/Installer.js +14 -1
  42. package/dist/lib/resources/app/flags.d.ts +3 -1
  43. package/dist/lib/resources/app/flags.js +12 -0
  44. package/dist/lib/resources/app/install.d.ts +1 -0
  45. package/dist/lib/resources/app/install.js +1 -0
  46. package/dist/lib/resources/app/versions.d.ts +7 -1
  47. package/dist/lib/resources/app/versions.js +35 -2
  48. package/dist/lib/resources/container/containerconfig.js +7 -1
  49. package/dist/lib/resources/container/containerconfig.test.d.ts +1 -0
  50. package/dist/lib/resources/container/containerconfig.test.js +25 -0
  51. package/dist/lib/resources/database/mysql/flags.js +1 -1
  52. package/dist/lib/resources/stack/env.d.ts +10 -0
  53. package/dist/lib/resources/stack/env.js +22 -0
  54. package/dist/lib/resources/stack/flags.js +16 -1
  55. package/dist/lib/resources/stack/template-loader.d.ts +18 -0
  56. package/dist/lib/resources/stack/template-loader.js +94 -0
  57. package/dist/lib/resources/stack/template-loader.test.d.ts +1 -0
  58. package/dist/lib/resources/stack/template-loader.test.js +125 -0
  59. package/dist/lib/units/PortMapping.d.ts +2 -0
  60. package/dist/lib/units/PortMapping.js +21 -6
  61. package/dist/lib/units/PortMapping.test.js +10 -0
  62. package/package.json +13 -9
  63. package/dist/commands/cronjob/execution/abort.d.ts +0 -18
  64. package/dist/commands/cronjob/execution/abort.js +0 -41
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Flags } from "@oclif/core";
3
3
  import { assertStatus } from "@mittwald/api-client-commons";
4
4
  import { serverFlags } from "../../lib/resources/server/flags.js";
@@ -7,6 +7,7 @@ import { Success } from "../../rendering/react/components/Success.js";
7
7
  import { makeProcessRenderer, processFlags, } from "../../rendering/process/process_flags.js";
8
8
  import { waitFlags, waitUntil } from "../../lib/wait.js";
9
9
  import Context from "../../lib/context/Context.js";
10
+ import { Value } from "../../rendering/react/components/Value.js";
10
11
  export default class Create extends ExecRenderBaseCommand {
11
12
  static description = "Create a new project";
12
13
  static flags = {
@@ -19,7 +20,8 @@ export default class Create extends ExecRenderBaseCommand {
19
20
  description: "A description for the project.",
20
21
  }),
21
22
  "update-context": Flags.boolean({
22
- description: "Update the CLI context to use the newly created project",
23
+ description: "update the CLI context to use the newly created project",
24
+ char: "c",
23
25
  }),
24
26
  };
25
27
  async exec() {
@@ -48,12 +50,16 @@ export default class Create extends ExecRenderBaseCommand {
48
50
  }, this.flags["wait-timeout"]);
49
51
  stepWaiting.complete();
50
52
  }
53
+ const projectResult = await this.apiClient.project.getProject({
54
+ projectId: result.data.id,
55
+ });
56
+ assertStatus(projectResult, 200);
51
57
  if (flags["update-context"]) {
52
58
  await process.runStep("updating CLI context", async () => {
53
59
  await new Context(this.apiClient, this.config).setProjectId(result.data.id);
54
60
  });
55
61
  }
56
- process.complete(_jsx(Success, { children: "Your new project was successfully created! \uD83D\uDE80" }));
62
+ await process.complete(_jsx(ProjectCreationSuccess, { shortId: projectResult.data.shortId }));
57
63
  return { projectId: result.data.id };
58
64
  }
59
65
  render({ projectId }) {
@@ -62,3 +68,6 @@ export default class Create extends ExecRenderBaseCommand {
62
68
  }
63
69
  }
64
70
  }
71
+ function ProjectCreationSuccess({ shortId }) {
72
+ return (_jsxs(Success, { children: ["Your new project ", _jsx(Value, { children: shortId }), " was successfully created! \uD83D\uDE80"] }));
73
+ }
@@ -8,10 +8,12 @@ export declare class Deploy extends ExecRenderBaseCommand<typeof Deploy, DeployR
8
8
  static aliases: string[];
9
9
  static flags: {
10
10
  "compose-file": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
+ "from-template": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
12
  "env-file": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
12
13
  quiet: import("@oclif/core/interfaces").BooleanFlag<boolean>;
13
14
  "stack-id": import("@oclif/core/interfaces").OptionFlag<string>;
14
15
  };
16
+ private loadStackDefinition;
15
17
  protected exec(): Promise<DeployResult>;
16
18
  protected render({ restartedServices }: DeployResult): ReactNode;
17
19
  }
@@ -3,14 +3,16 @@ import { ExecRenderBaseCommand } from "../../lib/basecommands/ExecRenderBaseComm
3
3
  import { stackFlags, withStackId } from "../../lib/resources/stack/flags.js";
4
4
  import { Flags } from "@oclif/core";
5
5
  import { makeProcessRenderer, processFlags, } from "../../rendering/process/process_flags.js";
6
- import { loadStackFromFile } from "../../lib/resources/stack/loader.js";
6
+ import { loadStackFromFile, loadStackFromStr, } from "../../lib/resources/stack/loader.js";
7
7
  import { assertStatus } from "@mittwald/api-client";
8
8
  import assertSuccess from "../../lib/apiutil/assert_success.js";
9
- import { collectEnvironment } from "../../lib/resources/stack/env.js";
9
+ import { collectEnvironment, fillMissingEnvironmentVariables, } from "../../lib/resources/stack/env.js";
10
10
  import { sanitizeStackDefinition } from "../../lib/resources/stack/sanitize.js";
11
11
  import { enrichStackDefinition } from "../../lib/resources/stack/enrich.js";
12
12
  import { Success } from "../../rendering/react/components/Success.js";
13
13
  import { Value } from "../../rendering/react/components/Value.js";
14
+ import { loadStackFromTemplate } from "../../lib/resources/stack/template-loader.js";
15
+ import { parse } from "envfile";
14
16
  export class Deploy extends ExecRenderBaseCommand {
15
17
  static description = "Deploys a docker-compose compatible file to a mittwald container stack";
16
18
  static aliases = ["stack:up"];
@@ -21,22 +23,63 @@ export class Deploy extends ExecRenderBaseCommand {
21
23
  summary: 'path to a compose file, or "-" to read from stdin',
22
24
  default: "./docker-compose.yml",
23
25
  char: "c",
26
+ exclusive: ["from-template"],
27
+ }),
28
+ "from-template": Flags.string({
29
+ summary: "deploy from a GitHub template (e.g., mittwald/n8n)",
30
+ description: `\
31
+ Fetch and deploy a stack from a GitHub template repository. Template names are automatically converted to repository names by prefixing "stack-template-" to the name part.
32
+
33
+ For example, "mittwald/n8n" resolves to the repository "mittwald/stack-template-n8n". The command fetches both docker-compose.yml and .env files from the main branch.
34
+
35
+ Environment variable precedence (from lowest to highest):
36
+ 1. Template .env file (if present in the repository)
37
+ 2. System environment variables (process.env)
38
+ 3. Local --env-file (if specified)
39
+
40
+ This flag is mutually exclusive with --compose-file.`,
41
+ exclusive: ["compose-file"],
24
42
  }),
25
43
  "env-file": Flags.file({
26
44
  summary: "alternative path to file with environment variables",
27
45
  default: "./.env",
28
46
  }),
29
47
  };
48
+ async loadStackDefinition(source, envFile, existing, renderer) {
49
+ // Build environment: start with process.env, then template .env, then local --env-file
50
+ let env = { ...process.env };
51
+ if ("template" in source) {
52
+ const hasServices = existing.services?.length ?? 0 > 0;
53
+ if (hasServices) {
54
+ throw new Error("Re-applying templates to existing stacks is currently not supported.");
55
+ }
56
+ // Load from GitHub template
57
+ const { composeYaml, envContent } = await renderer.runStep("fetching template from GitHub", () => loadStackFromTemplate(source.template));
58
+ if (envContent) {
59
+ const templateEnv = parse(envContent);
60
+ env = { ...env, ...templateEnv };
61
+ }
62
+ env = await collectEnvironment(env, envFile);
63
+ env = await fillMissingEnvironmentVariables(env, renderer);
64
+ return loadStackFromStr(composeYaml, env);
65
+ }
66
+ // Load from local file
67
+ env = await collectEnvironment(env, envFile);
68
+ return loadStackFromFile(source.composeFile, env);
69
+ }
30
70
  async exec() {
31
71
  const stackId = await withStackId(this.apiClient, Deploy, this.flags, this.args, this.config);
32
- const { "compose-file": composeFile, "env-file": envFile } = this.flags;
72
+ const { "compose-file": composeFile, "from-template": fromTemplate, "env-file": envFile, } = this.flags;
33
73
  const r = makeProcessRenderer(this.flags, "Deploying container stack");
74
+ const existingStack = await r.runStep("retrieving current stack state", async () => {
75
+ const resp = await this.apiClient.container.getStack({ stackId });
76
+ assertStatus(resp, 200);
77
+ return resp.data;
78
+ });
34
79
  const result = { restartedServices: [] };
35
- const env = await collectEnvironment(process.env, envFile);
36
- let stackDefinition = await loadStackFromFile(composeFile, env);
80
+ let stackDefinition = await this.loadStackDefinition(fromTemplate ? { template: fromTemplate } : { composeFile }, envFile, existingStack, r);
37
81
  stackDefinition = sanitizeStackDefinition(stackDefinition);
38
82
  stackDefinition = await r.runStep("getting image configurations", () => enrichStackDefinition(stackDefinition));
39
- this.debug("complete stack definition: %O", stackDefinition);
40
83
  const declaredStack = await r.runStep("deploying stack", async () => {
41
84
  const resp = await this.apiClient.container.declareStack({
42
85
  stackId,
@@ -27,6 +27,7 @@ export type FlagSetOptions = {
27
27
  normalize: NormalizeFn;
28
28
  displayName: string;
29
29
  retrieveFromContext: boolean;
30
+ retrieveFunction: (client: MittwaldAPIV2Client, ctx: Context) => Promise<string | null>;
30
31
  expectedShortIDFormat: {
31
32
  pattern: RegExp;
32
33
  display: string;
@@ -119,7 +119,7 @@ export default class FlagSetBuilder {
119
119
  buildIDGetter() {
120
120
  const idInputSanityCheck = this.buildSanityCheck();
121
121
  const idFromArgsOrFlag = this.buildIDFromArgsOrFlag();
122
- const { normalize = (_, id) => id, retrieveFromContext = true } = this.opts;
122
+ const { normalize = (_, id) => id, retrieveFromContext = true, retrieveFunction, } = this.opts;
123
123
  return async (apiClient, commandType, flags, args, cfg) => {
124
124
  const context = new Context(apiClient, cfg);
125
125
  const idInput = idFromArgsOrFlag(flags, args);
@@ -133,6 +133,12 @@ export default class FlagSetBuilder {
133
133
  return idFromContext.value;
134
134
  }
135
135
  }
136
+ if (retrieveFunction) {
137
+ const idFromFunction = await retrieveFunction(apiClient, context);
138
+ if (idFromFunction) {
139
+ return idFromFunction;
140
+ }
141
+ }
136
142
  throw makeMissingContextInputError(commandType, this.name, this.flagName);
137
143
  };
138
144
  }
@@ -7,7 +7,10 @@ import { generateIntellijConfigs } from "./config.js";
7
7
  describe("IntelliJ Config Generator", () => {
8
8
  let tempDir;
9
9
  let testData;
10
- const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_" });
10
+ const parser = new XMLParser({
11
+ ignoreAttributes: false,
12
+ attributeNamePrefix: "@_",
13
+ });
11
14
  beforeEach(() => {
12
15
  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "intellij-test-"));
13
16
  testData = {
@@ -74,7 +77,11 @@ describe("IntelliJ Config Generator", () => {
74
77
  });
75
78
  test("should add multiple SSH configs for different hosts", () => {
76
79
  generateIntellijConfigs(testData, tempDir);
77
- const differentHostData = { ...testData, host: "ssh.other.example.com", appShortId: "app456" };
80
+ const differentHostData = {
81
+ ...testData,
82
+ host: "ssh.other.example.com",
83
+ appShortId: "app456",
84
+ };
78
85
  generateIntellijConfigs(differentHostData, tempDir);
79
86
  const configPath = path.join(tempDir, ".idea", "sshConfigs.xml");
80
87
  const content = fs.readFileSync(configPath, "utf8");
@@ -5,7 +5,7 @@ import { MittwaldAPIV2, MittwaldAPIV2Client } from "@mittwald/api-client";
5
5
  import { Config } from "@oclif/core";
6
6
  type AppVersion = MittwaldAPIV2.Components.Schemas.AppAppVersion;
7
7
  type AppInstallation = MittwaldAPIV2.Components.Schemas.AppAppInstallation;
8
- type ImplicitDefaultFlag = "wait" | "wait-timeout" | "site-title";
8
+ type ImplicitDefaultFlag = "wait" | "wait-timeout" | "site-title" | "install-path" | "update-context";
9
9
  export interface AppInstallationResult {
10
10
  appInstallation: AppInstallation;
11
11
  appVersion: AppVersion;
@@ -7,6 +7,7 @@ import { triggerAppInstallation } from "./install.js";
7
7
  import { waitUntilAppStateHasNormalized } from "./wait.js";
8
8
  import { Success } from "../../../rendering/react/components/Success.js";
9
9
  import AppUsageHints from "../../../rendering/react/components/AppInstallation/AppUsageHints.js";
10
+ import Context from "../../context/Context.js";
10
11
  export class AppInstaller {
11
12
  appId;
12
13
  appName;
@@ -25,7 +26,14 @@ export class AppInstaller {
25
26
  this.description = AppInstaller.makeDescription(appName);
26
27
  }
27
28
  get flags() {
28
- const flags = provideSupportedFlags([...this.appSupportedFlags, "wait", "wait-timeout", "site-title"], this.appName);
29
+ const flags = provideSupportedFlags([
30
+ ...this.appSupportedFlags,
31
+ "wait",
32
+ "wait-timeout",
33
+ "site-title",
34
+ "install-path",
35
+ "update-context",
36
+ ], this.appName);
29
37
  if (this.mutateFlags) {
30
38
  this.mutateFlags(flags);
31
39
  }
@@ -45,6 +53,11 @@ export class AppInstaller {
45
53
  else {
46
54
  successText = `Your ${this.appName} installation has started. Have fun when it's ready! 🎉`;
47
55
  }
56
+ if (flags["update-context"]) {
57
+ const context = new Context(apiClient, config);
58
+ await context.setProjectId(appInstallation.projectId);
59
+ await context.setAppInstallationId(appInstallation.id);
60
+ }
48
61
  await process.complete(_jsx(Success, { children: successText }));
49
62
  return {
50
63
  appInstallation,
@@ -1,7 +1,7 @@
1
1
  import { MittwaldAPIV2Client } from "@mittwald/api-client";
2
2
  import { ProcessRenderer } from "../../../rendering/process/process.js";
3
3
  import { ProcessFlags } from "../../../rendering/process/process_flags.js";
4
- import { FlagInput, OptionFlag, OutputFlags } from "@oclif/core/interfaces";
4
+ import { BooleanFlag, FlagInput, OptionFlag, OutputFlags } from "@oclif/core/interfaces";
5
5
  import { waitFlags } from "../../wait.js";
6
6
  export declare const appInstallationFlags: import("../../context/FlagSetBuilder.js").ContextFlags<"installation">, appInstallationArgs: import("../../context/FlagSetBuilder.js").ContextArgs<"installation">, withAppInstallationId: (apiClient: MittwaldAPIV2Client, command: "flag" | "arg" | import("../../context/FlagSetBuilder.js").CommandType<"installation">, flags: {
7
7
  [k: string]: unknown;
@@ -22,10 +22,12 @@ type AvailableFlags = typeof waitFlags & {
22
22
  "shop-lang": OptionFlag<string | undefined>;
23
23
  "shop-currency": OptionFlag<string | undefined>;
24
24
  "install-mode": OptionFlag<string>;
25
+ "install-path": OptionFlag<string>;
25
26
  "document-root": OptionFlag<string>;
26
27
  "opensearch-host": OptionFlag<string>;
27
28
  "opensearch-port": OptionFlag<string>;
28
29
  entrypoint: OptionFlag<string | undefined>;
30
+ "update-context": BooleanFlag<boolean>;
29
31
  };
30
32
  export type RelevantFlags<TFlags extends readonly AvailableFlagName[]> = ProcessFlags & Pick<AvailableFlags, TFlags[number]>;
31
33
  export type RelevantFlagInput<TFlags extends readonly AvailableFlagName[]> = FlagInput<RelevantFlags<TFlags>>;
@@ -113,6 +113,12 @@ function buildFlagsWithDescription(appName) {
113
113
  options: ["composer", "symlink"],
114
114
  default: "composer",
115
115
  }),
116
+ "install-path": Flags.string({
117
+ required: false,
118
+ summary: `the installation path of your ${appName} application`,
119
+ description: "This is the path where your application will be installed. If omitted, this will default to an automatically-generated path.",
120
+ default: undefined,
121
+ }),
116
122
  "document-root": Flags.string({
117
123
  required: true,
118
124
  summary: `the document root from which your ${appName} will be served (relative to the installation path)`,
@@ -137,6 +143,12 @@ function buildFlagsWithDescription(appName) {
137
143
  required: false,
138
144
  default: undefined,
139
145
  }),
146
+ "update-context": Flags.boolean({
147
+ description: "update the CLI context to use the newly created app installation",
148
+ char: "c",
149
+ required: false,
150
+ default: false,
151
+ }),
140
152
  ...waitFlags,
141
153
  };
142
154
  }
@@ -5,6 +5,7 @@ type AppAppVersion = MittwaldAPIV2.Components.Schemas.AppAppVersion;
5
5
  export declare function triggerAppInstallation(apiClient: MittwaldAPIV2Client, process: ProcessRenderer, projectId: string, flags: {
6
6
  "site-title": string;
7
7
  "document-root"?: string;
8
+ "install-path"?: string;
8
9
  } & {
9
10
  [k: string]: unknown;
10
11
  }, appVersion: AppAppVersion): Promise<AppAppInstallation>;
@@ -7,6 +7,7 @@ export async function triggerAppInstallation(apiClient, process, projectId, flag
7
7
  appVersionId: appVersion.id,
8
8
  description: flags["site-title"],
9
9
  updatePolicy: "none",
10
+ installationPath: flags["install-path"],
10
11
  userInputs: Object.keys(flags).map((k) => ({
11
12
  name: k.replace("-", "_"),
12
13
  value: flags[k],
@@ -1,11 +1,17 @@
1
1
  import { MittwaldAPIV2, MittwaldAPIV2Client } from "@mittwald/api-client";
2
2
  import { ProcessRenderer } from "../../../rendering/process/process.js";
3
3
  type AppVersion = MittwaldAPIV2.Components.Schemas.AppAppVersion;
4
+ type ObjectWithVersions = {
5
+ internalVersion: string;
6
+ externalVersion: string;
7
+ };
4
8
  export declare function normalizeToAppVersionUuid(apiClient: MittwaldAPIV2Client, version: string, process: ProcessRenderer, appUuid: string): Promise<MittwaldAPIV2.Components.Schemas.AppAppVersion>;
5
9
  export declare function getLatestAvailableAppVersionForApp(apiClient: MittwaldAPIV2Client, appId: string): Promise<AppVersion | undefined>;
6
10
  export declare function getAllUpgradeCandidatesFromAppInstallationId(apiClient: MittwaldAPIV2Client, appInstallationId: string): Promise<AppVersion[]>;
7
11
  export declare function getLatestAvailableTargetAppVersionForAppVersionUpgradeCandidates(apiClient: MittwaldAPIV2Client, appId: string, baseAppVersionId: string): Promise<AppVersion | undefined>;
8
12
  export declare function getAvailableTargetAppVersionFromExternalVersion(apiClient: MittwaldAPIV2Client, appId: string, baseAppVersionId: string, targetExternalVersion: string): Promise<AppVersion | undefined>;
9
13
  export declare function getAppVersionUuidFromAppVersion(apiClient: MittwaldAPIV2Client, appId: string, appVersion: string | undefined): Promise<AppVersion | undefined>;
10
- export declare function sortArrayByExternalVersion(versions: AppVersion[]): AppVersion[];
14
+ export declare function sortArrayByExternalVersion<T extends ObjectWithVersions>(versions: T[]): T[];
15
+ export declare function sortArrayByInternalVersion<T extends ObjectWithVersions>(versions: T[]): T[];
16
+ export declare function compareVersionsBy<T extends ObjectWithVersions>(field: "internal" | "external"): (a: T, b: T) => -1 | 0 | 1;
11
17
  export {};
@@ -1,5 +1,5 @@
1
1
  import { assertStatus } from "@mittwald/api-client-commons";
2
- import { gt } from "semver";
2
+ import { coerce, gt } from "semver";
3
3
  import { getAppInstallationFromUuid, getAppNameFromUuid } from "./uuid.js";
4
4
  import { compare } from "semver";
5
5
  export async function normalizeToAppVersionUuid(apiClient, version, process, appUuid) {
@@ -78,5 +78,38 @@ export async function getAppVersionUuidFromAppVersion(apiClient, appId, appVersi
78
78
  item.externalVersion === appVersion);
79
79
  }
80
80
  export function sortArrayByExternalVersion(versions) {
81
- return versions.sort((a, b) => compare(b.externalVersion, a.externalVersion));
81
+ return versions.sort(compareVersionsBy("external"));
82
+ }
83
+ export function sortArrayByInternalVersion(versions) {
84
+ return versions.sort(compareVersionsBy("internal"));
85
+ }
86
+ export function compareVersionsBy(field) {
87
+ const fullField = `${field}Version`;
88
+ return (a, b) => {
89
+ const aCoerced = coerce(a[fullField]);
90
+ const bCoerced = coerce(b[fullField]);
91
+ if (!aCoerced || !bCoerced) {
92
+ return naiveVersionCompare(a.internalVersion, b.internalVersion);
93
+ }
94
+ return compare(aCoerced, bCoerced);
95
+ };
96
+ }
97
+ /**
98
+ * A naive version comparison function that compares version strings in the
99
+ * format "x.y.z". This function does not handle pre-release or build metadata.
100
+ */
101
+ function naiveVersionCompare(a, b) {
102
+ const aParts = a.split(".").map((part) => parseInt(part, 10));
103
+ const bParts = b.split(".").map((part) => parseInt(part, 10));
104
+ for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
105
+ const aPart = aParts[i] || 0;
106
+ const bPart = bParts[i] || 0;
107
+ if (aPart > bPart) {
108
+ return 1;
109
+ }
110
+ if (aPart < bPart) {
111
+ return -1;
112
+ }
113
+ }
114
+ return 0;
82
115
  }
@@ -40,7 +40,13 @@ export async function parseEnvironmentVariablesFromFile(envFiles = []) {
40
40
  * @returns An object containing environment variable key-value pairs
41
41
  */
42
42
  export function parseEnvironmentVariablesFromEnvFlags(envFlags = []) {
43
- const splitIntoKeyAndValue = (e) => e.split("=", 2);
43
+ const splitIntoKeyAndValue = (e) => {
44
+ const index = e.indexOf("=");
45
+ if (index < 0) {
46
+ throw new Error(`Invalid environment variable format: ${e}`);
47
+ }
48
+ return [e.slice(0, index), e.slice(index + 1)];
49
+ };
44
50
  return Object.fromEntries(envFlags.map(splitIntoKeyAndValue));
45
51
  }
46
52
  /**
@@ -0,0 +1,25 @@
1
+ import { describe, expect, test } from "@jest/globals";
2
+ import { parseEnvironmentVariablesFromEnvFlags } from "./containerconfig.js";
3
+ describe("Containerconfig handling", () => {
4
+ describe("Config parsing", () => {
5
+ test("call parser with simple flags", () => {
6
+ const args = ["foo=bar", "ham=eggs"];
7
+ const res = parseEnvironmentVariablesFromEnvFlags(args);
8
+ expect(res).toStrictEqual({
9
+ foo: "bar",
10
+ ham: "eggs",
11
+ });
12
+ });
13
+ test("call parser with flag containing another '='", () => {
14
+ const args = ["extra_args=first=1 second=2 third=littlebitoflove"];
15
+ const res = parseEnvironmentVariablesFromEnvFlags(args);
16
+ expect(res).toStrictEqual({
17
+ extra_args: "first=1 second=2 third=littlebitoflove",
18
+ });
19
+ });
20
+ test("throw error for invalid flag format", () => {
21
+ const args = ["invalidFlagWithoutEqualsSign"];
22
+ expect(() => parseEnvironmentVariablesFromEnvFlags(args)).toThrow("Invalid environment variable format: invalidFlagWithoutEqualsSign");
23
+ });
24
+ });
25
+ });
@@ -3,7 +3,7 @@ import { assertStatus } from "@mittwald/api-client-commons";
3
3
  export const mysqlConnectionFlags = {
4
4
  "mysql-password": Flags.string({
5
5
  char: "p",
6
- summary: "the password to use for the MySQL user (env: MYSQL_PWD)",
6
+ summary: "the password to use for the MySQL user",
7
7
  description: `\
8
8
  The password to use for the MySQL user. If not provided, the environment variable MYSQL_PWD will be used. If that is not set either, the command will interactively ask for the password.
9
9
 
@@ -1 +1,11 @@
1
+ import { ProcessRenderer } from "../../../rendering/process/process.js";
1
2
  export declare function collectEnvironment(base: NodeJS.ProcessEnv, envFile: string): Promise<Record<string, string | undefined>>;
3
+ /**
4
+ * Fills in missing environment variables in the base environment.
5
+ *
6
+ * This is done by checking for special placeholder values:
7
+ *
8
+ * - `__PROMPT__`: prompts the user to input a value.
9
+ * - `__GENERATE__`: generates a random base64-encoded string.
10
+ */
11
+ export declare function fillMissingEnvironmentVariables(base: NodeJS.ProcessEnv, renderer: ProcessRenderer): Promise<Record<string, string | undefined>>;
@@ -1,6 +1,7 @@
1
1
  import * as fs from "fs/promises";
2
2
  import { parse } from "envfile";
3
3
  import { pathExists } from "../../util/fs/pathExists.js";
4
+ import { getRandomValues } from "node:crypto";
4
5
  export async function collectEnvironment(base, envFile) {
5
6
  if (!(await pathExists(envFile))) {
6
7
  return base;
@@ -9,3 +10,24 @@ export async function collectEnvironment(base, envFile) {
9
10
  const parsed = parse(defs);
10
11
  return { ...base, ...parsed };
11
12
  }
13
+ /**
14
+ * Fills in missing environment variables in the base environment.
15
+ *
16
+ * This is done by checking for special placeholder values:
17
+ *
18
+ * - `__PROMPT__`: prompts the user to input a value.
19
+ * - `__GENERATE__`: generates a random base64-encoded string.
20
+ */
21
+ export async function fillMissingEnvironmentVariables(base, renderer) {
22
+ const output = { ...base };
23
+ for (const [key, value] of Object.entries(base)) {
24
+ if (value === "__PROMPT__") {
25
+ output[key] = await renderer.addInput(`enter value for environment variable ${key}`, false);
26
+ }
27
+ if (value === "__GENERATE__") {
28
+ renderer.addInfo(`generating random value for environment variable ${key}`);
29
+ output[key] = Buffer.from(getRandomValues(new Uint8Array(32))).toString("base64");
30
+ }
31
+ }
32
+ return output;
33
+ }
@@ -1,2 +1,17 @@
1
1
  import FlagSetBuilder from "../../context/FlagSetBuilder.js";
2
- export const { flags: stackFlags, args: stackArgs, withId: withStackId, } = new FlagSetBuilder("stack", "s").build();
2
+ import { assertStatus } from "@mittwald/api-client";
3
+ export const { flags: stackFlags, args: stackArgs, withId: withStackId, } = new FlagSetBuilder("stack", "s", {
4
+ retrieveFunction: async (client, context) => {
5
+ const projectContext = await context.projectId();
6
+ if (!projectContext) {
7
+ return null;
8
+ }
9
+ const projectId = projectContext.value;
10
+ const stacks = await client.container.listStacks({ projectId });
11
+ assertStatus(stacks, 200);
12
+ if (stacks.data.length === 1) {
13
+ return stacks.data[0].id;
14
+ }
15
+ return null;
16
+ },
17
+ }).build();
@@ -0,0 +1,18 @@
1
+ export interface TemplateContent {
2
+ composeYaml: string;
3
+ envContent: string | null;
4
+ }
5
+ export declare class TemplateFileNotFoundError extends Error {
6
+ constructor(url: string);
7
+ }
8
+ export declare class GitHubRateLimitError extends Error {
9
+ constructor();
10
+ }
11
+ export declare class TemplateNetworkError extends Error {
12
+ constructor(message: string);
13
+ }
14
+ export declare function validateTemplateName(name: string): void;
15
+ export declare function templateNameToRepoName(templateName: string): string;
16
+ export declare function buildGitHubRawUrl(templateName: string, filename: string): string;
17
+ export declare function fetchTemplateFile(url: string): Promise<string>;
18
+ export declare function loadStackFromTemplate(templateName: string): Promise<TemplateContent>;
@@ -0,0 +1,94 @@
1
+ import axios from "axios";
2
+ export class TemplateFileNotFoundError extends Error {
3
+ constructor(url) {
4
+ super(`File not found at ${url}`);
5
+ this.name = "TemplateFileNotFoundError";
6
+ }
7
+ }
8
+ export class GitHubRateLimitError extends Error {
9
+ constructor() {
10
+ super("GitHub API rate limit exceeded. Please try again later.");
11
+ this.name = "GitHubRateLimitError";
12
+ }
13
+ }
14
+ export class TemplateNetworkError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = "TemplateNetworkError";
18
+ }
19
+ }
20
+ export function validateTemplateName(name) {
21
+ const validFormat = /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/;
22
+ if (!validFormat.test(name)) {
23
+ throw new Error(`Invalid template name format: '${name}'. Expected format: 'owner/name' (e.g., 'mittwald/n8n')`);
24
+ }
25
+ }
26
+ export function templateNameToRepoName(templateName) {
27
+ const [owner, name] = templateName.split("/");
28
+ return `${owner}/stack-template-${name}`;
29
+ }
30
+ export function buildGitHubRawUrl(templateName, filename) {
31
+ const repoName = templateNameToRepoName(templateName);
32
+ return `https://raw.githubusercontent.com/${repoName}/main/${filename}`;
33
+ }
34
+ function convertAxiosError(error, url) {
35
+ if (!axios.isAxiosError(error)) {
36
+ return error instanceof Error ? error : new Error(String(error));
37
+ }
38
+ const axiosError = error;
39
+ if (axiosError.response?.status === 404) {
40
+ return new TemplateFileNotFoundError(url);
41
+ }
42
+ if (axiosError.response?.status === 403) {
43
+ return new GitHubRateLimitError();
44
+ }
45
+ if (axiosError.code === "ETIMEDOUT") {
46
+ return new TemplateNetworkError("Request timed out while fetching template from GitHub");
47
+ }
48
+ if (axiosError.code === "ECONNREFUSED" || axiosError.code === "ENOTFOUND") {
49
+ return new TemplateNetworkError("Network error: Unable to connect to GitHub. Please check your internet connection.");
50
+ }
51
+ return error;
52
+ }
53
+ export async function fetchTemplateFile(url) {
54
+ try {
55
+ const response = await axios.get(url, {
56
+ responseType: "text",
57
+ timeout: 10000,
58
+ });
59
+ return response.data;
60
+ }
61
+ catch (error) {
62
+ throw convertAxiosError(error, url);
63
+ }
64
+ }
65
+ export async function loadStackFromTemplate(templateName) {
66
+ validateTemplateName(templateName);
67
+ const composeUrl = buildGitHubRawUrl(templateName, "docker-compose.yml");
68
+ const envUrl = buildGitHubRawUrl(templateName, ".env");
69
+ let composeYaml;
70
+ try {
71
+ composeYaml = await fetchTemplateFile(composeUrl);
72
+ }
73
+ catch (error) {
74
+ if (error instanceof TemplateFileNotFoundError) {
75
+ const repoName = templateNameToRepoName(templateName);
76
+ throw new Error(`Template '${templateName}' not found. Repository '${repoName}' does not exist or does not contain a docker-compose.yml file.`);
77
+ }
78
+ throw error;
79
+ }
80
+ let envContent = null;
81
+ try {
82
+ envContent = await fetchTemplateFile(envUrl);
83
+ }
84
+ catch (error) {
85
+ if (error instanceof TemplateFileNotFoundError) {
86
+ // .env file is optional, so we ignore 404 errors
87
+ envContent = null;
88
+ }
89
+ else {
90
+ throw error;
91
+ }
92
+ }
93
+ return { composeYaml, envContent };
94
+ }
@@ -0,0 +1 @@
1
+ export {};