@pagopa/dx-cli 0.22.4 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +18 -0
  2. package/dist/adapters/commander/__tests__/spec.test.d.ts +8 -0
  3. package/dist/adapters/commander/__tests__/spec.test.js +264 -0
  4. package/dist/adapters/commander/commands/__tests__/init-command.test.d.ts +4 -0
  5. package/dist/adapters/commander/commands/__tests__/init-command.test.js +44 -0
  6. package/dist/adapters/commander/commands/__tests__/savemoney.test.d.ts +8 -0
  7. package/dist/adapters/commander/commands/__tests__/savemoney.test.js +60 -0
  8. package/dist/adapters/commander/commands/__tests__/spec.test.d.ts +7 -0
  9. package/dist/adapters/commander/commands/__tests__/spec.test.js +85 -0
  10. package/dist/adapters/commander/commands/add.d.ts +2 -6
  11. package/dist/adapters/commander/commands/add.js +4 -8
  12. package/dist/adapters/commander/commands/init.d.ts +2 -6
  13. package/dist/adapters/commander/commands/init.js +6 -5
  14. package/dist/adapters/commander/commands/savemoney.d.ts +11 -0
  15. package/dist/adapters/commander/commands/savemoney.js +30 -5
  16. package/dist/adapters/commander/commands/spec.d.ts +10 -0
  17. package/dist/adapters/commander/commands/spec.js +13 -0
  18. package/dist/adapters/commander/index.js +6 -2
  19. package/dist/adapters/commander/spec.d.ts +16 -0
  20. package/dist/adapters/commander/spec.js +54 -0
  21. package/dist/adapters/plop/generators/__tests__/temp-dir.d.ts +2 -0
  22. package/dist/adapters/plop/generators/__tests__/temp-dir.js +13 -0
  23. package/dist/adapters/plop/generators/environment/__tests__/generation.test.d.ts +1 -0
  24. package/dist/adapters/plop/generators/environment/__tests__/generation.test.js +213 -0
  25. package/dist/adapters/plop/generators/environment/actions.js +3 -3
  26. package/dist/adapters/plop/generators/monorepo/__tests__/generation.test.d.ts +1 -0
  27. package/dist/adapters/plop/generators/monorepo/__tests__/generation.test.js +79 -0
  28. package/dist/adapters/plop/index.js +3 -5
  29. package/dist/adapters/plop/templates-path.d.ts +5 -0
  30. package/dist/adapters/plop/templates-path.js +6 -0
  31. package/dist/domain/__tests__/data.d.ts +3 -7
  32. package/dist/domain/__tests__/data.js +2 -4
  33. package/dist/domain/dependencies.d.ts +15 -2
  34. package/dist/domain/spec.d.ts +50 -0
  35. package/dist/domain/spec.js +8 -0
  36. package/dist/index.js +15 -12
  37. package/package.json +2 -2
@@ -1,5 +1,7 @@
1
1
  import { azure, loadConfig } from "@pagopa/dx-savemoney";
2
- import { Command } from "commander";
2
+ import { Command, InvalidArgumentError } from "commander";
3
+ import { oraPromise } from "ora";
4
+ import { z } from "zod";
3
5
  import { exitWithError } from "../index.js";
4
6
  export const makeSavemoneyCommand = () => new Command("savemoney")
5
7
  .description("Analyze Azure subscriptions and report unused or inefficient resources")
@@ -7,7 +9,8 @@ export const makeSavemoneyCommand = () => new Command("savemoney")
7
9
  .option("-f, --format <format>", "Report format: json, table, detailed-json, or lint (default: table)", "table")
8
10
  .option("-l, --location <string>", "Preferred Azure location for resources (overrides config file)", "italynorth")
9
11
  .option("-d, --days <number>", "Number of days for metrics analysis (overrides config file)", "30")
