@mittwald/cli 1.13.1 → 1.13.3

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 (29) hide show
  1. package/dist/commands/container/update.js +1 -1
  2. package/dist/commands/cronjob/create.d.ts +1 -0
  3. package/dist/commands/cronjob/create.js +3 -1
  4. package/dist/commands/cronjob/list.js +4 -0
  5. package/dist/commands/cronjob/update.d.ts +1 -0
  6. package/dist/commands/cronjob/update.js +3 -1
  7. package/dist/commands/org/invite/list-own.d.ts +1 -0
  8. package/dist/commands/org/membership/list-own.d.ts +1 -0
  9. package/dist/commands/stack/deploy.js +2 -2
  10. package/dist/lib/resources/container/containerconfig.d.ts +0 -7
  11. package/dist/lib/resources/container/containerconfig.js +3 -19
  12. package/dist/lib/resources/cronjob/flags.d.ts +4 -0
  13. package/dist/lib/resources/cronjob/flags.js +4 -0
  14. package/dist/lib/resources/stack/enrich.d.ts +2 -1
  15. package/dist/lib/resources/stack/enrich.js +6 -2
  16. package/dist/lib/resources/stack/enrich.test.js +184 -0
  17. package/dist/lib/resources/stack/env.js +2 -2
  18. package/dist/lib/resources/stack/env.test.js +24 -0
  19. package/dist/lib/resources/stack/loader.d.ts +3 -5
  20. package/dist/lib/resources/stack/sanitize.d.ts +2 -4
  21. package/dist/lib/resources/stack/types.d.ts +22 -0
  22. package/dist/lib/util/parser.d.ts +15 -0
  23. package/dist/lib/util/parser.js +41 -0
  24. package/dist/lib/util/parser.test.d.ts +1 -0
  25. package/dist/lib/util/parser.test.js +103 -0
  26. package/dist/rendering/react/components/CronJob/CronJobDetails.js +1 -0
  27. package/package.json +4 -5
  28. package/dist/lib/resources/container/containerconfig.test.js +0 -25
  29. /package/dist/lib/resources/{container/containerconfig.test.d.ts → stack/env.test.d.ts} +0 -0
@@ -65,7 +65,7 @@ export class Update extends ExecRenderBaseCommand {
65
65
  }),
66
66
  volume: Flags.string({
67
67
  summary: "update volume mounts for the container",
68
- description: "This flag can be used to add volume mounts to the container. It can be used multiple times to mount multiple volumes." +
68
+ description: "This flag can be used to replace volume mounts of the container. It can be used multiple times to mount multiple volumes." +
69
69
  "" +
70
70
  "Needs to be in the format <host-path>:<container-path>. " +
71
71
  "" +
@@ -15,6 +15,7 @@ export declare class Create extends ExecRenderBaseCommand<typeof Create, Result>
15
15
  interpreter: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
16
16
  disable: import("@oclif/core/interfaces").BooleanFlag<boolean>;
17
17
  timeout: import("@oclif/core/interfaces").OptionFlag<Duration, import("@oclif/core/interfaces").CustomOptions>;
18
+ timezone: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
18
19
  quiet: import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
20
  "installation-id": import("@oclif/core/interfaces").OptionFlag<string>;
20
21
  };
@@ -41,11 +41,12 @@ export class Create extends ExecRenderBaseCommand {
41
41
  default: Duration.fromString("1h"),
42
42
  required: false,
43
43
  }),
44
+ timezone: cronjobFlagDefinitions.timezone(),
44
45
  };
