@pagopa/dx-cli 0.22.3 → 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 (49) 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/presenters/__tests__/index.test.d.ts +1 -0
  20. package/dist/adapters/commander/presenters/__tests__/index.test.js +23 -0
  21. package/dist/adapters/commander/presenters/__tests__/json.test.d.ts +1 -0
  22. package/dist/adapters/commander/presenters/__tests__/json.test.js +108 -0
  23. package/dist/adapters/commander/presenters/__tests__/text.test.d.ts +1 -0
  24. package/dist/adapters/commander/presenters/__tests__/text.test.js +60 -0
  25. package/dist/adapters/commander/presenters/index.d.ts +27 -0
  26. package/dist/adapters/commander/presenters/index.js +16 -0
  27. package/dist/adapters/commander/presenters/json-command-presenter.d.ts +19 -0
  28. package/dist/adapters/commander/presenters/json-command-presenter.js +26 -0
  29. package/dist/adapters/commander/presenters/text-command-presenter.d.ts +6 -0
  30. package/dist/adapters/commander/presenters/text-command-presenter.js +21 -0
  31. package/dist/adapters/commander/spec.d.ts +16 -0
  32. package/dist/adapters/commander/spec.js +54 -0
  33. package/dist/adapters/plop/generators/__tests__/temp-dir.d.ts +2 -0
  34. package/dist/adapters/plop/generators/__tests__/temp-dir.js +13 -0
  35. package/dist/adapters/plop/generators/environment/__tests__/generation.test.d.ts +1 -0
  36. package/dist/adapters/plop/generators/environment/__tests__/generation.test.js +213 -0
  37. package/dist/adapters/plop/generators/environment/actions.js +3 -3
  38. package/dist/adapters/plop/generators/monorepo/__tests__/generation.test.d.ts +1 -0
  39. package/dist/adapters/plop/generators/monorepo/__tests__/generation.test.js +79 -0
  40. package/dist/adapters/plop/index.js +3 -5
  41. package/dist/adapters/plop/templates-path.d.ts +5 -0
  42. package/dist/adapters/plop/templates-path.js +6 -0
  43. package/dist/domain/__tests__/data.d.ts +3 -7
  44. package/dist/domain/__tests__/data.js +2 -4
  45. package/dist/domain/dependencies.d.ts +15 -2
  46. package/dist/domain/spec.d.ts +50 -0
  47. package/dist/domain/spec.js +8 -0
  48. package/dist/index.js +15 -12
  49. package/package.json +2 -2
@@ -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>;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Spec Domain
3
+ *
4
+ * Pure types that describe the shape of the CLI spec output.
5
+ * Technology-agnostic: no dependency on Commander or any CLI framework.
6
+ * Consumed by the Commander adapter and by agent tooling that parses `dx spec`.
7
+ */
8
+ export type ArgumentSpec = {
9
+ /** Allowed values when the argument is constrained to a set */
10
+ choices: string[];
11
+ defaultValue: unknown;
12
+ description: string;
13
+ name: string;
14
+ required: boolean;
15
+ variadic: boolean;
16
+ };
17
+ export type CliSpec = {
18
+ /** Top-level subcommands with their own options, arguments, and nested commands. */
19
+ commands: CommandSpec[];
20
+ description: string;
21
+ /** Options defined directly on the root `dx` program (global flags). */
22
+ globalOptions: OptionSpec[];
23
+ name: string;
24
+ /** Stable version of the spec JSON shape. Increment on breaking changes. */
25
+ specVersion: "1";
26
+ version: string;
27
+ };
28
+ export type CommandSpec = {
29
+ arguments: ArgumentSpec[];
30
+ commands: CommandSpec[];
31
+ description: string;
32
+ name: string;
33
+ options: OptionSpec[];
34
+ };
35
+ export type OptionSpec = {
36
+ /** Allowed values when the option is an enum */
37
+ choices: string[];
38
+ defaultValue: unknown;
39
+ description: string;
40
+ /** Raw flags string as shown in help, e.g. "-v, --verbose" */
41
+ flags: string;
42
+ /** Long flag, e.g. "--verbose" */
43
+ long: string | undefined;
44
+ /** True when the option value is optional (e.g. `--output [mode]`) */
45
+ optional: boolean;
46
+ /** True when the option value is mandatory (e.g. `--output <mode>`) */
47
+ required: boolean;
48
+ /** Short flag, e.g. "-v" (undefined when not present) */
49
+ short: string | undefined;
50
+ };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Spec Domain
3
+ *
4
+ * Pure types that describe the shape of the CLI spec output.
5
+ * Technology-agnostic: no dependency on Commander or any CLI framework.
6
+ * Consumed by the Commander adapter and by agent tooling that parses `dx spec`.
7
+ */
8
+ export {};
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import "core-js/actual/set/index.js";
2
2
  import { configure, getConsoleSink } from "@logtape/logtape";