10
- .option("-t, --tags <tags...>", "Filter resources by tags (key=value key2=value2). Only resources matching ALL specified tags are analyzed.")
12
+ .option("-t, --tags <tags...>", "Filter findings by resource tags (key=value key2=value2). Advisor subscription-level findings remain global.")
13
+ .option("-s, --source <source>", "Restrict findings to a single source: 'advisor', 'custom', or 'all' (default: all)", parseSourceOption, "all")
11
14
  .action(async function (options) {
12
15
  const { verbose } = this.optsWithGlobals();
13
16
  try {
@@ -19,11 +22,21 @@ export const makeSavemoneyCommand = () => new Command("savemoney")
19
22
  ...config,
20
23
  filterTags,
21
24
  preferredLocation: options.location || config.preferredLocation,
25
+ sources: options.source === "all"
26
+ ? (config.sources ?? ["advisor", "custom"])
27
+ : [options.source],
22
28
  timespanDays: Number.parseInt(options.days, 10) || config.timespanDays,
23
29
  verbose: verbose ?? false,
24
30
  };
25
- // Run analysis
26
- await azure.analyzeAzureResources(finalConfig, options.format);
31
+ // Run analysis showing a progress spinner on stderr so the CLI doesn't
32
+ // look frozen during the (potentially several-minute) Azure round-trips.
33
+ const reports = await oraPromise(azure.analyzeAzureResources(finalConfig), {
34
+ failText: "Analysis failed",
35
+ stream: process.stderr,
36
+ successText: "Analysis complete",
37
+ text: "Analyzing Azure resources",
38
+ });
39
+ await azure.generateReport(reports, options.format);
27
40
  }
28
41
  catch (error) {
29
42
  exitWithError(this)(error instanceof Error
@@ -31,12 +44,24 @@ export const makeSavemoneyCommand = () => new Command("savemoney")
31
44
  : new Error(`Analysis failed: ${String(error)}`));
32
45
  }
33
46
  });
47
+ const SourceSchema = z.enum(["advisor", "all", "custom"]);
48
+ /**
49
+ * Parses the `--source` option via a zod enum, accepting `all`, `advisor`,
50
+ * or `custom` and rejecting any other value with a Commander-friendly error.
51
+ */
52
+ export function parseSourceOption(value) {
53
+ const result = SourceSchema.safeParse(value);
54
+ if (!result.success) {
55
+ throw new InvalidArgumentError(`Allowed values are: ${SourceSchema.options.join(", ")}`);
56
+ }
57
+ return result.data;
58
+ }
34
59
  /**
35
60
  * Parses an array of "key=value" strings (from commander variadic option) into a Map<string, string>.
36
61
  * Returns an empty Map when the option is not provided or empty.
37
62
  * Supports values that contain "=" (only the first "=" is treated as separator).
38
63
  */
