@pagopa/dx-cli 0.21.5 → 0.22.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.
- package/dist/adapters/azure/__tests__/cloud-account-service.test.js +35 -1
- package/dist/adapters/azure/cloud-account-service.js +12 -3
- package/dist/adapters/commander/__tests__/env.test.d.ts +1 -0
- package/dist/adapters/commander/__tests__/env.test.js +43 -0
- package/dist/adapters/commander/__tests__/global-options.test.d.ts +1 -0
- package/dist/adapters/commander/__tests__/global-options.test.js +33 -0
- package/dist/adapters/commander/env.d.ts +13 -0
- package/dist/adapters/commander/env.js +17 -0
- package/dist/adapters/commander/index.d.ts +1 -0
- package/dist/adapters/commander/index.js +5 -2
- package/dist/domain/command-presenter.d.ts +26 -0
- package/dist/domain/command-presenter.js +11 -0
- package/package.json +1 -1
|
@@ -285,7 +285,7 @@ describe("initialize", () => {
|
|
|
285
285
|
principalType: "ServicePrincipal",
|
|
286
286
|
roleDefinitionId: "/subscriptions/sub-1/providers/Microsoft.Authorization/roleDefinitions/ba92f5b4-2d11-453d-a403-e96b0029c9fe",
|
|
287
287
|
}));
|
|
288
|
-
expect(mockCreateFederatedIdentityCredential).toHaveBeenCalledWith("dx-d-itn-common-rg-01", "dx-d-itn-bootstrap-id-01", "bootstrapper-dev-cd", {
|
|
288
|
+
expect(mockCreateFederatedIdentityCredential).toHaveBeenCalledWith("dx-d-itn-common-rg-01", "dx-d-itn-bootstrap-id-01", "dx-bootstrapper-dev-cd", {
|
|
289
289
|
audiences: ["api://AzureADTokenExchange"],
|
|
290
290
|
issuer: "https://token.actions.githubusercontent.com",
|
|
291
291
|
subject: "repo:pagopa/dx:environment:bootstrapper-dev-cd",
|
|
@@ -341,4 +341,38 @@ describe("initialize", () => {
|
|
|
341
341
|
secretValue: "private-key",
|
|
342
342
|
});
|
|
343
343
|
});
|
|
344
|
+
test("uses a repository-specific federated credential name", async ({ cloudAccountService, }) => {
|
|
345
|
+
const createOrUpdateEnvironmentSecret = vi
|
|
346
|
+
.fn()
|
|
347
|
+
.mockResolvedValue(undefined);
|
|
348
|
+
await cloudAccountService.initialize({
|
|
349
|
+
csp: "azure",
|
|
350
|
+
defaultLocation: "italynorth",
|
|
351
|
+
displayName: "Test subscription",
|
|
352
|
+
id: "sub-1",
|
|
353
|
+
}, {
|
|
354
|
+
name: "dev",
|
|
355
|
+
prefix: "dx",
|
|
356
|
+
}, {
|
|
357
|
+
clientId: "app-client-id",
|
|
358
|
+
id: "app-id",
|
|
359
|
+
installationId: "installation-id",
|
|
360
|
+
key: "private-key\n",
|
|
361
|
+
}, {
|
|
362
|
+
owner: "pagopa",
|
|
363
|
+
repo: "dx-playground",
|
|
364
|
+
}, {
|
|
365
|
+
createBranch: vi.fn(),
|
|
366
|
+
createOrUpdateEnvironmentSecret,
|
|
367
|
+
createPullRequest: vi.fn(),
|
|
368
|
+
getFileContent: vi.fn(),
|
|
369
|
+
getRepository: vi.fn(),
|
|
370
|
+
updateFile: vi.fn(),
|
|
371
|
+
});
|
|
372
|
+
expect(mockCreateFederatedIdentityCredential).toHaveBeenCalledWith("dx-d-itn-common-rg-01", "dx-d-itn-bootstrap-id-01", "dx-playground-bootstrapper-dev-cd", {
|
|
373
|
+
audiences: ["api://AzureADTokenExchange"],
|
|
374
|
+
issuer: "https://token.actions.githubusercontent.com",
|
|
375
|
+
subject: "repo:pagopa/dx-playground:environment:bootstrapper-dev-cd",
|
|
376
|
+
});
|
|
377
|
+
});
|
|
344
378
|
});
|
|
@@ -199,14 +199,19 @@ export class AzureCloudAccountService {
|
|
|
199
199
|
})));
|
|
200
200
|
logger.debug("Assigned bootstrap roles to identity {identityName} in subscription {subscriptionId}", { identityName, subscriptionId: cloudAccount.id });
|
|
201
201
|
const githubEnvironmentName = `bootstrapper-${name}-cd`;
|
|
202
|
-
|
|
203
|
-
|
|
202
|
+
const federatedCredentialName = this.#createFederatedCredentialName({
|
|
203
|
+
github,
|
|
204
|
+
githubEnvironmentName,
|
|
205
|
+
});
|
|
206
|
+
// Include a repository-derived suffix so different repositories can share
|
|
207
|
+
// the same bootstrap identity without overwriting each other's OIDC trust.
|
|
208
|
+
await msiClient.federatedIdentityCredentials.createOrUpdate(resourceGroupName, identityName, federatedCredentialName, {
|
|
204
209
|
audiences: ["api://AzureADTokenExchange"],
|
|
205
210
|
issuer: "https://token.actions.githubusercontent.com",
|
|
206
211
|
subject: `repo:${github.owner}/${github.repo}:environment:${githubEnvironmentName}`,
|
|
207
212
|
});
|
|
208
213
|
logger.debug("Configured federated identity credential {credentialName} for identity {identityName} in subscription {subscriptionId}", {
|
|
209
|
-
credentialName:
|
|
214
|
+
credentialName: federatedCredentialName,
|
|
210
215
|
identityName,
|
|
211
216
|
subscriptionId: cloudAccount.id,
|
|
212
217
|
});
|
|
@@ -374,6 +379,10 @@ export class AzureCloudAccountService {
|
|
|
374
379
|
logger.debug("Created key vault {keyVaultName} in subscription {subscriptionId}", { keyVaultName, subscriptionId: cloudAccount.id });
|
|
375
380
|
return keyVaultName;
|
|
376
381
|
}
|
|
382
|
+
#createFederatedCredentialName({ github, githubEnvironmentName, }) {
|
|
383
|
+
const repoName = github.repo.replaceAll(/[^a-zA-Z0-9-]/g, "-");
|
|
384
|
+
return `${repoName}-${githubEnvironmentName}`;
|
|
385
|
+
}
|
|
377
386
|
#createRoleAssignmentName(scope, principalId, roleDefinitionId) {
|
|
378
387
|
const hash = createHash("sha256")
|
|
379
388
|
.update(`${scope}:${principalId}:${roleDefinitionId}`)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the CLI environment schema.
|
|
3
|
+
* Validates that `cliEnvSchema` correctly parses `process.env`.
|
|
4
|
+
*/
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { cliEnvSchema } from "../env.js";
|
|
7
|
+
describe("cliEnvSchema", () => {
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
vi.unstubAllEnvs();
|
|
10
|
+
});
|
|
11
|
+
describe("CI field", () => {
|
|
12
|
+
it("is undefined when CI is not set", () => {
|
|
13
|
+
vi.stubEnv("CI", undefined);
|
|
14
|
+
const result = cliEnvSchema.safeParse(process.env);
|
|
15
|
+
expect(result.success).toBe(true);
|
|
16
|
+
expect(result.success && result.data.CI).toBeUndefined();
|
|
17
|
+
});
|
|
18
|
+
it("captures any non-empty CI value", () => {
|
|
19
|
+
vi.stubEnv("CI", "true");
|
|
20
|
+
const result = cliEnvSchema.safeParse(process.env);
|
|
21
|
+
expect(result.success).toBe(true);
|
|
22
|
+
expect(result.success && result.data.CI).toBe("true");
|
|
23
|
+
});
|
|
24
|
+
it("captures CI=false as a string (presence is the signal, not the value)", () => {
|
|
25
|
+
vi.stubEnv("CI", "false");
|
|
26
|
+
const result = cliEnvSchema.safeParse(process.env);
|
|
27
|
+
expect(result.success).toBe(true);
|
|
28
|
+
expect(result.success && result.data.CI).toBe("false");
|
|
29
|
+
});
|
|
30
|
+
it("captures CI=1 for numeric-style env vars", () => {
|
|
31
|
+
vi.stubEnv("CI", "1");
|
|
32
|
+
const result = cliEnvSchema.safeParse(process.env);
|
|
33
|
+
expect(result.success).toBe(true);
|
|
34
|
+
expect(result.success && result.data.CI).toBe("1");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
it("passes through an object with unrelated env keys without failing", () => {
|
|
38
|
+
vi.stubEnv("CI", "true");
|
|
39
|
+
const result = cliEnvSchema.safeParse(process.env);
|
|
40
|
+
expect(result.success).toBe(true);
|
|
41
|
+
expect(result.success && result.data.CI).toBe("true");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { mock } from "vitest-mock-extended";
|
|
3
|
+
import { makeCli } from "../index.js";
|
|
4
|
+
const makeProgram = (argv) => {
|
|
5
|
+
const program = makeCli(mock(), mock(), mock(), "0.0.0");
|
|
6
|
+
program.exitOverride().configureOutput({
|
|
7
|
+
writeErr: () => {
|
|
8
|
+
/* silence stderr in tests */
|
|
9
|
+
},
|
|
10
|
+
writeOut: () => {
|
|
11
|
+
/* silence stdout in tests */
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
program.parseOptions(argv);
|
|
15
|
+
return program;
|
|
16
|
+
};
|
|
17
|
+
describe("--output option", () => {
|
|
18
|
+
it("defaults to 'text' when not provided", () => {
|
|
19
|
+
const program = makeProgram([]);
|
|
20
|
+
expect(program.opts().output).toBe("text");
|
|
21
|
+
});
|
|
22
|
+
it("accepts 'text' explicitly", () => {
|
|
23
|
+
const program = makeProgram(["--output", "text"]);
|
|
24
|
+
expect(program.opts().output).toBe("text");
|
|
25
|
+
});
|
|
26
|
+
it("accepts 'json'", () => {
|
|
27
|
+
const program = makeProgram(["--output", "json"]);
|
|
28
|
+
expect(program.opts().output).toBe("json");
|
|
29
|
+
});
|
|
30
|
+
it("rejects invalid values", () => {
|
|
31
|
+
expect(() => makeProgram(["--output", "xml"])).toThrow();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod schema for the CLI environment variables.
|
|
3
|
+
*
|
|
4
|
+
* Intended to centralize env parsing at the entrypoint (`src/index.ts`),
|
|
5
|
+
* which will parse the raw environment once and pass the validated result as
|
|
6
|
+
* `CliEnv` to every command. This keeps env-var reads out of use-cases and
|
|
7
|
+
* adapters and makes them fully testable.
|
|
8
|
+
*/
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
export declare const cliEnvSchema: z.ZodObject<{
|
|
11
|
+
CI: z.ZodOptional<z.ZodString>;
|
|
12
|
+
}, z.core.$loose>;
|
|
13
|
+
export type CliEnv = z.infer<typeof cliEnvSchema>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zod schema for the CLI environment variables.
|
|
3
|
+
*
|
|
4
|
+
* Intended to centralize env parsing at the entrypoint (`src/index.ts`),
|
|
5
|
+
* which will parse the raw environment once and pass the validated result as
|
|
6
|
+
* `CliEnv` to every command. This keeps env-var reads out of use-cases and
|
|
7
|
+
* adapters and makes them fully testable.
|
|
8
|
+
*/
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
export const cliEnvSchema = z
|
|
11
|
+
.object({
|
|
12
|
+
// Standard CI marker. Presence (any string value) signals an automated
|
|
13
|
+
// pipeline where interactive prompts must not block. Follows the same
|
|
14
|
+
// convention used by `is-interactive` and `ora`.
|
|
15
|
+
CI: z.string().optional(),
|
|
16
|
+
})
|
|
17
|
+
.loose();
|
|
@@ -4,6 +4,7 @@ import { Dependencies } from "../../domain/dependencies.js";
|
|
|
4
4
|
import { CodemodCommandDependencies } from "./commands/codemod.js";
|
|
5
5
|
export type CliDependencies = CodemodCommandDependencies;
|
|
6
6
|
export type GlobalOptions = {
|
|
7
|
+
output: "json" | "text";
|
|
7
8
|
verbose?: boolean;
|
|
8
9
|
};
|
|
9
10
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Command } from "commander";
|
|
1
|
+
import { Command, Option } from "commander";
|
|
2
2
|
import { makeAddCommand } from "./commands/add.js";
|
|
3
3
|
import { makeCodemodCommand, } from "./commands/codemod.js";
|
|
4
4
|
import { makeDoctorCommand } from "./commands/doctor.js";
|
|
@@ -17,7 +17,10 @@ export const makeCli = (deps, config, cliDeps, version) => {
|
|
|
17
17
|
.name("dx")
|
|
18
18
|
.description("The CLI for DX-Platform")
|
|
19
19
|
.version(version)
|
|
20
|
-
.option("-v, --verbose", "Enable verbose output: debug-level logs and full error chain (with stack traces) when a command fails", false)
|
|
20
|
+
.option("-v, --verbose", "Enable verbose output: debug-level logs and full error chain (with stack traces) when a command fails", false)
|
|
21
|
+
.addOption(new Option("--output <mode>", "Output mode: 'text' (human-readable, default) or 'json' (structured JSON envelope on stdout, NDJSON progress events on stderr)")
|
|
22
|
+
.choices(["text", "json"])
|
|
23
|
+
.default("text"));
|
|
21
24
|
program.addCommand(makeDoctorCommand(deps, config));
|
|
22
25
|
program.addCommand(makeCodemodCommand(cliDeps));
|
|
23
26
|
program.addCommand(makeInitCommand(deps));
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CommandPresenter — domain port for all CLI output.
|
|
3
|
+
*
|
|
4
|
+
* Use-cases depend on this interface, not on any concrete output mechanism.
|
|
5
|
+
* Concrete implementations are injected at the entry point.
|
|
6
|
+
*
|
|
7
|
+
* The interface deliberately does NOT handle process exit: callers report the
|
|
8
|
+
* error through `reportError`, then let the host terminate the process with
|
|
9
|
+
* the appropriate exit code.
|
|
10
|
+
*/
|
|
11
|
+
export interface CommandPresenter {
|
|
12
|
+
/**
|
|
13
|
+
* Formats and emits the error.
|
|
14
|
+
* Does NOT exit the process — that responsibility belongs to the host
|
|
15
|
+
* after this returns.
|
|
16
|
+
*/
|
|
17
|
+
reportError(error: unknown): void;
|
|
18
|
+
/**
|
|
19
|
+
* Emits the final successful result.
|
|
20
|
+
*/
|
|
21
|
+
reportResult<T>(data: T): void;
|
|
22
|
+
/**
|
|
23
|
+
* Tracks `task` while emitting start/end lifecycle events for the named step.
|
|
24
|
+
*/
|
|
25
|
+
trackStep<T>(name: string, task: () => Promise<T>): Promise<T>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CommandPresenter — domain port for all CLI output.
|
|
3
|
+
*
|
|
4
|
+
* Use-cases depend on this interface, not on any concrete output mechanism.
|
|
5
|
+
* Concrete implementations are injected at the entry point.
|
|
6
|
+
*
|
|
7
|
+
* The interface deliberately does NOT handle process exit: callers report the
|
|
8
|
+
* error through `reportError`, then let the host terminate the process with
|
|
9
|
+
* the appropriate exit code.
|
|
10
|
+
*/
|
|
11
|
+
export {};
|