3
- import * as assert from "node:assert/strict";
3
+ import { errAsync, okAsync, ResultAsync } from "neverthrow";
4
4
  import { Octokit } from "octokit";
5
5
  import codemodRegistry from "./adapters/codemods/index.js";
6
6
  import { makeCli } from "./adapters/commander/index.js";
@@ -13,7 +13,6 @@ import { getConfig } from "./config.js";
13
13
  import { getInfo } from "./domain/info.js";
14
14
  import { applyCodemodById } from "./use-cases/apply-codemod.js";
15
15
  import { listCodemods } from "./use-cases/list-codemods.js";
16
- import { requestAuthorization } from "./use-cases/request-authorization.js";
17
16
  /**
18
17
  * Returns `true` when `-v` or `--verbose` is present in argv.
19
18
  *
@@ -50,29 +49,33 @@ const configureLogging = async (verbose) => {
50
49
  };
51
50
  export const runCli = async (version) => {
52
51
  await configureLogging(detectVerboseFromArgv(process.argv));
53
- // Creating the adapters
54
52
  const repositoryReader = makeRepositoryReader();
55
53
  const packageJsonReader = makePackageJsonReader();
56
54
  const validationReporter = makeValidationReporter();
57
- const auth = await getGitHubPAT();
58
- assert.ok(auth, "GitHub PAT is required. Please set the GH_TOKEN environment variable or login using GitHub CLI.");
59
- const octokit = new Octokit({
60
- auth,
55
+ /**
56
+ * Lazily creates GitHub-authenticated services on first call.
57
+ * Only commands that actually need GitHub (init, add) will trigger this,
58
+ * so credential-free commands (spec, doctor, info, …) never require a PAT.
59
+ */
60
+ const requireGitHubAuth = () => ResultAsync.fromPromise(getGitHubPAT(), (cause) => new Error("Failed to read GitHub PAT", { cause })).andThen((auth) => {
61
+ if (!auth) {
62
+ return errAsync(new Error("GitHub PAT is required. Please set the GH_TOKEN environment variable or login using GitHub CLI."));
63
+ }
64
+ const octokit = new Octokit({ auth });
65
+ const gitHubService = new OctokitGitHubService(octokit);
66
+ const authorizationService = makeAzureAuthorizationService(gitHubService);
67
+ return okAsync({ authorizationService, gitHubService });
61
68
  });
62
- const gitHubService = new OctokitGitHubService(octokit);
63
- const authorizationService = makeAzureAuthorizationService(gitHubService);
64
69
  const deps = {
65
- authorizationService,
66
- gitHubService,
67
70
  packageJsonReader,
68
71
  repositoryReader,
72
+ requireGitHubAuth,
69
73
  validationReporter,
70
74
  };
71
75
  const config = getConfig();
72
76
  const useCases = {
73
77
  applyCodemodById: applyCodemodById(codemodRegistry, getInfo(deps)),
74
78
  listCodemods: listCodemods(codemodRegistry),
75
- requestAuthorization: requestAuthorization(authorizationService),
76
79
  };
77
80
  const program = makeCli(deps, config, useCases, version);
78
81
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pagopa/dx-cli",
3
- "version": "0.22.3",
3
+ "version": "0.23.0",
4
4
  "type": "module",
5
5
  "description": "A CLI useful to manage DX tools.",
6
6
  "repository": {
@@ -48,7 +48,7 @@
48
48
  "semver": "^7.7.4",
49
49
  "yaml": "^2.8.4",
50
50
  "zod": "^4.4.2",
51
- "@pagopa/dx-savemoney": "^0.2.6"
51
+ "@pagopa/dx-savemoney": "^0.3.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@tsconfig/node24": "24.0.4",