@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.
- package/README.md +18 -0
- package/dist/adapters/commander/__tests__/spec.test.d.ts +8 -0
- package/dist/adapters/commander/__tests__/spec.test.js +264 -0
- package/dist/adapters/commander/commands/__tests__/init-command.test.d.ts +4 -0
- package/dist/adapters/commander/commands/__tests__/init-command.test.js +44 -0
- package/dist/adapters/commander/commands/__tests__/savemoney.test.d.ts +8 -0
- package/dist/adapters/commander/commands/__tests__/savemoney.test.js +60 -0
- package/dist/adapters/commander/commands/__tests__/spec.test.d.ts +7 -0
- package/dist/adapters/commander/commands/__tests__/spec.test.js +85 -0
- package/dist/adapters/commander/commands/add.d.ts +2 -6
- package/dist/adapters/commander/commands/add.js +4 -8
- package/dist/adapters/commander/commands/init.d.ts +2 -6
- package/dist/adapters/commander/commands/init.js +6 -5
- package/dist/adapters/commander/commands/savemoney.d.ts +11 -0
- package/dist/adapters/commander/commands/savemoney.js +30 -5
- package/dist/adapters/commander/commands/spec.d.ts +10 -0
- package/dist/adapters/commander/commands/spec.js +13 -0
- package/dist/adapters/commander/index.js +6 -2
- package/dist/adapters/commander/presenters/__tests__/index.test.d.ts +1 -0
- package/dist/adapters/commander/presenters/__tests__/index.test.js +23 -0
- package/dist/adapters/commander/presenters/__tests__/json.test.d.ts +1 -0
- package/dist/adapters/commander/presenters/__tests__/json.test.js +108 -0
- package/dist/adapters/commander/presenters/__tests__/text.test.d.ts +1 -0
- package/dist/adapters/commander/presenters/__tests__/text.test.js +60 -0
- package/dist/adapters/commander/presenters/index.d.ts +27 -0
- package/dist/adapters/commander/presenters/index.js +16 -0
- package/dist/adapters/commander/presenters/json-command-presenter.d.ts +19 -0
- package/dist/adapters/commander/presenters/json-command-presenter.js +26 -0
- package/dist/adapters/commander/presenters/text-command-presenter.d.ts +6 -0
- package/dist/adapters/commander/presenters/text-command-presenter.js +21 -0
- package/dist/adapters/commander/spec.d.ts +16 -0
- package/dist/adapters/commander/spec.js +54 -0
- package/dist/adapters/plop/generators/__tests__/temp-dir.d.ts +2 -0
- package/dist/adapters/plop/generators/__tests__/temp-dir.js +13 -0
- package/dist/adapters/plop/generators/environment/__tests__/generation.test.d.ts +1 -0
- package/dist/adapters/plop/generators/environment/__tests__/generation.test.js +213 -0
- package/dist/adapters/plop/generators/environment/actions.js +3 -3
- package/dist/adapters/plop/generators/monorepo/__tests__/generation.test.d.ts +1 -0
- package/dist/adapters/plop/generators/monorepo/__tests__/generation.test.js +79 -0
- package/dist/adapters/plop/index.js +3 -5
- package/dist/adapters/plop/templates-path.d.ts +5 -0
- package/dist/adapters/plop/templates-path.js +6 -0
- package/dist/domain/__tests__/data.d.ts +3 -7
- package/dist/domain/__tests__/data.js +2 -4
- package/dist/domain/dependencies.d.ts +15 -2
- package/dist/domain/spec.d.ts +50 -0
- package/dist/domain/spec.js +8 -0
- package/dist/index.js +15 -12
- 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
|
|
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
|
-
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
+
}
|
|
@@ -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,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 @@
|
|
|
1
|
+
export {};
|