product-discovery-cli 0.0.13 → 0.0.15
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/package.json +2 -1
- package/src/application/RunDiscoveryFlow.js +1 -1
- package/src/application/RunDiscoveryFlow.spec.js +222 -0
- package/src/domain/DiscoverySession.spec.js +12 -0
- package/src/domain/PathNaming.spec.js +26 -0
- package/src/infrastructure/ConsolePresenter.js +25 -11
- package/src/infrastructure/i18n.js +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "product-discovery-cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.15",
|
|
4
4
|
"description": "CLI to consume the Product Discovery Agent API",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "AuronForge",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"boxen": "^5.1.2",
|
|
34
|
+
"cfonts": "^3.3.1",
|
|
34
35
|
"chalk": "^4.1.2",
|
|
35
36
|
"commander": "^11.1.0",
|
|
36
37
|
"inquirer": "^8.2.6",
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
const { RunDiscoveryFlow } = require("./RunDiscoveryFlow");
|
|
2
|
+
|
|
3
|
+
const mockI18n = {
|
|
4
|
+
t: jest.fn((key) => key)
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
describe("RunDiscoveryFlow", () => {
|
|
8
|
+
test("runs discovery, saves, and stops", async () => {
|
|
9
|
+
const prompt = {
|
|
10
|
+
askInput: jest.fn().mockResolvedValue("My idea"),
|
|
11
|
+
askYesNo: jest.fn().mockResolvedValue(false)
|
|
12
|
+
};
|
|
13
|
+
const presenter = {
|
|
14
|
+
printHeader: jest.fn(),
|
|
15
|
+
info: jest.fn(),
|
|
16
|
+
error: jest.fn(),
|
|
17
|
+
json: jest.fn(),
|
|
18
|
+
success: jest.fn(),
|
|
19
|
+
goodbye: jest.fn(),
|
|
20
|
+
spinner: jest.fn().mockReturnValue({ succeed: jest.fn(), fail: jest.fn() })
|
|
21
|
+
};
|
|
22
|
+
const apiClient = {
|
|
23
|
+
runDiscovery: jest.fn().mockResolvedValue({ name: "Test Product" })
|
|
24
|
+
};
|
|
25
|
+
const storage = {
|
|
26
|
+
saveJson: jest.fn().mockResolvedValue({ saved: true, fullPath: "/tmp/file.json" })
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const useCase = new RunDiscoveryFlow({ prompt, apiClient, storage, presenter });
|
|
30
|
+
await useCase.execute({
|
|
31
|
+
apiUrl: "http://localhost/api",
|
|
32
|
+
lang: "pt-br",
|
|
33
|
+
saveDefaults: { autoSave: true },
|
|
34
|
+
i18n: mockI18n
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
expect(presenter.printHeader).toHaveBeenCalled();
|
|
38
|
+
expect(prompt.askInput).toHaveBeenCalled();
|
|
39
|
+
expect(apiClient.runDiscovery).toHaveBeenCalledWith("My idea", "http://localhost/api", "pt-br");
|
|
40
|
+
expect(storage.saveJson).toHaveBeenCalled();
|
|
41
|
+
expect(presenter.goodbye).toHaveBeenCalled();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("asks to improve when not saving and exits on no", async () => {
|
|
45
|
+
const prompt = {
|
|
46
|
+
askInput: jest.fn().mockResolvedValue("My idea"),
|
|
47
|
+
askYesNo: jest.fn().mockResolvedValueOnce(false)
|
|
48
|
+
};
|
|
49
|
+
const presenter = {
|
|
50
|
+
printHeader: jest.fn(),
|
|
51
|
+
info: jest.fn(),
|
|
52
|
+
error: jest.fn(),
|
|
53
|
+
json: jest.fn(),
|
|
54
|
+
success: jest.fn(),
|
|
55
|
+
goodbye: jest.fn(),
|
|
56
|
+
spinner: jest.fn().mockReturnValue({ succeed: jest.fn(), fail: jest.fn() })
|
|
57
|
+
};
|
|
58
|
+
const apiClient = {
|
|
59
|
+
runDiscovery: jest.fn().mockResolvedValue({ name: "Test Product" })
|
|
60
|
+
};
|
|
61
|
+
const storage = {
|
|
62
|
+
saveJson: jest.fn()
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const useCase = new RunDiscoveryFlow({ prompt, apiClient, storage, presenter });
|
|
66
|
+
await useCase.execute({
|
|
67
|
+
apiUrl: "http://localhost/api",
|
|
68
|
+
lang: "pt-br",
|
|
69
|
+
saveDefaults: { autoSave: false },
|
|
70
|
+
i18n: mockI18n
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(prompt.askYesNo).toHaveBeenCalledWith("askImprove");
|
|
74
|
+
expect(storage.saveJson).not.toHaveBeenCalled();
|
|
75
|
+
expect(presenter.goodbye).toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("retries when discovery fails and user chooses to retry", async () => {
|
|
79
|
+
const prompt = {
|
|
80
|
+
askInput: jest.fn().mockResolvedValue("My idea"),
|
|
81
|
+
askYesNo: jest
|
|
82
|
+
.fn()
|
|
83
|
+
.mockResolvedValueOnce(true)
|
|
84
|
+
.mockResolvedValueOnce(false)
|
|
85
|
+
};
|
|
86
|
+
const presenter = {
|
|
87
|
+
printHeader: jest.fn(),
|
|
88
|
+
info: jest.fn(),
|
|
89
|
+
error: jest.fn(),
|
|
90
|
+
json: jest.fn(),
|
|
91
|
+
success: jest.fn(),
|
|
92
|
+
goodbye: jest.fn(),
|
|
93
|
+
spinner: jest.fn().mockReturnValue({ succeed: jest.fn(), fail: jest.fn() })
|
|
94
|
+
};
|
|
95
|
+
const apiClient = {
|
|
96
|
+
runDiscovery: jest
|
|
97
|
+
.fn()
|
|
98
|
+
.mockRejectedValueOnce(new Error("API down"))
|
|
99
|
+
.mockResolvedValueOnce({ name: "Recovered" })
|
|
100
|
+
};
|
|
101
|
+
const storage = {
|
|
102
|
+
saveJson: jest.fn().mockResolvedValue({ saved: true, fullPath: "/tmp/file.json" })
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const useCase = new RunDiscoveryFlow({ prompt, apiClient, storage, presenter });
|
|
106
|
+
await useCase.execute({
|
|
107
|
+
apiUrl: "http://localhost/api",
|
|
108
|
+
lang: "pt-br",
|
|
109
|
+
saveDefaults: { autoSave: true },
|
|
110
|
+
i18n: mockI18n
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(apiClient.runDiscovery).toHaveBeenCalledTimes(2);
|
|
114
|
+
expect(presenter.error).toHaveBeenCalledWith("API down");
|
|
115
|
+
expect(presenter.goodbye).toHaveBeenCalled();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("prompts to improve after save declined", async () => {
|
|
119
|
+
const prompt = {
|
|
120
|
+
askInput: jest.fn().mockResolvedValue("My idea"),
|
|
121
|
+
askYesNo: jest
|
|
122
|
+
.fn()
|
|
123
|
+
.mockResolvedValueOnce(true)
|
|
124
|
+
.mockResolvedValueOnce(false)
|
|
125
|
+
};
|
|
126
|
+
const presenter = {
|
|
127
|
+
printHeader: jest.fn(),
|
|
128
|
+
info: jest.fn(),
|
|
129
|
+
error: jest.fn(),
|
|
130
|
+
json: jest.fn(),
|
|
131
|
+
success: jest.fn(),
|
|
132
|
+
goodbye: jest.fn(),
|
|
133
|
+
spinner: jest.fn().mockReturnValue({ succeed: jest.fn(), fail: jest.fn() })
|
|
134
|
+
};
|
|
135
|
+
const apiClient = {
|
|
136
|
+
runDiscovery: jest.fn().mockResolvedValue({ name: "Test Product" })
|
|
137
|
+
};
|
|
138
|
+
const storage = {
|
|
139
|
+
saveJson: jest.fn().mockResolvedValue({ saved: false })
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const useCase = new RunDiscoveryFlow({ prompt, apiClient, storage, presenter });
|
|
143
|
+
await useCase.execute({
|
|
144
|
+
apiUrl: "http://localhost/api",
|
|
145
|
+
lang: "pt-br",
|
|
146
|
+
saveDefaults: { autoSave: true },
|
|
147
|
+
i18n: mockI18n
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(storage.saveJson).toHaveBeenCalled();
|
|
151
|
+
expect(prompt.askYesNo).toHaveBeenCalledWith("askImprove");
|
|
152
|
+
expect(presenter.goodbye).toHaveBeenCalled();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("stops when discovery fails and user declines retry", async () => {
|
|
156
|
+
const prompt = {
|
|
157
|
+
askInput: jest.fn().mockResolvedValue("My idea"),
|
|
158
|
+
askYesNo: jest.fn().mockResolvedValue(false)
|
|
159
|
+
};
|
|
160
|
+
const presenter = {
|
|
161
|
+
printHeader: jest.fn(),
|
|
162
|
+
info: jest.fn(),
|
|
163
|
+
error: jest.fn(),
|
|
164
|
+
json: jest.fn(),
|
|
165
|
+
success: jest.fn(),
|
|
166
|
+
goodbye: jest.fn(),
|
|
167
|
+
spinner: jest.fn().mockReturnValue({ succeed: jest.fn(), fail: jest.fn() })
|
|
168
|
+
};
|
|
169
|
+
const apiClient = {
|
|
170
|
+
runDiscovery: jest.fn().mockRejectedValue(new Error("API down"))
|
|
171
|
+
};
|
|
172
|
+
const storage = {
|
|
173
|
+
saveJson: jest.fn()
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const useCase = new RunDiscoveryFlow({ prompt, apiClient, storage, presenter });
|
|
177
|
+
await useCase.execute({
|
|
178
|
+
apiUrl: "http://localhost/api",
|
|
179
|
+
lang: "pt-br",
|
|
180
|
+
saveDefaults: { autoSave: true },
|
|
181
|
+
i18n: mockI18n
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(prompt.askYesNo).toHaveBeenCalledWith("askRetry");
|
|
185
|
+
expect(storage.saveJson).not.toHaveBeenCalled();
|
|
186
|
+
expect(presenter.goodbye).toHaveBeenCalled();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("continues when autoSave false and user wants to improve", async () => {
|
|
190
|
+
const prompt = {
|
|
191
|
+
askInput: jest.fn().mockResolvedValue("My idea"),
|
|
192
|
+
askYesNo: jest.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false)
|
|
193
|
+
};
|
|
194
|
+
const presenter = {
|
|
195
|
+
printHeader: jest.fn(),
|
|
196
|
+
info: jest.fn(),
|
|
197
|
+
error: jest.fn(),
|
|
198
|
+
json: jest.fn(),
|
|
199
|
+
success: jest.fn(),
|
|
200
|
+
goodbye: jest.fn(),
|
|
201
|
+
spinner: jest.fn().mockReturnValue({ succeed: jest.fn(), fail: jest.fn() })
|
|
202
|
+
};
|
|
203
|
+
const apiClient = {
|
|
204
|
+
runDiscovery: jest.fn().mockResolvedValue({ name: "Test Product" })
|
|
205
|
+
};
|
|
206
|
+
const storage = {
|
|
207
|
+
saveJson: jest.fn()
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const useCase = new RunDiscoveryFlow({ prompt, apiClient, storage, presenter });
|
|
211
|
+
await useCase.execute({
|
|
212
|
+
apiUrl: "http://localhost/api",
|
|
213
|
+
lang: "pt-br",
|
|
214
|
+
saveDefaults: { autoSave: false },
|
|
215
|
+
i18n: mockI18n
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
expect(apiClient.runDiscovery).toHaveBeenCalledTimes(2);
|
|
219
|
+
expect(prompt.askYesNo).toHaveBeenCalledWith("askImprove");
|
|
220
|
+
expect(presenter.goodbye).toHaveBeenCalled();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const { DiscoverySession } = require("./DiscoverySession");
|
|
2
|
+
|
|
3
|
+
describe("DiscoverySession", () => {
|
|
4
|
+
test("appends additional details after base idea", () => {
|
|
5
|
+
const session = new DiscoverySession();
|
|
6
|
+
const first = session.addIdea("Base idea");
|
|
7
|
+
const second = session.addIdea("More details");
|
|
8
|
+
|
|
9
|
+
expect(first).toBe("Base idea");
|
|
10
|
+
expect(second).toBe("Base idea\n\nAdditional details: More details");
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const {
|
|
2
|
+
sanitizeForFolderName,
|
|
3
|
+
timestampForPath,
|
|
4
|
+
ensureJsonExtension
|
|
5
|
+
} = require("./PathNaming");
|
|
6
|
+
|
|
7
|
+
describe("PathNaming", () => {
|
|
8
|
+
test("sanitizeForFolderName normalizes input", () => {
|
|
9
|
+
expect(sanitizeForFolderName("My Product!! 2024")).toBe("my-product-2024");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("sanitizeForFolderName falls back to product", () => {
|
|
13
|
+
expect(sanitizeForFolderName("###")).toBe("product");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("timestampForPath returns a safe string", () => {
|
|
17
|
+
const value = timestampForPath();
|
|
18
|
+
expect(value).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
|
19
|
+
expect(value).not.toMatch(/[:.]/);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("ensureJsonExtension appends extension", () => {
|
|
23
|
+
expect(ensureJsonExtension("discovery")).toBe("discovery.json");
|
|
24
|
+
expect(ensureJsonExtension("report.JSON")).toBe("report.JSON");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -2,6 +2,7 @@ const { stdout: output } = require("node:process");
|
|
|
2
2
|
const chalk = require("chalk");
|
|
3
3
|
const boxen = require("boxen");
|
|
4
4
|
const ora = require("ora");
|
|
5
|
+
const cfonts = require("cfonts");
|
|
5
6
|
const pkg = require("../../package.json");
|
|
6
7
|
|
|
7
8
|
class ConsolePresenter {
|
|
@@ -9,23 +10,36 @@ class ConsolePresenter {
|
|
|
9
10
|
this.i18n = i18n;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
printHeader() {
|
|
13
|
+
printHeader(apiUrl = null) {
|
|
13
14
|
if (!this.i18n) return;
|
|
14
|
-
const logo =
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
15
|
+
const logo = cfonts.render("product discovery", {
|
|
16
|
+
font: "chrome",
|
|
17
|
+
align: "left",
|
|
18
|
+
colors: ["cyan"],
|
|
19
|
+
background: "transparent",
|
|
20
|
+
letterSpacing: 1,
|
|
21
|
+
lineHeight: 1,
|
|
22
|
+
space: false,
|
|
23
|
+
maxLength: 0
|
|
24
|
+
}).string;
|
|
23
25
|
const title = chalk.bold.cyan(this.i18n.t("headerTitle"));
|
|
24
26
|
const subtitle = chalk.gray(this.i18n.t("headerSubtitle"));
|
|
25
27
|
const author = chalk.gray(`${this.i18n.t("headerAuthor")}: ${pkg.author}`);
|
|
26
28
|
const version = chalk.gray(`${this.i18n.t("headerVersion")}: ${pkg.version}`);
|
|
27
29
|
const license = chalk.gray(`${this.i18n.t("headerLicense")}: ${pkg.license}`);
|
|
28
|
-
|
|
30
|
+
|
|
31
|
+
let portLine = "";
|
|
32
|
+
if (apiUrl && apiUrl.includes("localhost")) {
|
|
33
|
+
try {
|
|
34
|
+
const url = new URL(apiUrl);
|
|
35
|
+
const port = url.port || (url.protocol === "https:" ? 443 : 80);
|
|
36
|
+
portLine = `\n${chalk.cyan(`🔌 ${this.i18n.t("runningOnPort")} ${port}`)}`;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
// Ignora se falhar ao parsear
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const banner = `${logo}\n${title}\n${subtitle}\n\n${author}\n${version}\n${license}${portLine}`;
|
|
29
43
|
output.write(
|
|
30
44
|
boxen(banner, {
|
|
31
45
|
padding: 1,
|
|
@@ -19,6 +19,7 @@ const translations = {
|
|
|
19
19
|
|
|
20
20
|
// ConsolePresenter
|
|
21
21
|
generatedDiscovery: "Discovery JSON gerado:",
|
|
22
|
+
runningOnPort: "Rodando na porta",
|
|
22
23
|
error: "Erro:",
|
|
23
24
|
|
|
24
25
|
// RunDiscoveryFlow
|
|
@@ -63,6 +64,7 @@ const translations = {
|
|
|
63
64
|
|
|
64
65
|
// ConsolePresenter
|
|
65
66
|
generatedDiscovery: "Generated discovery JSON:",
|
|
67
|
+
runningOnPort: "Running on port",
|
|
66
68
|
error: "Error:",
|
|
67
69
|
|
|
68
70
|
// RunDiscoveryFlow
|