45
46
  async exec() {
46
47
  const p = makeProcessRenderer(this.flags, "Creating a new cron job");
47
48
  const appInstallationId = await this.withAppInstallationId(Create);
48
- const { description, interval, disable, email, url, interpreter, command, timeout, } = this.flags;
49
+ const { description, interval, disable, email, url, interpreter, command, timeout, timezone, } = this.flags;
49
50
  const { projectId } = await p.runStep("fetching project", async () => {
50
51
  const r = await this.apiClient.app.getAppinstallation({
51
52
  appInstallationId,
@@ -67,6 +68,7 @@ export class Create extends ExecRenderBaseCommand {
67
68
  email,
68
69
  destination: buildCronjobDestination(url, command, interpreter),
69
70
  timeout: timeout.seconds,
71
+ timeZone: timezone,
70
72
  },
71
73
  });
72
74
  assertStatus(r, 201);
@@ -21,6 +21,10 @@ export class List extends ListBaseCommand {
21
21
  shortId,
22
22
  interval: {},
23
23
  description: {},
24
+ timezone: {
25
+ header: "Timezone",
26
+ get: (r) => r.timeZone || "UTC",
27
+ },
24
28
  lastExecution: {
25
29
  header: "Last execution",
26
30
  get: (r) => {
@@ -17,6 +17,7 @@ export default class Update extends ExecRenderBaseCommand<typeof Update, UpdateR
17
17
  enable: import("@oclif/core/interfaces").BooleanFlag<boolean>;
18
18
  disable: import("@oclif/core/interfaces").BooleanFlag<boolean>;
19
19
  timeout: import("@oclif/core/interfaces").OptionFlag<Duration | undefined, import("@oclif/core/interfaces").CustomOptions>;
20
+ timezone: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
20
21
  quiet: import("@oclif/core/interfaces").BooleanFlag<boolean>;
21
22
  };
22
23
  protected exec(): Promise<void>;
@@ -47,6 +47,7 @@ export default class Update extends ExecRenderBaseCommand {
47
47
  "If an email address is defined, an error message will be sent.",
48
48
  required: false,
49
49
  }),
50
+ timezone: cronjobFlagDefinitions.timezone(),
50
51
  };
51
52
  async exec() {
52
53
  const process = makeProcessRenderer(this.flags, "Updating cron job");
@@ -55,7 +56,7 @@ export default class Update extends ExecRenderBaseCommand {
55
56
  cronjobId,
56
57
  });
57
58
  assertSuccess(currentCronjob);
58
- const { description, interval, email, timeout, url, interpreter, command, enable, disable, } = this.flags;
59
+ const { description, interval, email, timeout, url, interpreter, command, enable, disable, timezone, } = this.flags;
59
60
  if (Object.keys(this.flags).length == 0) {
60
61
  await process.complete(_jsx(Success, { children: "Nothing to change. Have a good day!" }));
61
62
  return;
@@ -72,6 +73,7 @@ export default class Update extends ExecRenderBaseCommand {
72
73
  timeout: timeout?.seconds,
73
74
  email,
74
75
  interval,
76
+ timeZone: timezone,
75
77
  },
76
78
  });
77
79
  assertSuccess(response);
@@ -39,6 +39,7 @@ export declare class List extends ListBaseCommand<typeof List, ResponseItem, Res
39
39
  isAllowedToPlaceOrders?: boolean | undefined;
40
40
  isBanned?: boolean | undefined;
41
41
  isInDefaultOfPayment?: boolean | undefined;
42
+ isMailAddressInvalid?: boolean | undefined;
42
43
  levelOfUndeliverableDunningNotice?: "first" | "second" | undefined;
43
44
  memberCount: number;
44
45
  name: string;
@@ -39,6 +39,7 @@ export declare class ListOwn extends ListBaseCommand<typeof ListOwn, ResponseIte
39
39
  isAllowedToPlaceOrders?: boolean | undefined;
40
40
  isBanned?: boolean | undefined;
41
41
  isInDefaultOfPayment?: boolean | undefined;
42
+ isMailAddressInvalid?: boolean | undefined;
42
43
  levelOfUndeliverableDunningNotice?: "first" | "second" | undefined;
43
44
  memberCount: number;
44
45
  name: string;
@@ -12,7 +12,7 @@ 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
14
  import { loadStackFromTemplate } from "../../lib/resources/stack/template-loader.js";
15
- import { parse } from "envfile";
15
+ import { parseEnvironmentVariablesFromStr } from "../../lib/util/parser.js";
16
16
  export class Deploy extends ExecRenderBaseCommand {
17
17
  static description = "Deploys a docker-compose compatible file to a mittwald container stack";
18
18
  static aliases = ["stack:up"];
@@ -56,7 +56,7 @@ This flag is mutually exclusive with --compose-file.`,
56
56
  // Load from GitHub template
57
57
  const { composeYaml, envContent } = await renderer.runStep("fetching template from GitHub", () => loadStackFromTemplate(source.template));
58
58
  if (envContent) {
59
- const templateEnv = parse(envContent);
59
+ const templateEnv = parseEnvironmentVariablesFromStr(envContent);
60
60
  env = { ...env, ...templateEnv };
61
61
  }
62
62
  env = await collectEnvironment(env, envFile);
@@ -15,13 +15,6 @@ export declare function parseEnvironmentVariables(envFlags?: string[], envFiles?
15
15
  * @returns An object containing environment variable key-value pairs
16
16
  */
17
17
  export declare function parseEnvironmentVariablesFromFile(envFiles?: string[]): Promise<Record<string, string>>;
18
- /**
19
- * Parses environment variables from command line flags
20
- *
21
- * @param envFlags Array of environment variable strings in KEY=VALUE format
22
- * @returns An object containing environment variable key-value pairs
23
- */
24
- export declare function parseEnvironmentVariablesFromEnvFlags(envFlags?: string[]): Record<string, string>;
25
18
  /**
26
19
  * Determines which ports to expose based on image metadata
27
20
  *
@@ -1,7 +1,7 @@
1
1
  import { assertStatus, } from "@mittwald/api-client";
2
2
  import * as fs from "fs/promises";
3
- import { parse } from "envfile";
4
3
  import { pathExists } from "../../util/fs/pathExists.js";
4
+ import { parseEnvironmentVariablesFromArray, parseEnvironmentVariablesFromStr, } from "../../util/parser.js";
5
5
  /**
6
6
  * Parses environment variables from command line flags and env files
7
7
  *
@@ -11,7 +11,7 @@ import { pathExists } from "../../util/fs/pathExists.js";
11
11
  */
12
12
  export async function parseEnvironmentVariables(envFlags = [], envFiles = []) {
13
13
  return {
14
- ...parseEnvironmentVariablesFromEnvFlags(envFlags),
14
+ ...parseEnvironmentVariablesFromArray(envFlags),
15
15
  ...(await parseEnvironmentVariablesFromFile(envFiles)),
16
16
  };
17
17
  }
@@ -28,27 +28,11 @@ export async function parseEnvironmentVariablesFromFile(envFiles = []) {
28
28
  throw new Error(`Env file not found: ${envFile}`);
29
29
  }
30
30
  const fileContent = await fs.readFile(envFile, { encoding: "utf-8" });
31
- const parsed = parse(fileContent);
31
+ const parsed = parseEnvironmentVariablesFromStr(fileContent);
32
32
  Object.assign(result, parsed);
33
33
  }
34
34
  return result;
35
35
  }
36
- /**
37
- * Parses environment variables from command line flags
38
- *
39
- * @param envFlags Array of environment variable strings in KEY=VALUE format
40
- * @returns An object containing environment variable key-value pairs
41
- */
42
- export function parseEnvironmentVariablesFromEnvFlags(envFlags = []) {
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
- };
50
- return Object.fromEntries(envFlags.map(splitIntoKeyAndValue));
51
- }
52
36
  /**
53
37
  * Determines which ports to expose based on image metadata
54
38
  *
@@ -23,4 +23,8 @@ export declare const cronjobFlagDefinitions: {
23
23
  multiple: false;
24
24
  requiredOrDefaulted: false;
25
25
  }>;
26
+ timezone: import("@oclif/core/interfaces").FlagDefinition<string, import("@oclif/core/interfaces").CustomOptions, {
27
+ multiple: false;
28
+ requiredOrDefaulted: false;
29
+ }>;
26
30
  };
@@ -25,4 +25,8 @@ export const cronjobFlagDefinitions = {
25
25
  summary: "Set the interpreter to be used for execution.",
26
26
  description: "Must be either 'bash' or 'php'. Define the interpreter to be used to execute the previously defined command. The interpreter should match the corresponding command or script.",
27
27
  }),
28
+ timezone: Flags.custom({
29
+ summary: "Set the timezone for the cron job.",
30
+ description: "Specify the timezone in which the cron job should be executed. Use standard timezone identifiers (e.g., 'Europe/Berlin', 'America/New_York'). Defaults to UTC if not specified.",
31
+ }),
28
32
  };
@@ -1,4 +1,5 @@
1
1
  import { type MittwaldAPIV2 } from "@mittwald/api-client";
2
+ import { RawStackInput } from "./types.js";
2
3
  type StackRequest = MittwaldAPIV2.Paths.V2StacksStackId.Put.Parameters.RequestBody;
3
- export declare function enrichStackDefinition(input: StackRequest): Promise<StackRequest>;
4
+ export declare function enrichStackDefinition(input: RawStackInput): Promise<StackRequest>;
4
5
  export {};
@@ -1,9 +1,13 @@
1
1
  import { readFile } from "fs/promises";
2
- import { parse } from "envfile";
2
+ import { parseEnvironmentVariablesFromArray, parseEnvironmentVariablesFromStr, } from "../../util/parser.js";
3
3
  export async function enrichStackDefinition(input) {
4
4
  const enriched = structuredClone(input);
5
5
  for (const serviceName of Object.keys(input.services ?? {})) {
6
6
  let service = enriched.services[serviceName];
7
+ // resolve array into object before enriching
8
+ if (service.envs && Array.isArray(service.envs)) {
9
+ service.envs = parseEnvironmentVariablesFromArray(service.envs);
10
+ }
7
11
  service = await setEnvironmentFromEnvFile(service);
8
12
  enriched.services[serviceName] = service;
9
13
  }
@@ -20,7 +24,7 @@ async function setEnvironmentFromEnvFile(service) {
20
24
  let envVars = {};
21
25
  for (const envFile of envFiles) {
22
26
  const envFileContent = await readFile(envFile, "utf-8");
23
- const fileEnvVars = parse(envFileContent);
27
+ const fileEnvVars = parseEnvironmentVariablesFromStr(envFileContent);
24
28
  envVars = { ...envVars, ...fileEnvVars };
25
29
  }
26
30
  delete enriched.env_file;
@@ -4,10 +4,36 @@ jest.unstable_mockModule("fs/promises", () => ({
4
4
  readFile: mockReadFile,
5
5
  }));
6
6
  const { enrichStackDefinition } = await import("./enrich.js");
7
+ const { loadStackFromStr } = await import("./loader.js");
8
+ const { sanitizeStackDefinition } = await import("./sanitize.js");
7
9
  describe("enrichStackDefinition", () => {
8
10
  beforeEach(() => {
9
11
  jest.clearAllMocks();
10
12
  });
13
+ it("should resolve environment lists to objects", async () => {
14
+ const input = {
15
+ services: {
16
+ webapp: {
17
+ image: "postgres:latest",
18
+ envs: ["something=little", 'bit="of love"'],
19
+ },
20
+ },
21
+ };
22
+ const expected = {
23
+ services: {
24
+ webapp: {
25
+ image: "postgres:latest",
26
+ envs: {
27
+ something: "little",
28
+ bit: "of love",
29
+ },
30
+ },
31
+ },
32
+ };
33
+ const result = await enrichStackDefinition(input);
34
+ expect(result).toEqual(expected);
35
+ expect(mockReadFile).not.toHaveBeenCalled();
36
+ });
11
37
  it("should handle service without env_file", async () => {
12
38
  const input = {
13
39
  services: {
@@ -165,3 +191,161 @@ describe("enrichStackDefinition", () => {
165
191
  });
166
192
  });
167
193
  });
194
+ describe("Integration Test: Docker Compose to Mittwald API Request transformation", () => {
195
+ beforeEach(() => {
196
+ jest.clearAllMocks();
197
+ });
198
+ it("should transform Docker Compose file, environment variables, and CLI args into a valid API request", async () => {
199
+ // Sample Docker Compose file content
200
+ const dockerComposeFile = `
201
+ version: '3'
202
+ services:
203
+ nginx:
204
+ image: 'nginx:latest'
205
+ ports:
206
+ - "80:80"
207
+ environment:
208
+ APP_ENV: "development"
209
+ API_URL: "\${CUSTOM_API_URL:-https://default.api}"
210
+ env_file:
211
+ - ".env"
212
+ - ".env.local"
213
+ `;
214
+ // Mocking environment variables (process.env)
215
+ const cliEnv = {
216
+ CUSTOM_API_URL: "https://cli.api",
217
+ };
218
+ // Mocking .env files using fs/promises
219
+ mockReadFile
220
+ .mockResolvedValueOnce("DB_HOST=db.local\nDB_PORT=5432") // Content of .env
221
+ .mockResolvedValueOnce("DB_HOST=db.override.local\nDB_USER=user123"); // Content of .env.local
222
+ // Step 1: Load and parse Docker Compose file
223
+ const parsedStack = await loadStackFromStr(dockerComposeFile, cliEnv);
224
+ // Step 2: Sanitize stack definition ( compat layer )
225
+ const sanitizedStack = sanitizeStackDefinition(parsedStack);
226
+ // Step 3: Enrich stack definition (process environment variables)
227
+ const enrichedStack = await enrichStackDefinition(sanitizedStack);
228
+ // Expected API request format after the transformation
229
+ const expectedApiRequest = {
230
+ services: {
231
+ nginx: {
232
+ image: "nginx:latest",
233
+ ports: ["80:80"],
234
+ envs: {
235
+ APP_ENV: "development", // From compose file
236
+ API_URL: "https://cli.api", // Default value overridden by CLI env
237
+ DB_HOST: "db.override.local", // Overridden by .env.local
238
+ DB_PORT: "5432", // Value from first .env file
239
+ DB_USER: "user123", // Value from .env.local
240
+ },
241
+ },
242
+ },
243
+ };
244
+ // Assertions
245
+ expect(enrichedStack).toMatchObject(expectedApiRequest);
246
+ // Type Assertions
247
+ const typeCheck = enrichedStack;
248
+ expect(typeCheck).toBeTruthy();
249
+ // Ensure env files are read in order
250
+ expect(mockReadFile).toHaveBeenCalledWith(".env", "utf-8");
251
+ expect(mockReadFile).toHaveBeenCalledWith(".env.local", "utf-8");
252
+ });
253
+ it("should transform Docker Compose file with list environment, environment variables, and CLI args into a valid API request", async () => {
254
+ // Sample Docker Compose file content
255
+ const dockerComposeFile = `
256
+ version: '3'
257
+ services:
258
+ nginx:
259
+ image: 'nginx:latest'
260
+ ports:
261
+ - "80:80"
262
+ environment:
263
+ - APP_ENV=development
264
+ - API_URL=\${CUSTOM_API_URL:-https://default.api}
265
+ env_file:
266
+ - ".env"
267
+ - ".env.local"
268
+ `;
269
+ // Mocking environment variables (process.env)
270
+ const cliEnv = {
271
+ CUSTOM_API_URL: "https://cli.api",
272
+ };
273
+ // Mocking .env files using fs/promises
274
+ mockReadFile
275
+ .mockResolvedValueOnce("DB_HOST=db.local\nDB_PORT=5432") // Content of .env
276
+ .mockResolvedValueOnce("DB_HOST=db.override.local\nDB_USER=user123"); // Content of .env.local
277
+ // Step 1: Load and parse Docker Compose file
278
+ const parsedStack = await loadStackFromStr(dockerComposeFile, cliEnv);
279
+ // Step 2: Sanitize stack definition ( compat layer )
280
+ const sanitizedStack = sanitizeStackDefinition(parsedStack);
281
+ // Step 3: Enrich stack definition (process environment variables)
282
+ const enrichedStack = await enrichStackDefinition(sanitizedStack);
283
+ // Expected API request format after the transformation
284
+ const expectedApiRequest = {
285
+ services: {
286
+ nginx: {
287
+ image: "nginx:latest",
288
+ ports: ["80:80"],
289
+ envs: {
290
+ APP_ENV: "development", // From compose file
291
+ API_URL: "https://cli.api", // Default value overridden by CLI env
292
+ DB_HOST: "db.override.local", // Overridden by .env.local
293
+ DB_PORT: "5432", // Value from first .env file
294
+ DB_USER: "user123", // Value from .env.local
295
+ },
296
+ },
297
+ },
298
+ };
299
+ // Assertions
300
+ expect(enrichedStack).toMatchObject(expectedApiRequest);
301
+ // Type Assertions
302
+ const typeCheck = enrichedStack;
303
+ expect(typeCheck).toBeTruthy();
304
+ // Ensure env files are read in order
305
+ expect(mockReadFile).toHaveBeenCalledWith(".env", "utf-8");
306
+ expect(mockReadFile).toHaveBeenCalledWith(".env.local", "utf-8");
307
+ });
308
+ it("Issue # 1589: Invalid JSON from env file", async () => {
309
+ // Sample Docker Compose file content
310
+ const dockerComposeFile = `
311
+ version: '3'
312
+ services:
313
+ nginx:
314
+ image: 'nginx:latest'
315
+ ports:
316
+ - "80:80"
317
+ environment:
318
+ EXCLUDES: \${CUSTOM_EXCLUDES:-failed}
319
+ `;
320
+ // simulate env file input, mimes "collectEnvironment" output
321
+ const env = {
322
+ CUSTOM_EXCLUDES: JSON.stringify(["foo", "bar"]),
323
+ };
324
+ // Step 1: Load and parse Docker Compose file
325
+ // stuff from --env-file and/or process environment
326
+ // is already injected here!
327
+ const parsedStack = await loadStackFromStr(dockerComposeFile, env);
328
+ // Step 2: Sanitize stack definition ( compat layer )
329
+ const sanitizedStack = sanitizeStackDefinition(parsedStack);
330
+ // Step 3: Enrich stack definition (process environment variables)
331
+ // this one deals with additional env files *per service*
332
+ const enrichedStack = await enrichStackDefinition(sanitizedStack);
333
+ // Expected API request format after the transformation
334
+ const expectedApiRequest = {
335
+ services: {
336
+ nginx: {
337
+ image: "nginx:latest",
338
+ ports: ["80:80"],
339
+ envs: {
340
+ EXCLUDES: JSON.stringify(["foo", "bar"]),
341
+ },
342
+ },
343
+ },
344
+ };
345
+ // Assertions
346
+ expect(enrichedStack).toMatchObject(expectedApiRequest);
347
+ // Type Assertions
348
+ const typeCheck = enrichedStack;
349
+ expect(typeCheck).toBeTruthy();
350
+ });
351
+ });
@@ -1,13 +1,13 @@
1
1
  import * as fs from "fs/promises";
2
- import { parse } from "envfile";
3
2
  import { pathExists } from "../../util/fs/pathExists.js";
4
3
  import { getRandomValues } from "node:crypto";
4
+ import { parseEnvironmentVariablesFromStr } from "../../util/parser.js";
5
5
  export async function collectEnvironment(base, envFile) {
6
6
  if (!(await pathExists(envFile))) {
7
7
  return base;
8
8
  }
9
9
  const defs = await fs.readFile(envFile, { encoding: "utf-8" });
10
- const parsed = parse(defs);
10
+ const parsed = parseEnvironmentVariablesFromStr(defs);
11
11
  return { ...base, ...parsed };
12
12
  }
13
13
  /**
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect, beforeEach, jest } from "@jest/globals";
2
+ import { collectEnvironment } from "./env.js";
3
+ import * as path from "path";
4
+ describe("collectEnvironment", () => {
5
+ beforeEach(() => {
6
+ jest.clearAllMocks();
7
+ });
8
+ it("should preserve JSON array strings from env file", async () => {
9
+ // Simulate base environment variables
10
+ const baseEnv = {
11
+ NODE_ENV: "production",
12
+ };
13
+ // Mock .env file path and its content
14
+ const envFilePath = path.join(import.meta.dirname, "test_assets", "test.env");
15
+ // Call `collectEnvironment` with the mocks in effect
16
+ const result = await collectEnvironment(baseEnv, envFilePath);
17
+ // Assertions
18
+ expect(result).toEqual({
19
+ NODE_ENV: "production",
20
+ NODES_EXCLUDE: '["node1","node2","node3"]', // JSON array must remain a string
21
+ MY_ENV: "8.8.8.8",
22
+ });
23
+ });
24
+ });
@@ -1,5 +1,3 @@
1
- import type { MittwaldAPIV2 } from "@mittwald/api-client";
2
- type StackRequest = MittwaldAPIV2.Paths.V2StacksStackId.Put.Parameters.RequestBody;
3
- export declare function loadStackFromFile(file: string, environment: Record<string, string | undefined>): Promise<StackRequest>;
4
- export declare function loadStackFromStr(input: string, environment: Record<string, string | undefined>): Promise<StackRequest>;
5
- export {};
1
+ import { RawStackInput } from "./types.js";
2
+ export declare function loadStackFromFile(file: string, environment: Record<string, string | undefined>): Promise<RawStackInput>;
3
+ export declare function loadStackFromStr(input: string, environment: Record<string, string | undefined>): Promise<RawStackInput>;
@@ -1,10 +1,8 @@
1
- import type { MittwaldAPIV2 } from "@mittwald/api-client";
2
- type StackRequest = MittwaldAPIV2.Paths.V2StacksStackId.Put.Parameters.RequestBody;
1
+ import { RawStackInput } from "./types.js";
3
2
  /**
4
3
  * This function is needed to work around the mStudios supposedly docker-compose
5
4
  * compatible container stack API.
6
5
  *
7
6
  * @param stack
8
7
  */
9
- export declare function sanitizeStackDefinition(stack: StackRequest): StackRequest;
10
- export {};
8
+ export declare function sanitizeStackDefinition(stack: RawStackInput): RawStackInput;
@@ -9,4 +9,26 @@ export type ContainerServiceInput = ContainerServiceDeclareRequest & {
9
9
  };
10
10
  ports?: string[];
11
11
  };
12
+ export type RawStackInput = {
13
+ services?: {
14
+ [serviceName: string]: {
15
+ environment?: string[] | {
16
+ [k: string]: string;
17
+ };
18
+ env_file?: string | string[];
19
+ image: string;
20
+ ports?: string[];
21
+ command?: string[];
22
+ description?: string;
23
+ envs?: string[] | {
24
+ [k: string]: string;
25
+ };
26
+ };
27
+ };
28
+ volumes?: {
29
+ [key: string]: {
30
+ name: string;
31
+ };
32
+ };
33
+ };
12
34
  export {};
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Parses environment variables from array and strips unnecessary quotes from
3
+ * values.
4
+ *
5
+ * @param envFlags Array of environment variable strings in KEY=VALUE format
6
+ * @returns An object containing environment variable key-value pairs
7
+ */
8
+ export declare function parseEnvironmentVariablesFromArray(envFlags?: string[]): Record<string, string>;
9
+ /**
10
+ * Parses environment variables from string. Called with .env file content.
11
+ *
12
+ * @param src String describing environment, .env file notation
13
+ * @returns An object containing environment variable key-value pairs
14
+ */
15
+ export declare function parseEnvironmentVariablesFromStr(src: string): Record<string, string>;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Parses environment variables from array and strips unnecessary quotes from
3
+ * values.
4
+ *
5
+ * @param envFlags Array of environment variable strings in KEY=VALUE format
6
+ * @returns An object containing environment variable key-value pairs
7
+ */
8
+ export function parseEnvironmentVariablesFromArray(envFlags = []) {
9
+ const splitIntoKeyAndValue = (e) => {
10
+ const index = e.indexOf("=");
11
+ if (index < 0) {
12
+ throw new Error(`Invalid environment variable format: ${e}`);
13
+ }
14
+ const key = e.slice(0, index);
15
+ const rawValue = e.slice(index + 1);
16
+ // Remove enclosing quotes (if they exist)
17
+ const isQuoted = rawValue.startsWith('"') && rawValue.endsWith('"');
18
+ const value = isQuoted ? rawValue.slice(1, -1) : rawValue;
19
+ return [key, value];
20
+ };
21
+ return Object.fromEntries(envFlags.map(splitIntoKeyAndValue));
22
+ }
23
+ /**
24
+ * Parses environment variables from string. Called with .env file content.
25
+ *
26
+ * @param src String describing environment, .env file notation
27
+ * @returns An object containing environment variable key-value pairs
28
+ */
29
+ export function parseEnvironmentVariablesFromStr(src) {
30
+ const result = {};
31
+ const lines = src.toString().split("\n");
32
+ for (const line of lines) {
33
+ const match = line.match(/^([^=:#]+?)[=:](.*)/);
34
+ if (match) {
35
+ const key = match[1].trim();
36
+ const value = match[2].trim();
37
+ result[key] = value;
38
+ }
39
+ }
40
+ return result;
41
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,103 @@
1
+ import { describe, expect, test } from "@jest/globals";
2
+ import { parseEnvironmentVariablesFromArray, parseEnvironmentVariablesFromStr, } from "./parser.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 = parseEnvironmentVariablesFromArray(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 = parseEnvironmentVariablesFromArray(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(() => parseEnvironmentVariablesFromArray(args)).toThrow("Invalid environment variable format: invalidFlagWithoutEqualsSign");
23
+ });
24
+ test("remove enclosing quotes from values", () => {
25
+ const args = ['foo="bar"', 'ham="eggs and bacon"'];
26
+ const res = parseEnvironmentVariablesFromArray(args);
27
+ expect(res).toStrictEqual({
28
+ foo: "bar",
29
+ ham: "eggs and bacon",
30
+ });
31
+ });
32
+ test("preserve values with quotes in the middle", () => {
33
+ const args = ['foo=bar"baz', 'ham=eggs"and"bacon'];
34
+ const res = parseEnvironmentVariablesFromArray(args);
35
+ expect(res).toStrictEqual({
36
+ foo: 'bar"baz',
37
+ ham: 'eggs"and"bacon',
38
+ });
39
+ });
40
+ });
41
+ describe("parseEnvironmentVariablesFromStr", () => {
42
+ test("parse simple .env format", () => {
43
+ const src = "foo=bar\nham=eggs";
44
+ const res = parseEnvironmentVariablesFromStr(src);
45
+ expect(res).toStrictEqual({
46
+ foo: "bar",
47
+ ham: "eggs",
48
+ });
49
+ });
50
+ test("parse .env format with colon separator", () => {
51
+ const src = "foo:bar\nham:eggs";
52
+ const res = parseEnvironmentVariablesFromStr(src);
53
+ expect(res).toStrictEqual({
54
+ foo: "bar",
55
+ ham: "eggs",
56
+ });
57
+ });
58
+ test("parse .env format with empty lines", () => {
59
+ const src = "foo=bar\n\nham=eggs\n";
60
+ const res = parseEnvironmentVariablesFromStr(src);
61
+ expect(res).toStrictEqual({
62
+ foo: "bar",
63
+ ham: "eggs",
64
+ });
65
+ });
66
+ test("parse .env format with comments", () => {
67
+ const src = "foo=bar\n# comment line\nham=eggs";
68
+ const res = parseEnvironmentVariablesFromStr(src);
69
+ expect(res).toStrictEqual({
70
+ foo: "bar",
71
+ ham: "eggs",
72
+ });
73
+ });
74
+ test("parse .env format with values containing equals signs", () => {
75
+ const src = "extra_args=first=1 second=2 third=littlebitoflove";
76
+ const res = parseEnvironmentVariablesFromStr(src);
77
+ expect(res).toStrictEqual({
78
+ extra_args: "first=1 second=2 third=littlebitoflove",
79
+ });
80
+ });
81
+ test("trim whitespace from keys and values", () => {
82
+ const src = " foo = bar \n ham : eggs ";
83
+ const res = parseEnvironmentVariablesFromStr(src);
84
+ expect(res).toStrictEqual({
85
+ foo: "bar",
86
+ ham: "eggs",
87
+ });
88
+ });
89
+ test("handle empty string input", () => {
90
+ const src = "";
91
+ const res = parseEnvironmentVariablesFromStr(src);
92
+ expect(res).toStrictEqual({});
93
+ });
94
+ test("ignore lines without key-value pairs", () => {
95
+ const src = "foo=bar\ninvalid line\nham=eggs";
96
+ const res = parseEnvironmentVariablesFromStr(src);
97
+ expect(res).toStrictEqual({
98
+ foo: "bar",
99
+ ham: "eggs",
100
+ });
101
+ });
102
+ });
103
+ });
@@ -34,6 +34,7 @@ export const CronJobDetails = ({ cronjob }) => {
34
34
  Project: project ? _jsx(IDAndShortID, { object: project }) : _jsx(Value, { notSet: true }),
35
35
  App: _jsx(IDAndShortID, { object: app }),
36
36
  Schedule: (_jsxs(Text, { children: [_jsx(Value, { children: cronjob.interval }), " (next execution:", " ", _jsx(CronJobNextExecution, { cronjob: cronjob }), ")"] })),
37
+ Timezone: _jsx(Value, { children: cronjob.timeZone || "UTC" }),
37
38
  };
38
39
  const sections = [
39
40
  _jsx(SingleResult, { title: _jsxs(_Fragment, { children: ["CRON JOB DETAILS: ", _jsx(Value, { children: cronjob.description })] }), rows: rows }, "primary"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mittwald/cli",
3
- "version": "1.13.1",
3
+ "version": "1.13.3",
4
4
  "description": "Hand-crafted CLI for the mittwald API",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -57,7 +57,6 @@
57
57
  "chalk": "^5.3.0",
58
58
  "date-fns": "^4.0.0",
59
59
  "docker-names": "^1.2.1",
60
- "envfile": "^7.1.0",
61
60
  "fast-xml-parser": "^5.2.5",
62
61
  "ink": "^6.2.3",
63
62
  "ink-link": "^5.0.0",
@@ -72,7 +71,7 @@
72
71
  "semver": "^7.5.4",
73
72
  "semver-parser": "^4.1.6",
74
73
  "shell-escape": "^0.2.0",
75
- "slice-ansi": "^7.1.0",
74
+ "slice-ansi": "^8.0.0",
76
75
  "string-width": "^8.0.0",
77
76
  "tempfile": "^6.0.1",
78
77
  "uuid": "^13.0.0"
@@ -97,11 +96,11 @@
97
96
  "license-checker-rseidelsohn": "^4.2.6",
98
97
  "nock": "^14.0.0",
99
98
  "oclif": "^4.14.31",
100
- "prettier": "~3.6.2",
99
+ "prettier": "~3.8.1",
101
100
  "prettier-plugin-jsdoc": "^1.3.2",
102
101
  "prettier-plugin-package": "^2.0.0",
103
102
  "prettier-plugin-sort-json": "^4.1.1",
104
- "rimraf": "^5.0.1",
103
+ "rimraf": "^6.1.3",
105
104
  "ts-jest": "^29.2.5",
106
105
  "ts-node": "^10.9.2",
107
106
  "tsx": "^4.7.1",
@@ -1,25 +0,0 @@
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
- });