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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "product-discovery-cli",
3
- "version": "0.0.13",
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",
@@ -10,7 +10,7 @@ class RunDiscoveryFlow {
10
10
 
11
11
  async execute({ apiUrl, lang, saveDefaults, i18n }) {
12
12
  this.i18n = i18n;
13
- this.presenter.printHeader();
13
+ this.presenter.printHeader(apiUrl);
14
14
 
15
15
  let continueOuter = true;
16
16
 
@@ -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 = chalk.cyan(
15
- String.raw`
16
- ____ _ _ ____ _
17
- | _ \ _ __ ___ __| |_ _ ___| |_ | _ \(_)___ ___ _____ _____ _ __
18
- | |_) | '__/ _ \ / _ | | | |/ __| __| | | | | / __|/ __/ _ \ \ / / _ \ '__|
19
- | __/| | | (_) | (_| | |_| | (__| |_ | |_| | \__ \ (_| (_) \ V / __/ |
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
- const banner = `${logo}\n${title}\n${subtitle}\n\n${author}\n${version}\n${license}`;
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