@pagopa/dx-cli 0.22.2 → 0.22.4

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 (24) hide show
  1. package/dist/adapters/commander/presenters/__tests__/index.test.d.ts +1 -0
  2. package/dist/adapters/commander/presenters/__tests__/index.test.js +23 -0
  3. package/dist/adapters/commander/presenters/__tests__/json.test.d.ts +1 -0
  4. package/dist/adapters/commander/presenters/__tests__/json.test.js +108 -0
  5. package/dist/adapters/commander/presenters/__tests__/text.test.d.ts +1 -0
  6. package/dist/adapters/commander/presenters/__tests__/text.test.js +60 -0
  7. package/dist/adapters/commander/presenters/index.d.ts +27 -0
  8. package/dist/adapters/commander/presenters/index.js +16 -0
  9. package/dist/adapters/commander/presenters/json-command-presenter.d.ts +19 -0
  10. package/dist/adapters/commander/presenters/json-command-presenter.js +26 -0
  11. package/dist/adapters/commander/presenters/text-command-presenter.d.ts +6 -0
  12. package/dist/adapters/commander/presenters/text-command-presenter.js +21 -0
  13. package/dist/adapters/plop/generators/environment/__tests__/actions.test.js +26 -0
  14. package/dist/adapters/plop/generators/environment/__tests__/prompts.test.js +6 -3
  15. package/dist/adapters/plop/generators/environment/actions.js +15 -9
  16. package/dist/adapters/plop/generators/environment/index.js +2 -0
  17. package/dist/adapters/plop/generators/environment/prompts.d.ts +2 -2
  18. package/dist/adapters/plop/generators/environment/prompts.js +9 -2
  19. package/dist/adapters/plop/helpers/__tests__/terraform-state-key.test.d.ts +1 -0
  20. package/dist/adapters/plop/helpers/__tests__/terraform-state-key.test.js +35 -0
  21. package/dist/adapters/plop/helpers/terraform-state-key.d.ts +33 -0
  22. package/dist/adapters/plop/helpers/terraform-state-key.js +30 -0
  23. package/package.json +1 -1
  24. package/templates/environment/bootstrapper/{{env.name}}/main.tf.hbs +1 -1
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Tests for isNonInteractive and createCommandPresenter factory.
3
+ */
4
+ import { describe, expect, it } from "vitest";
5
+ import { createCommandPresenter, isNonInteractive } from "../index.js";
6
+ import { JsonCommandPresenter } from "../json-command-presenter.js";
7
+ import { TextCommandPresenter } from "../text-command-presenter.js";
8
+ describe("isNonInteractive", () => {
9
+ it("returns true when CI is true", () => {
10
+ expect(isNonInteractive({ CI: true })).toBe(true);
11
+ });
12
+ it("returns false when CI is false", () => {
13
+ expect(isNonInteractive({ CI: false })).toBe(false);
14
+ });
15
+ });
16
+ describe("createCommandPresenter", () => {
17
+ it("returns a TextCommandPresenter when output is 'text'", () => {
18
+ expect(createCommandPresenter("text")).toBeInstanceOf(TextCommandPresenter);
19
+ });
20
+ it("returns a JsonCommandPresenter when output is 'json'", () => {
21
+ expect(createCommandPresenter("json")).toBeInstanceOf(JsonCommandPresenter);
22
+ });
23
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Tests for JsonCommandPresenter.
3
+ *
4
+ * JsonCommandPresenter uses a split-stream convention:
5
+ * - stdout for the final result/error envelope
6
+ * - stderr for step lifecycle (progress) events
7
+ *
8
+ * Tests verify the wire format, correct stream usage, and that values
9
+ * flow through correctly.
10
+ */
11
+ import { afterEach, describe, expect, it, vi } from "vitest";
12
+ import { JsonCommandPresenter } from "../json-command-presenter.js";
13
+ const captureStdout = () => {
14
+ const written = [];
15
+ vi.spyOn(process.stdout, "write").mockImplementation((data) => {
16
+ written.push(String(data));
17
+ return true;
18
+ });
19
+ return { written };
20
+ };
21
+ const captureStderr = () => {
22
+ const written = [];
23
+ vi.spyOn(process.stderr, "write").mockImplementation((data) => {
24
+ written.push(String(data));
25
+ return true;
26
+ });
27
+ return { written };
28
+ };
29
+ describe("JsonCommandPresenter", () => {
30
+ afterEach(() => {
31
+ vi.restoreAllMocks();
32
+ });
33
+ describe("trackStep", () => {
34
+ it("executes the task and returns its resolved value", async () => {
35
+ captureStderr();
36
+ const logger = new JsonCommandPresenter();
37
+ const result = await logger.trackStep("check terraform", () => Promise.resolve(42));
38
+ expect(result).toBe(42);
39
+ });
40
+ it("emits start then success events to stderr", async () => {
41
+ const stderr = captureStderr();
42
+ const logger = new JsonCommandPresenter();
43
+ await logger.trackStep("check terraform", () => Promise.resolve("done"));
44
+ expect(stderr.written).toHaveLength(2);
45
+ expect(JSON.parse(stderr.written[0])).toMatchObject({
46
+ name: "check terraform",
47
+ status: "start",
48
+ type: "step",
49
+ });
50
+ expect(JSON.parse(stderr.written[1])).toMatchObject({
51
+ name: "check terraform",
52
+ status: "success",
53
+ type: "step",
54
+ });
55
+ });
56
+ it("emits error event to stderr and rethrows on task failure", async () => {
57
+ const stderr = captureStderr();
58
+ const logger = new JsonCommandPresenter();
59
+ const error = new Error("terraform not found");
60
+ await expect(logger.trackStep("check terraform", () => Promise.reject(error))).rejects.toThrow("terraform not found");
61
+ expect(JSON.parse(stderr.written[1])).toMatchObject({
62
+ error: "terraform not found",
63
+ name: "check terraform",
64
+ status: "error",
65
+ type: "step",
66
+ });
67
+ });
68
+ it("can run multiple sequential steps", async () => {
69
+ captureStderr();
70
+ const logger = new JsonCommandPresenter();
71
+ const order = [];
72
+ await logger.trackStep("step A", async () => {
73
+ order.push("A");
74
+ });
75
+ await logger.trackStep("step B", async () => {
76
+ order.push("B");
77
+ });
78
+ expect(order).toEqual(["A", "B"]);
79
+ });
80
+ });
81
+ describe("reportResult", () => {
82
+ it("emits an ok:true JSON envelope to stdout", () => {
83
+ const stdout = captureStdout();
84
+ const logger = new JsonCommandPresenter();
85
+ logger.reportResult({ repository: { name: "my-repo" } });
86
+ expect(JSON.parse(stdout.written[0])).toEqual({
87
+ data: { repository: { name: "my-repo" } },
88
+ ok: true,
89
+ });
90
+ });
91
+ });
92
+ describe("reportError", () => {
93
+ it("emits an ok:false JSON envelope to stdout", () => {
94
+ const stdout = captureStdout();
95
+ const logger = new JsonCommandPresenter();
96
+ logger.reportError(new Error("something failed"));
97
+ expect(JSON.parse(stdout.written[0])).toMatchObject({
98
+ error: "something failed",
99
+ ok: false,
100
+ });
101
+ });
102
+ it("handles non-Error values without throwing", () => {
103
+ captureStdout();
104
+ const logger = new JsonCommandPresenter();
105
+ expect(() => logger.reportError("plain string error")).not.toThrow();
106
+ });
107
+ });
108
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Tests for TextCommandPresenter.
3
+ *
4
+ * The TextCommandPresenter wraps oraPromise for step feedback and chalk for
5
+ * final output. Tests verify the observable behavior: tasks are executed,
6
+ * values flow through, and errors surface correctly.
7
+ */
8
+ import { afterEach, describe, expect, it, vi } from "vitest";
9
+ // Mock oraPromise so tests don't spin up a real TTY spinner
10
+ vi.mock("ora", () => ({
11
+ oraPromise: (_promise) => _promise,
12
+ }));
13
+ import { TextCommandPresenter } from "../text-command-presenter.js";
14
+ describe("TextCommandPresenter", () => {
15
+ afterEach(() => {
16
+ vi.restoreAllMocks();
17
+ });
18
+ describe("trackStep", () => {
19
+ it("executes the task and returns its resolved value", async () => {
20
+ const logger = new TextCommandPresenter();
21
+ const result = await logger.trackStep("check terraform", () => Promise.resolve(42));
22
+ expect(result).toBe(42);
23
+ });
24
+ it("propagates task rejection as a thrown error", async () => {
25
+ const logger = new TextCommandPresenter();
26
+ const error = new Error("terraform not found");
27
+ await expect(logger.trackStep("check terraform", () => Promise.reject(error))).rejects.toThrow("terraform not found");
28
+ });
29
+ it("can run multiple sequential steps", async () => {
30
+ const logger = new TextCommandPresenter();
31
+ const order = [];
32
+ await logger.trackStep("step A", async () => {
33
+ order.push("A");
34
+ });
35
+ await logger.trackStep("step B", async () => {
36
+ order.push("B");
37
+ });
38
+ expect(order).toEqual(["A", "B"]);
39
+ });
40
+ });
41
+ describe("reportResult", () => {
42
+ it("calls console.log without throwing", () => {
43
+ vi.spyOn(console, "log").mockImplementation(() => undefined);
44
+ const logger = new TextCommandPresenter();
45
+ expect(() => logger.reportResult({ repository: { name: "my-repo" } })).not.toThrow();
46
+ });
47
+ });
48
+ describe("reportError", () => {
49
+ it("calls console.error without throwing", () => {
50
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
51
+ const logger = new TextCommandPresenter();
52
+ expect(() => logger.reportError(new Error("something failed"))).not.toThrow();
53
+ });
54
+ it("handles non-Error values without throwing", () => {
55
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
56
+ const logger = new TextCommandPresenter();
57
+ expect(() => logger.reportError("plain string error")).not.toThrow();
58
+ });
59
+ });
60
+ });
@@ -0,0 +1,27 @@
1
+ import type { CommandPresenter } from "../../../domain/command-presenter.js";
2
+ /**
3
+ * Output logger factory.
4
+ *
5
+ * isNonInteractive determines whether the CLI should suppress interactive
6
+ * prompts (e.g. when running in a CI pipeline). This is independent of the
7
+ * output format: a user can request JSON output while still answering prompts,
8
+ * and a CI system can set CI=true with text output.
9
+ *
10
+ * createCommandPresenter selects the appropriate CommandPresenter adapter based solely
11
+ * on the requested output format.
12
+ */
13
+ import type { CliEnv } from "../env.js";
14
+ /**
15
+ * Returns true when the CLI should suppress interactive prompts.
16
+ *
17
+ * Checks the parsed CI boolean from CliEnv (coerced by zod's stringbool),
18
+ * following the same convention used by `is-interactive` and `ora`.
19
+ */
20
+ export declare const isNonInteractive: (env: CliEnv) => boolean;
21
+ /**
22
+ * Returns the appropriate CommandPresenter adapter for the requested output mode.
23
+ *
24
+ * - "text" → TextCommandPresenter (chalk + ora, for human terminal sessions)
25
+ * - "json" → JsonCommandPresenter (NDJSON on stderr, JSON envelope on stdout)
26
+ */
27
+ export declare const createCommandPresenter: (output: "json" | "text") => CommandPresenter;
@@ -0,0 +1,16 @@
1
+ import { JsonCommandPresenter } from "./json-command-presenter.js";
2
+ import { TextCommandPresenter } from "./text-command-presenter.js";
3
+ /**
4
+ * Returns true when the CLI should suppress interactive prompts.
5
+ *
6
+ * Checks the parsed CI boolean from CliEnv (coerced by zod's stringbool),
7
+ * following the same convention used by `is-interactive` and `ora`.
8
+ */
9
+ export const isNonInteractive = (env) => env.CI;
10
+ /**
11
+ * Returns the appropriate CommandPresenter adapter for the requested output mode.
12
+ *
13
+ * - "text" → TextCommandPresenter (chalk + ora, for human terminal sessions)
14
+ * - "json" → JsonCommandPresenter (NDJSON on stderr, JSON envelope on stdout)
15
+ */
16
+ export const createCommandPresenter = (output) => output === "json" ? new JsonCommandPresenter() : new TextCommandPresenter();
@@ -0,0 +1,19 @@
1
+ /**
2
+ * JsonCommandPresenter — structured adapter for the CommandPresenter domain port.
3
+ *
4
+ * Uses a split-stream convention for machine-readable output:
5
+ * - stdout: final result/error envelope (JSON, one line)
6
+ * - stderr: step lifecycle events (NDJSON progress stream)
7
+ *
8
+ * Each line is a self-describing JSON object discriminated by its fields:
9
+ *
10
+ * stderr: {"type":"step","status":"start"|"success"|"error","name":"...","error":"...?"}
11
+ * stdout: {"ok":true,"data":{...}}
12
+ * stdout: {"ok":false,"error":"..."}
13
+ */
14
+ import type { CommandPresenter } from "../../../domain/command-presenter.js";
15
+ export declare class JsonCommandPresenter implements CommandPresenter {
16
+ reportError(error: unknown): void;
17
+ reportResult<T>(data: T): void;
18
+ trackStep<T>(name: string, task: () => Promise<T>): Promise<T>;
19
+ }
@@ -0,0 +1,26 @@
1
+ import { toErrorMessage } from "../error-reporting.js";
2
+ export class JsonCommandPresenter {
3
+ reportError(error) {
4
+ process.stdout.write(JSON.stringify({ error: toErrorMessage(error), ok: false }) + "\n");
5
+ }
6
+ reportResult(data) {
7
+ process.stdout.write(JSON.stringify({ data, ok: true }) + "\n");
8
+ }
9
+ async trackStep(name, task) {
10
+ process.stderr.write(JSON.stringify({ name, status: "start", type: "step" }) + "\n");
11
+ try {
12
+ const result = await task();
13
+ process.stderr.write(JSON.stringify({ name, status: "success", type: "step" }) + "\n");
14
+ return result;
15
+ }
16
+ catch (error) {
17
+ process.stderr.write(JSON.stringify({
18
+ error: toErrorMessage(error),
19
+ name,
20
+ status: "error",
21
+ type: "step",
22
+ }) + "\n");
23
+ throw error;
24
+ }
25
+ }
26
+ }
@@ -0,0 +1,6 @@
1
+ import type { CommandPresenter } from "../../../domain/command-presenter.js";
2
+ export declare class TextCommandPresenter implements CommandPresenter {
3
+ reportError(error: unknown): void;
4
+ reportResult<T>(data: T): void;
5
+ trackStep<T>(name: string, task: () => Promise<T>): Promise<T>;
6
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * TextCommandPresenter — human-readable adapter for the CommandPresenter domain port.
3
+ *
4
+ * Renders step progress via oraPromise for interactive terminal feedback,
5
+ * and writes results and errors to the console with chalk colouring.
6
+ * Intended for interactive terminal sessions (TTY).
7
+ */
8
+ import chalk from "chalk";
9
+ import { oraPromise } from "ora";
10
+ import { toErrorMessage } from "../error-reporting.js";
11
+ export class TextCommandPresenter {
12
+ reportError(error) {
13
+ console.error(chalk.red(toErrorMessage(error)));
14
+ }
15
+ reportResult(data) {
16
+ console.log(data);
17
+ }
18
+ async trackStep(name, task) {
19
+ return oraPromise(task(), { text: name });
20
+ }
21
+ }
@@ -1,5 +1,12 @@
1
1
  import { describe, expect, test } from "vitest";
2
+ import { z } from "zod";
2
3
  import getActions from "../actions.js";
4
+ const terraformBackendActionSchema = z.object({
5
+ data: z.object({
6
+ terraformBackendKey: z.string(),
7
+ }),
8
+ type: z.literal("addMany"),
9
+ });
3
10
  export const getPayload = (includeInit = false) => {
4
11
  const cloudAccount = {
5
12
  csp: "azure",
@@ -63,4 +70,23 @@ describe("actions", () => {
63
70
  .map((action) => action.type);
64
71
  expect(actionTypes).toEqual(actionsOrder);
65
72
  });
73
+ test.each([
74
+ {
75
+ expectedKeys: [
76
+ "dx/mytest/bootstrapper.tfstate",
77
+ "dx/mytest/core.tfstate",
78
+ ],
79
+ payload: getPayload(true),
80
+ },
81
+ {
82
+ expectedKeys: ["dx/mytest/bootstrapper.tfstate"],
83
+ payload: getPayload(false),
84
+ },
85
+ ])("uses prefix/domain/scope state keys in shared backend actions", ({ expectedKeys, payload }) => {
86
+ const actions = getActions("/templates/path")(payload);
87
+ const terraformBackendKeys = actions
88
+ .filter((action) => terraformBackendActionSchema.safeParse(action).success)
89
+ .map((action) => action.data.terraformBackendKey);
90
+ expect(terraformBackendKeys).toEqual(expectedKeys);
91
+ });
66
92
  });
@@ -18,10 +18,13 @@ describe("workspaceSchema — domain transforms", () => {
18
18
  expect(result.success).toBe(true);
19
19
  expect(result.success && result.data.domain).toBe("api");
20
20
  });
21
- it("defaults domain to empty string when not provided", () => {
21
+ it("requires domain to be provided", () => {
22
22
  const result = workspaceSchema.safeParse({});
23
- expect(result.success).toBe(true);
24
- expect(result.success && result.data.domain).toBe("");
23
+ expect(result.success).toBe(false);
24
+ });
25
+ it("rejects domains that would create nested paths", () => {
26
+ const result = workspaceSchema.safeParse({ domain: "core/platform" });
27
+ expect(result.success).toBe(false);
25
28
  });
26
29
  });
27
30
  describe("formatInitializationDetails", () => {
@@ -1,12 +1,14 @@
1
1
  import { getLogger } from "@logtape/logtape";
2
2
  import * as path from "node:path";
3
3
  import { formatTerraformCode } from "../../../terraform/fmt.js";
4
+ import { terraformStateKey } from "../../helpers/terraform-state-key.js";
4
5
  import { payloadSchema } from "./prompts.js";
5
- const addModule = (env, templatesPath, init = false) => {
6
+ const addModule = (context, templatesPath, init = false) => {
7
+ const { env } = context;
6
8
  const cloudAccountsByCsp = Object.groupBy(env.cloudAccounts, (account) => account.csp);
7
9
  const includesProdIO = env.cloudAccounts.some((account) => account.displayName === "PROD-IO");
8
10
  const cwd = process.cwd();
9
- return (name, terraformBackendKey) => [
11
+ return (name) => [
10
12
  {
11
13
  base: templatesPath,
12
14
  data: { cloudAccountsByCsp, includesProdIO, init },
@@ -19,7 +21,11 @@ const addModule = (env, templatesPath, init = false) => {
19
21
  },
20
22
  {
21
23
  base: path.join(templatesPath, "shared"),
22
- data: { cloudAccountsByCsp, init, terraformBackendKey },
24
+ data: {
25
+ cloudAccountsByCsp,
26
+ init,
27
+ terraformBackendKey: terraformStateKey(context, name),
28
+ },
23
29
  destination: path.join(cwd, "infra", name, "{{env.name}}"),
24
30
  force: true,
25
31
  templateFiles: path.join(templatesPath, "shared"),
@@ -41,17 +47,17 @@ const addWorkflowModule = (templatesPath) => {
41
47
  };
42
48
  };
43
49
  export default function getActions(templatesPath) {
44
- return (payload) => {
50
+ return (input) => {
45
51
  const logger = getLogger(["gen", "env"]);
46
- logger.debug("payload {payload}", { payload });
47
- const { env, github, init } = payloadSchema.parse(payload);
48
- const addEnvironmentModule = addModule(env, templatesPath, !!init);
52
+ logger.debug("environment generator input {input}", { input });
53
+ const { env, init, workspace } = payloadSchema.parse(input);
54
+ const addEnvironmentModule = addModule({ env, workspace }, templatesPath, !!init);
49
55
  const actions = [
50
56
  {
51
57
  type: "getTerraformBackend",
52
58
  },
53
59
  addWorkflowModule(templatesPath),
54
- ...addEnvironmentModule("bootstrapper", `${github.repo}.bootstrapper.${env.name}.tfstate`),
60
+ ...addEnvironmentModule("bootstrapper"),
55
61
  ];
56
62
  if (init) {
57
63
  actions.unshift({
@@ -61,7 +67,7 @@ export default function getActions(templatesPath) {
61
67
  ? "provisionTerraformBackend"
62
68
  : "getTerraformBackend",
63
69
  });
64
- actions.push(...addEnvironmentModule("core", `${env.prefix}.core.${env.name}.tfstate`));
70
+ actions.push(...addEnvironmentModule("core"));
65
71
  }
66
72
  return actions;
67
73
  };
@@ -4,6 +4,7 @@ import setProvisionTerraformBackendAction from "../../actions/provision-terrafor
4
4
  import setEnvShortHelper from "../../helpers/env-short.js";
5
5
  import setEqHelper from "../../helpers/eq.js";
6
6
  import setResourcePrefixHelper from "../../helpers/resource-prefix.js";
7
+ import setTerraformStateKeyHelper from "../../helpers/terraform-state-key.js";
7
8
  import getActions from "./actions.js";
8
9
  import getPrompts, { payloadSchema } from "./prompts.js";
9
10
  export const PLOP_ENVIRONMENT_GENERATOR_NAME = "DX_DeploymentEnvironment";
@@ -12,6 +13,7 @@ export default function (plop, templatesPath, cloudAccountRepository, cloudAccou
12
13
  setEnvShortHelper(plop);
13
14
  setResourcePrefixHelper(plop);
14
15
  setEqHelper(plop);
16
+ setTerraformStateKeyHelper(plop);
15
17
  setGetTerraformBackend(plop, cloudAccountService);
16
18
  setProvisionTerraformBackendAction(plop, cloudAccountService);
17
19
  setInitCloudAccountsAction(plop, cloudAccountService, gitHubService);
@@ -9,7 +9,7 @@ type InquirerChoice<T> = inquirer.Separator | {
9
9
  import { z } from "zod/v4";
10
10
  import { GitHubRepo } from "../../../../domain/github-repo.js";
11
11
  export declare const workspaceSchema: z.ZodObject<{
12
- domain: z.ZodDefault<z.ZodString>;
12
+ domain: z.ZodString;
13
13
  }, z.core.$strip>;
14
14
  export declare const payloadSchema: z.ZodObject<{
15
15
  env: z.ZodObject<{
@@ -60,7 +60,7 @@ export declare const payloadSchema: z.ZodObject<{
60
60
  }, z.core.$strip>>;
61
61
  tags: z.ZodRecord<z.ZodString, z.ZodString>;
62
62
  workspace: z.ZodObject<{
63
- domain: z.ZodDefault<z.ZodString>;
63
+ domain: z.ZodString;
64
64
  }, z.core.$strip>;
65
65
  }, z.core.$strip>;
66
66
  export type Payload = z.infer<typeof payloadSchema>;
@@ -19,8 +19,14 @@ const initSchema = z.object({
19
19
  .optional(),
20
20
  });
21
21
  const tagsSchema = z.record(z.string(), z.string().min(1));
22
+ const workspaceDomainSchema = z
23
+ .string()
24
+ .trim()
25
+ .toLowerCase()
26
+ .min(1, "Domain is required")
27
+ .regex(/^[a-z0-9-]+$/, "Domain may contain only lowercase letters, numbers, and hyphens");
22
28
  export const workspaceSchema = z.object({
23
- domain: z.string().trim().toLowerCase().default(""),
29
+ domain: workspaceDomainSchema,
24
30
  });
25
31
  export const payloadSchema = z.object({
26
32
  env: environmentSchema,
@@ -76,9 +82,10 @@ const prompts = (deps) => async (inquirer) => {
76
82
  },
77
83
  {
78
84
  filter: (value) => value.trim().toLowerCase(),
79
- message: "Domain (optional)",
85
+ message: "Domain",
80
86
  name: "workspace.domain",
81
87
  type: "input",
88
+ validate: validatePrompt(workspaceSchema.shape.domain),
82
89
  },
83
90
  {
84
91
  choices: [
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Tests for Terraform state key generation used by the environment generator.
3
+ */
4
+ import { describe, expect, it } from "vitest";
5
+ import { terraformStateKey } from "../terraform-state-key.js";
6
+ const createMockContext = (overrides = {}) => ({
7
+ env: {
8
+ cloudAccounts: [],
9
+ name: "dev",
10
+ prefix: "dx",
11
+ },
12
+ workspace: {
13
+ domain: "shared",
14
+ },
15
+ ...overrides,
16
+ });
17
+ describe("terraformStateKey", () => {
18
+ it("returns keys using the prefix/domain/scope convention", () => {
19
+ const result = terraformStateKey(createMockContext(), "bootstrapper");
20
+ expect(result).toBe("dx/shared/bootstrapper.tfstate");
21
+ });
22
+ it("supports hyphenated names", () => {
23
+ const result = terraformStateKey(createMockContext({
24
+ workspace: { domain: "playground" },
25
+ }), "mcp-server");
26
+ expect(result).toBe("dx/playground/mcp-server.tfstate");
27
+ });
28
+ it("rejects names that would create nested paths", () => {
29
+ expect(() => terraformStateKey(createMockContext(), "core/root")).toThrow(/Terraform state name may contain only lowercase letters, numbers, and hyphens/u);
30
+ });
31
+ it("rejects names with uppercase letters or surrounding spaces", () => {
32
+ expect(() => terraformStateKey(createMockContext(), "Mcp-Server")).toThrow(/Terraform state name may contain only lowercase letters, numbers, and hyphens/u);
33
+ expect(() => terraformStateKey(createMockContext(), " mcp-server ")).toThrow(/Terraform state name may contain only lowercase letters, numbers, and hyphens/u);
34
+ });
35
+ });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Terraform state key helper.
3
+ *
4
+ * Keeps the DX environment generator aligned with the shared
5
+ * prefix/domain/scope.tfstate convention for remote state keys.
6
+ */
7
+ import { type NodePlopAPI } from "node-plop";
8
+ import { z } from "zod";
9
+ declare const terraformStateContextSchema: z.ZodObject<{
10
+ env: z.ZodObject<{
11
+ cloudAccounts: z.ZodArray<z.ZodObject<{
12
+ csp: z.ZodDefault<z.ZodEnum<{
13
+ azure: "azure";
14
+ }>>;
15
+ defaultLocation: z.ZodString;
16
+ displayName: z.ZodString;
17
+ id: z.ZodString;
18
+ }, z.core.$strip>>;
19
+ name: z.ZodEnum<{
20
+ dev: "dev";
21
+ prod: "prod";
22
+ uat: "uat";
23
+ }>;
24
+ prefix: z.ZodString;
25
+ }, z.core.$strip>;
26
+ workspace: z.ZodObject<{
27
+ domain: z.ZodString;
28
+ }, z.core.$strip>;
29
+ }, z.core.$strip>;
30
+ type TerraformStateContext = z.infer<typeof terraformStateContextSchema>;
31
+ export declare const terraformStateKey: (context: TerraformStateContext, name: string) => string;
32
+ declare const _default: (plop: NodePlopAPI) => void;
33
+ export default _default;
@@ -0,0 +1,30 @@
1
+ import { z } from "zod";
2
+ import { payloadSchema } from "../generators/environment/prompts.js";
3
+ const terraformStateContextSchema = payloadSchema.pick({
4
+ env: true,
5
+ workspace: true,
6
+ });
7
+ const terraformStateNameSchema = z
8
+ .string()
9
+ .regex(/^[a-z0-9-]+$/, "Terraform state name may contain only lowercase letters, numbers, and hyphens");
10
+ export const terraformStateKey = (context, name) => {
11
+ const parsedName = terraformStateNameSchema.safeParse(name);
12
+ if (!parsedName.success) {
13
+ throw new Error(parsedName.error.issues[0]?.message ?? "Invalid Terraform state name", {
14
+ cause: parsedName.error,
15
+ });
16
+ }
17
+ return `${context.env.prefix}/${context.workspace.domain}/${parsedName.data}.tfstate`;
18
+ };
19
+ export default (plop) => {
20
+ plop.setHelper("terraformStateKey", (input, name) => {
21
+ const context = terraformStateContextSchema.safeParse(input);
22
+ if (!context.success) {
23
+ throw new Error(context.error.issues[0]?.message ??
24
+ "Invalid Terraform state helper input", {
25
+ cause: context.error,
26
+ });
27
+ }
28
+ return terraformStateKey(context.data, name);
29
+ });
30
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pagopa/dx-cli",
3
- "version": "0.22.2",
3
+ "version": "0.22.4",
4
4
  "type": "module",
5
5
  "description": "A CLI useful to manage DX tools.",
6
6
  "repository": {
@@ -60,7 +60,7 @@ module "azure-{{displayName}}_core_values" {
60
60
  storage_account_name = "{{@root.terraform.backend.storageAccountName}}"
61
61
  subscription_id = "{{@root.terraform.backend.subscriptionId}}"
62
62
  container_name = "terraform-state"
63
- key = "{{@root.env.prefix}}.core.{{@root.env.name}}.tfstate"
63
+ key = "{{terraformStateKey @root "core"}}"
64
64
  }
65
65
  }
66
66