39
- function parseTagsOption(tagsOption) {
64
+ export function parseTagsOption(tagsOption) {
40
65
  const result = new Map();
41
66
  if (!tagsOption || tagsOption.length === 0) {
42
67
  return result;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Spec Command
3
+ *
4
+ * Implements the `dx spec` subcommand. Calls the injected `getSpec` factory
5
+ * and writes the result as pretty-printed JSON to stdout. The command has no
6
+ * dependencies and requires no authentication.
7
+ */
8
+ import { Command } from "commander";
9
+ import type { CliSpec } from "../../../domain/spec.js";
10
+ export declare const makeSpecCommand: (getSpec: () => CliSpec) => Command;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Spec Command
3
+ *
4
+ * Implements the `dx spec` subcommand. Calls the injected `getSpec` factory
5
+ * and writes the result as pretty-printed JSON to stdout. The command has no
6
+ * dependencies and requires no authentication.
7
+ */
8
+ import { Command } from "commander";
9
+ export const makeSpecCommand = (getSpec) => new Command("spec")
10
+ .description("Print the full CLI spec as JSON (commands, subcommands, flags, arguments). Useful for agents that need to understand the CLI without running --help on every command.")
11
+ .action(() => {
12
+ process.stdout.write(JSON.stringify(getSpec(), null, 2) + "\n");
13
+ });
@@ -5,7 +5,9 @@ import { makeDoctorCommand } from "./commands/doctor.js";
5
5
  import { makeInfoCommand } from "./commands/info.js";
6
6
  import { makeInitCommand } from "./commands/init.js";
7
7
  import { makeSavemoneyCommand } from "./commands/savemoney.js";
8
+ import { makeSpecCommand } from "./commands/spec.js";
8
9
  import { formatErrorDetailed, toErrorMessage } from "./error-reporting.js";
10
+ import { extractCliSpec } from "./spec.js";
9
11
  /**
10
12
  * Returns true when the global `--verbose` flag is active on the closest
11
13
  * ancestor command that defines it (the root `dx` program in our CLI).
@@ -23,10 +25,12 @@ export const makeCli = (deps, config, cliDeps, version) => {
23
25
  .default("text"));
24
26
  program.addCommand(makeDoctorCommand(deps, config));
25
27
  program.addCommand(makeCodemodCommand(cliDeps));
26
- program.addCommand(makeInitCommand(deps));
28
+ program.addCommand(makeInitCommand(deps.requireGitHubAuth));
27
29
  program.addCommand(makeSavemoneyCommand());
28
30
  program.addCommand(makeInfoCommand(deps));
29
- program.addCommand(makeAddCommand(deps));
31
+ program.addCommand(makeAddCommand(deps.requireGitHubAuth));
32
+ // spec is registered last so the closure captures the complete command tree.
33
+ program.addCommand(makeSpecCommand(() => extractCliSpec(program, version)));
30
34
  return program;
31
35
  };
32
36
  /**
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Commander Spec Adapter
3
+ *
4
+ * Maps a Commander `Command` tree to the technology-agnostic `CliSpec` domain
5
+ * type defined in `domain/spec.ts`. This is the only place in the codebase
6
+ * that depends on Commander's introspection API.
7
+ */
8
+ import { Command } from "commander";
9
+ import type { CliSpec } from "../../domain/spec.js";
10
+ /**
11
+ * Extracts the full CLI spec from a fully-constructed Commander `Command` tree.
12
+ *
13
+ * Should be called after all subcommands have been registered on `rootCommand`
14
+ * so the spec reflects the complete command surface.
15
+ */
16
+ export declare const extractCliSpec: (rootCommand: Command, version: string) => CliSpec;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Commander Spec Adapter
3
+ *
4
+ * Maps a Commander `Command` tree to the technology-agnostic `CliSpec` domain
5
+ * type defined in `domain/spec.ts`. This is the only place in the codebase
6
+ * that depends on Commander's introspection API.
7
+ */
8
+ const INTERNAL_FLAGS = new Set(["--help", "--version"]);
9
+ const INTERNAL_ROOT_COMMANDS = new Set(["spec"]);
10
+ const mapOption = (opt) => ({
11
+ choices: opt.argChoices ?? [],
12
+ defaultValue: opt.defaultValue,
13
+ description: opt.description,
14
+ flags: opt.flags,
15
+ long: opt.long ?? undefined,
16
+ optional: opt.optional,
17
+ required: opt.required,
18
+ short: opt.short ?? undefined,
19
+ });
20
+ const mapArgument = (arg) => ({
21
+ choices: arg.argChoices ?? [],
22
+ defaultValue: arg.defaultValue,
23
+ description: arg.description,
24
+ name: arg.name(),
25
+ required: arg.required,
26
+ variadic: arg.variadic,
27
+ });
28
+ const mapCommand = (cmd) => ({
29
+ arguments: cmd.registeredArguments.map(mapArgument),
30
+ commands: cmd.commands.map(mapCommand),
31
+ description: cmd.description(),
32
+ name: cmd.name(),
33
+ options: cmd.options
34
+ .filter((opt) => !INTERNAL_FLAGS.has(opt.long ?? ""))
35
+ .map(mapOption),
36
+ });
37
+ /**
38
+ * Extracts the full CLI spec from a fully-constructed Commander `Command` tree.
39
+ *
40
+ * Should be called after all subcommands have been registered on `rootCommand`
41
+ * so the spec reflects the complete command surface.
42
+ */
43
+ export const extractCliSpec = (rootCommand, version) => ({
44
+ commands: rootCommand.commands
45
+ .filter((cmd) => !INTERNAL_ROOT_COMMANDS.has(cmd.name()))
46
+ .map(mapCommand),
47
+ description: rootCommand.description(),
48
+ globalOptions: rootCommand.options
49
+ .filter((opt) => !INTERNAL_FLAGS.has(opt.long ?? ""))
50
+ .map(mapOption),
51
+ name: rootCommand.name(),
52
+ specVersion: "1",
53
+ version,
54
+ });
@@ -0,0 +1,2 @@
1
+ export declare const cleanupTempDir: (tmpDir: string) => Promise<void>;
2
+ export declare const readGeneratedFiles: (rootDir: string, filePaths: readonly string[]) => Promise<Record<string, string>>;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Temp directory helpers for Plop generator integration tests.
3
+ */
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+ export const cleanupTempDir = async (tmpDir) => {
7
+ await fs.rm(tmpDir, { force: true, recursive: true });
8
+ };
9
+ const readGeneratedFile = async (rootDir, filePath) => [
10
+ filePath,
11
+ await fs.readFile(path.join(rootDir, filePath), "utf-8"),
12
+ ];
13
+ export const readGeneratedFiles = async (rootDir, filePaths) => Object.fromEntries(await Promise.all(filePaths.map((filePath) => readGeneratedFile(rootDir, filePath))));
@@ -0,0 +1,213 @@
1
+ import nodePlop from "node-plop";
2
+ /**
3
+ * Contract tests for the environment generator.
4
+ *
5
+ * They generate real files in a temp directory while asserting only
6
+ * generator-specific behavior: which domain actions run for a given
7
+ * generator state and which high-value values are materialized into
8
+ * the generated infrastructure files.
9
+ */
10
+ import fs from "node:fs/promises";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
14
+ import setGetTerraformBackend from "../../../actions/get-terraform-backend.js";
15
+ import setInitCloudAccountsAction from "../../../actions/init-cloud-accounts.js";
16
+ import setProvisionTerraformBackendAction from "../../../actions/provision-terraform-backend.js";
17
+ import setEnvShortHelper from "../../../helpers/env-short.js";
18
+ import setEqHelper from "../../../helpers/eq.js";
19
+ import setResourcePrefixHelper from "../../../helpers/resource-prefix.js";
20
+ import setTerraformStateKeyHelper from "../../../helpers/terraform-state-key.js";
21
+ import { resolveTemplatesPath } from "../../../templates-path.js";
22
+ import { cleanupTempDir, readGeneratedFiles, } from "../../__tests__/temp-dir.js";
23
+ import getActions from "../actions.js";
24
+ import { PLOP_ENVIRONMENT_GENERATOR_NAME } from "../index.js";
25
+ vi.mock("../../../../terraform/fmt.js", () => ({
26
+ formatTerraformCode: vi.fn((content) => content),
27
+ }));
28
+ /**
29
+ * Register helpers and stub action types for the environment generator.
30
+ * Cloud-service action types are registered with DI mock objects.
31
+ */
32
+ const registerEnvironmentSetup = (plop, mockCloudAccountService, mockGitHubService) => {
33
+ setEnvShortHelper(plop);
34
+ setResourcePrefixHelper(plop);
35
+ setEqHelper(plop);
36
+ setTerraformStateKeyHelper(plop);
37
+ setGetTerraformBackend(plop, mockCloudAccountService);
38
+ setProvisionTerraformBackendAction(plop, mockCloudAccountService);
39
+ setInitCloudAccountsAction(plop, mockCloudAccountService, mockGitHubService);
40
+ };
41
+ const mockTerraformBackend = {
42
+ resourceGroupName: "dx-d-itn-tf-rg",
43
+ storageAccountName: "dxditntfst",
44
+ subscriptionId: "00000000-0000-0000-0000-000000000000",
45
+ type: "azurerm",
46
+ };
47
+ const createMockCloudAccountService = (backend, isInitialized) => ({
48
+ getTerraformBackend: vi.fn().mockResolvedValue(backend),
49
+ hasUserPermissionToInitialize: vi.fn().mockResolvedValue(true),
50
+ initialize: vi.fn().mockResolvedValue(undefined),
51
+ isInitialized: vi.fn().mockResolvedValue(isInitialized),
52
+ provisionTerraformBackend: vi.fn().mockResolvedValue(backend),
53
+ });
54
+ const createMockGitHubService = () => ({
55
+ createBranch: vi.fn().mockResolvedValue(undefined),
56
+ createOrUpdateEnvironmentSecret: vi.fn().mockResolvedValue(undefined),
57
+ createPullRequest: vi.fn().mockResolvedValue(undefined),
58
+ getFileContent: vi.fn().mockResolvedValue(undefined),
59
+ getRepository: vi.fn().mockResolvedValue(undefined),
60
+ updateFile: vi.fn().mockResolvedValue(undefined),
61
+ });
62
+ const runEnvironmentGenerator = async ({ mockCloudAccountService, mockGitHubService, payload, tmpDirPrefix, }) => {
63
+ const originalCwd = process.cwd();
64
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), tmpDirPrefix));
65
+ process.chdir(tmpDir);
66
+ const plop = await nodePlop();
67
+ registerEnvironmentSetup(plop, mockCloudAccountService, mockGitHubService);
68
+ plop.setGenerator(PLOP_ENVIRONMENT_GENERATOR_NAME, {
69
+ actions: getActions(resolveTemplatesPath("environment")),
70
+ description: "Generate a new deployment environment",
71
+ prompts: [],
72
+ });
73
+ const generator = plop.getGenerator(PLOP_ENVIRONMENT_GENERATOR_NAME);
74
+ const result = await generator.runActions(payload);
75
+ const realFailures = result.failures.filter((f) => f.error !== "Aborted due to previous action failure");
76
+ if (realFailures.length > 0) {
77
+ const summary = realFailures.map((f) => `${f.type}: ${f.error}`).join("\n");
78
+ throw new Error(`Generator failed:\n${summary}`);
79
+ }
80
+ return { originalCwd, tmpDir };
81
+ };
82
+ describe("environment generator — file generation (no init)", () => {
83
+ let tmpDir;
84
+ let originalCwd;
85
+ const payload = {
86
+ env: {
87
+ cloudAccounts: [
88
+ {
89
+ csp: "azure",
90
+ defaultLocation: "italynorth",
91
+ displayName: "DEV-DX",
92
+ id: "sub-dev-123",
93
+ },
94
+ ],
95
+ name: "dev",
96
+ prefix: "dx",
97
+ },
98
+ github: {
99
+ owner: "pagopa",
100
+ repo: "my-project",
101
+ },
102
+ tags: {
103
+ BusinessUnit: "Platform",
104
+ CostCenter: "TS000",
105
+ ManagementTeam: "Engineering",
106
+ },
107
+ workspace: {
108
+ domain: "payments",
109
+ },
110
+ };
111
+ const mockCloudAccountService = createMockCloudAccountService(mockTerraformBackend, true);
112
+ const mockGitHubService = createMockGitHubService();
113
+ beforeAll(async () => {
114
+ ({ originalCwd, tmpDir } = await runEnvironmentGenerator({
115
+ mockCloudAccountService,
116
+ mockGitHubService,
117
+ payload,
118
+ tmpDirPrefix: "dx-cli-env-test-",
119
+ }));
120
+ });
121
+ afterAll(async () => {
122
+ process.chdir(originalCwd);
123
+ await cleanupTempDir(tmpDir);
124
+ });
125
+ it("materializes bootstrapper files from payload and backend state", async () => {
126
+ const generatedFiles = await readGeneratedFiles(tmpDir, [
127
+ `.github/workflows/_release-terraform-apply-bootstrapper-${payload.env.name}.yaml`,
128
+ `infra/bootstrapper/${payload.env.name}/main.tf`,
129
+ `infra/bootstrapper/${payload.env.name}/providers.tf`,
130
+ `infra/bootstrapper/${payload.env.name}/backend.tf`,
131
+ `infra/bootstrapper/${payload.env.name}/locals.tf`,
132
+ ]);
133
+ expect(generatedFiles).toMatchSnapshot();
134
+ });
135
+ it("skips init-only side effects and core files when init is absent", async () => {
136
+ const corePath = path.join(tmpDir, "infra", "core", payload.env.name);
137
+ expect(mockCloudAccountService.getTerraformBackend).toHaveBeenCalledWith(payload.env.cloudAccounts[0].id, payload.env);
138
+ expect(mockCloudAccountService.initialize).not.toHaveBeenCalled();
139
+ expect(mockCloudAccountService.provisionTerraformBackend).not.toHaveBeenCalled();
140
+ await expect(fs.stat(corePath)).rejects.toThrow();
141
+ });
142
+ });
143
+ describe("environment generator — file generation (with init)", () => {
144
+ let tmpDir;
145
+ let originalCwd;
146
+ const cloudAccount = {
147
+ csp: "azure",
148
+ defaultLocation: "italynorth",
149
+ displayName: "DEV-DX",
150
+ id: "sub-dev-123",
151
+ };
152
+ const payload = {
153
+ env: {
154
+ cloudAccounts: [cloudAccount],
155
+ name: "dev",
156
+ prefix: "dx",
157
+ },
158
+ github: {
159
+ owner: "pagopa",
160
+ repo: "my-project",
161
+ },
162
+ init: {
163
+ cloudAccountsToInitialize: [cloudAccount],
164
+ runnerAppCredentials: {
165
+ clientId: "test-app-client-id",
166
+ id: "test-app-id",
167
+ installationId: "test-installation-id",
168
+ key: "test-private-key",
169
+ },
170
+ terraformBackend: {
171
+ cloudAccount,
172
+ },
173
+ },
174
+ tags: {
175
+ BusinessUnit: "Platform",
176
+ CostCenter: "TS000",
177
+ ManagementTeam: "Engineering",
178
+ },
179
+ workspace: {
180
+ domain: "payments",
181
+ },
182
+ };
183
+ const mockCloudAccountService = createMockCloudAccountService(mockTerraformBackend, false);
184
+ const mockGitHubService = createMockGitHubService();
185
+ beforeAll(async () => {
186
+ ({ originalCwd, tmpDir } = await runEnvironmentGenerator({
187
+ mockCloudAccountService,
188
+ mockGitHubService,
189
+ payload,
190
+ tmpDirPrefix: "dx-cli-env-init-test-",
191
+ }));
192
+ });
193
+ afterAll(async () => {
194
+ process.chdir(originalCwd);
195
+ await cleanupTempDir(tmpDir);
196
+ });
197
+ it("runs init-specific actions when init is provided", () => {
198
+ expect(mockCloudAccountService.initialize).toHaveBeenCalledWith(cloudAccount, payload.env,
199
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
200
+ payload.init.runnerAppCredentials, payload.github, mockGitHubService, payload.tags);
201
+ expect(mockCloudAccountService.provisionTerraformBackend).toHaveBeenCalledWith(cloudAccount, payload.env, payload.tags);
202
+ expect(mockCloudAccountService.getTerraformBackend).not.toHaveBeenCalled();
203
+ });
204
+ it("materializes init-specific infrastructure files", async () => {
205
+ const generatedFiles = await readGeneratedFiles(tmpDir, [
206
+ `infra/core/${payload.env.name}/main.tf`,
207
+ `infra/core/${payload.env.name}/providers.tf`,
208
+ `infra/core/${payload.env.name}/backend.tf`,
209
+ `infra/bootstrapper/${payload.env.name}/main.tf`,
210
+ ]);
211
+ expect(generatedFiles).toMatchSnapshot();
212
+ });
213
+ });
@@ -1,5 +1,5 @@
1
1
  import { getLogger } from "@logtape/logtape";
2
- import * as path from "node:path";
2
+ import path from "node:path";
3
3
  import { formatTerraformCode } from "../../../terraform/fmt.js";
4
4
  import { terraformStateKey } from "../../helpers/terraform-state-key.js";
5
5
  import { payloadSchema } from "./prompts.js";
@@ -11,7 +11,7 @@ const addModule = (context, templatesPath, init = false) => {
11
11
  return (name) => [
12
12
  {
13
13
  base: templatesPath,
14
- data: { cloudAccountsByCsp, includesProdIO, init },
14
+ data: { cloudAccountsByCsp, includesProdIO, init: init || undefined },
15
15
  destination: path.join(cwd, "infra"),
16
16
  force: true,
17
17
  templateFiles: path.join(templatesPath, name),
@@ -23,7 +23,7 @@ const addModule = (context, templatesPath, init = false) => {
23
23
  base: path.join(templatesPath, "shared"),
24
24
  data: {
25
25
  cloudAccountsByCsp,
26
- init,
26
+ init: init || undefined,
27
27
  terraformBackendKey: terraformStateKey(context, name),
28
28
  },
29
29
  destination: path.join(cwd, "infra", name, "{{env.name}}"),
@@ -0,0 +1,79 @@
1
+ import nodePlop from "node-plop";
2
+ /**
3
+ * Contract tests for the monorepo generator.
4
+ *
5
+ * They generate a real repository in a temp directory, but they only
6
+ * assert the generator-specific contract: payload interpolation,
7
+ * injected action outputs, and post-processing we own. They
8
+ * intentionally avoid asserting generic Plop copy/render behavior.
9
+ */
10
+ import * as fs from "node:fs/promises";
11
+ import * as os from "node:os";
12
+ import * as path from "node:path";
13
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
14
+ import { resolveTemplatesPath } from "../../../templates-path.js";
15
+ import { cleanupTempDir, readGeneratedFiles, } from "../../__tests__/temp-dir.js";
16
+ import getActions from "../actions.js";
17
+ import { PLOP_MONOREPO_GENERATOR_NAME } from "../index.js";
18
+ /**
19
+ * Register stub versions of the custom action types that would normally
20
+ * call external services (GitHub API, Node.js releases, shell commands).
21
+ */
22
+ const registerStubActions = (plop) => {
23
+ plop.setActionType("getNodeVersion", async (data) => {
24
+ data.nodeVersion = "22.14.0";
25
+ return "Fetched latest version: 22.14.0";
26
+ });
27
+ plop.setActionType("fetchGithubRelease", async (data, ctx) => {
28
+ const resultKey = ctx.resultKey;
29
+ data[resultKey] = "1.11.0";
30
+ return `Fetched latest version: 1.11.0`;
31
+ });
32
+ plop.setActionType("setupPnpm", async () => "Monorepo bootstrapped");
33
+ };
34
+ describe("monorepo generator — file generation", () => {
35
+ let tmpDir;
36
+ let originalCwd;
37
+ const payload = {
38
+ repoDescription: "A test repository for DX",
39
+ repoName: "my-test-repo",
40
+ repoOwner: "pagopa",
41
+ };
42
+ beforeAll(async () => {
43
+ originalCwd = process.cwd();
44
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "dx-cli-monorepo-test-"));
45
+ process.chdir(tmpDir);
46
+ const plop = await nodePlop();
47
+ registerStubActions(plop);
48
+ plop.setGenerator(PLOP_MONOREPO_GENERATOR_NAME, {
49
+ actions: getActions(resolveTemplatesPath("monorepo")),
50
+ description: "A scaffold for a monorepo repository",
51
+ prompts: [],
52
+ });
53
+ const generator = plop.getGenerator(PLOP_MONOREPO_GENERATOR_NAME);
54
+ const result = await generator.runActions(payload);
55
+ const realFailures = result.failures.filter((f) => f.error !== "Aborted due to previous action failure");
56
+ if (realFailures.length > 0) {
57
+ const summary = realFailures
58
+ .map((f) => `${f.type}: ${f.error}`)
59
+ .join("\n");
60
+ throw new Error(`Generator failed:\n${summary}`);
61
+ }
62
+ });
63
+ afterAll(async () => {
64
+ process.chdir(originalCwd);
65
+ await cleanupTempDir(tmpDir);
66
+ });
67
+ it("materializes repository metadata from the generator payload", async () => {
68
+ const generatedFiles = await readGeneratedFiles(path.join(tmpDir, payload.repoName), ["package.json"]);
69
+ expect(generatedFiles).toMatchSnapshot();
70
+ });
71
+ it("propagates action outputs into generated version files", async () => {
72
+ const generatedFiles = await readGeneratedFiles(path.join(tmpDir, payload.repoName), [".node-version", ".terraform-version", ".pre-commit-config.yaml"]);
73
+ expect(generatedFiles).toMatchSnapshot();
74
+ });
75
+ it("applies the repository-specific gitignore customization", async () => {
76
+ const generatedFiles = await readGeneratedFiles(path.join(tmpDir, payload.repoName), [".gitignore"]);
77
+ expect(generatedFiles).toMatchSnapshot();
78
+ });
79
+ });
@@ -1,7 +1,6 @@
1
1
  import { AzureCliCredential } from "@azure/identity";
2
2
  import { getLogger } from "@logtape/logtape";
3
3
  import nodePlop from "node-plop";
4
- import path from "node:path";
5
4
  import { Octokit } from "octokit";
6
5
  import { oraPromise } from "ora";
7
6
  import { RepositoryNotFoundError } from "../../domain/github.js";
@@ -9,10 +8,10 @@ import { AzureSubscriptionRepository } from "../azure/cloud-account-repository.j
9
8
  import { AzureCloudAccountService } from "../azure/cloud-account-service.js";
10
9
  import createDeploymentEnvironmentGenerator, { payloadSchema as environmentPayloadSchema, PLOP_ENVIRONMENT_GENERATOR_NAME, } from "../plop/generators/environment/index.js";
11
10
  import createMonorepoGenerator, { payloadSchema as monorepoPayloadSchema, PLOP_MONOREPO_GENERATOR_NAME, } from "../plop/generators/monorepo/index.js";
11
+ import { resolveTemplatesPath } from "./templates-path.js";
12
12
  export const setMonorepoGenerator = (plop) => {
13
13
  const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
14
- const templatesPath = path.join(import.meta.dirname, "../../../templates/monorepo");
15
- createMonorepoGenerator(plop, templatesPath, octokit);
14
+ createMonorepoGenerator(plop, resolveTemplatesPath("monorepo"), octokit);
16
15
  };
17
16
  const validatePayload = async (payload, github) => {
18
17
  try {
@@ -94,6 +93,5 @@ export const setDeploymentEnvironmentGenerator = (plop, gitHubService, github) =
94
93
  const credential = new AzureCliCredential();
95
94
  const cloudAccountRepository = new AzureSubscriptionRepository(credential);
96
95
  const cloudAccountService = new AzureCloudAccountService(credential);
97
- const templatesPath = path.join(import.meta.dirname, "../../../templates/environment");
98
- createDeploymentEnvironmentGenerator(plop, templatesPath, cloudAccountRepository, cloudAccountService, gitHubService, github);
96
+ createDeploymentEnvironmentGenerator(plop, resolveTemplatesPath("environment"), cloudAccountRepository, cloudAccountService, gitHubService, github);
99
97
  };
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Resolves CLI plop templates from a single place so runtime wiring and tests
3
+ * stay aligned with the package layout.
4
+ */
5
+ export declare const resolveTemplatesPath: (generatorName: "environment" | "monorepo") => string;
@@ -0,0 +1,6 @@
1
+ import path from "node:path";
2
+ /**
3
+ * Resolves CLI plop templates from a single place so runtime wiring and tests
4
+ * stay aligned with the package layout.
5
+ */
6
+ export const resolveTemplatesPath = (generatorName) => path.join(import.meta.dirname, "../../../templates", generatorName);
@@ -1,18 +1,14 @@
1
- import { Octokit } from "octokit";
2
- import { DeepMockProxy, MockProxy } from "vitest-mock-extended";
1
+ import type { MockProxy } from "vitest-mock-extended";
3
2
  import { Config } from "../../config.js";
4
- import { AuthorizationService } from "../authorization.js";
5
- import { GitHubService } from "../github.js";
3
+ import { GitHubAuthFactory } from "../dependencies.js";
6
4
  import { PackageJson, PackageJsonReader } from "../package-json.js";
7
5
  import { RepositoryReader } from "../repository.js";
8
6
  import { ValidationReporter } from "../validation.js";
9
7
  export declare const makeMockPackageJson: (overrides?: Partial<PackageJson>) => PackageJson;
10
8
  export declare const makeMockDependencies: () => {
11
- authorizationService: MockProxy<AuthorizationService>;
12
- gitHubService: MockProxy<GitHubService>;
13
- octokit: DeepMockProxy<Octokit>;
14
9
  packageJsonReader: MockProxy<PackageJsonReader>;
15
10
  repositoryReader: MockProxy<RepositoryReader>;
11
+ requireGitHubAuth: GitHubAuthFactory;
16
12
  validationReporter: MockProxy<ValidationReporter>;
17
13
  };
18
14
  export declare const makeMockConfig: () => Config;
@@ -1,4 +1,4 @@
1
- import { mock, mockDeep } from "vitest-mock-extended";
1
+ import { mock } from "vitest-mock-extended";
2
2
  export const makeMockPackageJson = (overrides = {}) => {
3
3
  const basePackageJson = {
4
4
  dependencies: new Map(),
@@ -13,11 +13,9 @@ export const makeMockPackageJson = (overrides = {}) => {
13
13
  };
14
14
  };
15
15
  export const makeMockDependencies = () => ({
16
- authorizationService: mock(),
17
- gitHubService: mock(),
18
- octokit: mockDeep(),
19
16
  packageJsonReader: mock(),
20
17
  repositoryReader: mock(),
18
+ requireGitHubAuth: mock(),
21
19
  validationReporter: mock(),
22
20
  });
23
21
  export const makeMockConfig = () => ({
@@ -1,12 +1,25 @@
1
+ import { ResultAsync } from "neverthrow";
1
2
  import { AuthorizationService } from "./authorization.js";
2
3
  import { GitHubService } from "./github.js";
3
4
  import { PackageJsonReader } from "./package-json.js";
4
5
  import { RepositoryReader } from "./repository.js";
5
6
  import { ValidationReporter } from "./validation.js";
6
7
  export type Dependencies = {
7
- authorizationService: AuthorizationService;
8
- gitHubService: GitHubService;
9
8
  packageJsonReader: PackageJsonReader;
10
9
  repositoryReader: RepositoryReader;
10
+ requireGitHubAuth: GitHubAuthFactory;
11
11
  validationReporter: ValidationReporter;
12
12
  };
13
+ /** Services that require a GitHub PAT to be instantiated. */
14
+ export type GitHubAuthDeps = {
15
+ authorizationService: AuthorizationService;
16
+ gitHubService: GitHubService;
17
+ };
18
+ /**
19
+ * Lazily resolves GitHub-authenticated services.
20
+ * Commands that need GitHub access call this factory inside their action
21
+ * handler so that auth is only required when those commands actually run.
22
+ * Returns a `ResultAsync` so auth failures (e.g. missing GH_TOKEN) can be
23
+ * routed through the same neverthrow error pipeline used by the commands.
24
+ */
25
+ export type GitHubAuthFactory = () => ResultAsync<GitHubAuthDeps, Error>;