react-doctor-cli-dev 1.0.1 → 1.0.3
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/backend/data/reports.db +0 -0
- package/backend/dist/db.js +55 -11
- package/backend/dist/index.js +22 -23
- package/backend/dist/routes/reports.js +221 -31
- package/backend/src/db.ts +61 -15
- package/backend/src/index.ts +32 -35
- package/backend/src/routes/reports.ts +289 -71
- package/cli/dist/commands/dashboard.js +152 -0
- package/cli/dist/index.js +24 -50
- package/cli/src/commands/dashboard.ts +179 -0
- package/cli/src/index.ts +33 -60
- package/package.json +1 -1
- package/react-doctor-cli-dev-1.0.0.tgz +0 -0
|
@@ -1,110 +1,328 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────
|
|
2
|
+
// backend/src/routes/reports.ts
|
|
3
|
+
//
|
|
4
|
+
// All report endpoints.
|
|
5
|
+
//
|
|
6
|
+
// ENDPOINTS:
|
|
7
|
+
//
|
|
8
|
+
// GET /api/reports
|
|
9
|
+
// Returns a summary list (no blobs) — fast for the dashboard
|
|
10
|
+
// history page. Each row has id, project, score, grade, dates.
|
|
11
|
+
//
|
|
12
|
+
// GET /api/reports/:id
|
|
13
|
+
// Returns the full report for one run — static + runtime +
|
|
14
|
+
// suggestions all parsed back to objects.
|
|
15
|
+
//
|
|
16
|
+
// GET /api/reports/project/:name
|
|
17
|
+
// All runs for a named project, summary only.
|
|
18
|
+
//
|
|
19
|
+
// POST /api/reports/upload (requires x-api-key header)
|
|
20
|
+
// Accepts a FinalReport from the CLI.
|
|
21
|
+
// Strips screenshot dataUrls → saves as .png files.
|
|
22
|
+
// Stores static_json, runtime_json, suggestions in DB.
|
|
23
|
+
//
|
|
24
|
+
// SCREENSHOT HANDLING ON UPLOAD:
|
|
25
|
+
// The CLI sends the full FinalReport including base64 screenshots
|
|
26
|
+
// (up to 200KB each). We extract those before storing so the DB
|
|
27
|
+
// stays lean. Each screenshot is saved as:
|
|
28
|
+
// data/screenshots/<reportId>-<routeKey>-<label>.png
|
|
29
|
+
// And the dataUrl in runtime_json is replaced with:
|
|
30
|
+
// /screenshots/<filename>
|
|
31
|
+
// so the dashboard can load them as normal <img> tags.
|
|
32
|
+
// ─────────────────────────────────────────────────────────────
|
|
1
33
|
|
|
2
34
|
import { Router, Request, Response, RequestHandler } from "express";
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
|
|
35
|
+
import path from "path";
|
|
36
|
+
import fs from "fs";
|
|
37
|
+
import db, { screenshotsDir } from "../db";
|
|
38
|
+
import { requireApiKey } from "../middleware/auth";
|
|
39
|
+
|
|
6
40
|
const router = Router();
|
|
7
|
-
|
|
8
41
|
|
|
9
|
-
|
|
42
|
+
// ── GET /api/reports ─────────────────────────────────────────
|
|
43
|
+
// Summary list — no blobs, just the columns the history page needs.
|
|
44
|
+
|
|
45
|
+
router.get("/", (_req: Request, res: Response) => {
|
|
10
46
|
try {
|
|
11
47
|
const rows = db.prepare(`
|
|
12
48
|
SELECT id, project, score, grade, analyzed_at, created_at
|
|
13
|
-
FROM
|
|
14
|
-
ORDER
|
|
15
|
-
LIMIT
|
|
49
|
+
FROM reports
|
|
50
|
+
ORDER BY created_at DESC
|
|
51
|
+
LIMIT 100
|
|
16
52
|
`).all() as any[];
|
|
17
|
-
|
|
53
|
+
|
|
18
54
|
res.json({ count: rows.length, reports: rows });
|
|
19
55
|
} catch (err: any) {
|
|
56
|
+
console.error("GET / error:", err.message);
|
|
20
57
|
res.status(500).json({ error: "Internal server error" });
|
|
21
58
|
}
|
|
22
59
|
});
|
|
23
|
-
|
|
60
|
+
|
|
61
|
+
// ── GET /api/reports/project/:name ───────────────────────────
|
|
62
|
+
// All runs for one project, summary only.
|
|
24
63
|
|
|
25
64
|
router.get("/project/:name", (req: Request, res: Response) => {
|
|
26
65
|
try {
|
|
27
66
|
const rows = db.prepare(`
|
|
28
67
|
SELECT id, project, score, grade, analyzed_at, created_at
|
|
29
|
-
FROM
|
|
30
|
-
WHERE
|
|
31
|
-
ORDER
|
|
68
|
+
FROM reports
|
|
69
|
+
WHERE project = ?
|
|
70
|
+
ORDER BY created_at DESC
|
|
32
71
|
`).all(req.params.name) as any[];
|
|
33
|
-
|
|
34
|
-
res.json({
|
|
35
|
-
project: req.params.name,
|
|
36
|
-
count: rows.length,
|
|
37
|
-
reports: rows,
|
|
38
|
-
});
|
|
72
|
+
|
|
73
|
+
res.json({ project: req.params.name, count: rows.length, reports: rows });
|
|
39
74
|
} catch (err: any) {
|
|
75
|
+
console.error("GET /project/:name error:", err.message);
|
|
40
76
|
res.status(500).json({ error: "Internal server error" });
|
|
41
77
|
}
|
|
42
78
|
});
|
|
43
|
-
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
router.post(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
79
|
+
|
|
80
|
+
// ── POST /api/reports/upload ──────────────────────────────────
|
|
81
|
+
// Receives a FinalReport from the CLI, strips screenshots to disk,
|
|
82
|
+
// and stores the three JSON blobs in separate columns.
|
|
83
|
+
|
|
84
|
+
router.post(
|
|
85
|
+
"/upload",
|
|
86
|
+
requireApiKey as RequestHandler,
|
|
87
|
+
(req: Request, res: Response) => {
|
|
88
|
+
try {
|
|
89
|
+
const body = req.body;
|
|
90
|
+
|
|
91
|
+
// ── Validate required top-level fields ──────────────────
|
|
92
|
+
if (
|
|
93
|
+
!body ||
|
|
94
|
+
!body.projectName ||
|
|
95
|
+
!body.analyzedAt ||
|
|
96
|
+
body.performanceScore === undefined ||
|
|
97
|
+
!body.static ||
|
|
98
|
+
!body.runtime ||
|
|
99
|
+
!body.suggestions
|
|
100
|
+
) {
|
|
101
|
+
res.status(400).json({
|
|
102
|
+
error: "Invalid report",
|
|
103
|
+
missing: getMissingFields(body),
|
|
104
|
+
});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Extract grade from static report ────────────────────
|
|
109
|
+
const grade: string = body.static?.grade ?? "N/A";
|
|
110
|
+
|
|
111
|
+
// ── Strip screenshots from runtime, save as .png files ──
|
|
112
|
+
// We do this BEFORE inserting so the DB never holds base64.
|
|
113
|
+
// The row ID isn't known yet — we'll rename after insert.
|
|
114
|
+
// For now we use a temp prefix and rename below.
|
|
115
|
+
const { cleanedRuntime, pendingScreenshots } = extractScreenshots(body.runtime);
|
|
116
|
+
|
|
117
|
+
// ── Insert the row ───────────────────────────────────────
|
|
118
|
+
const stmt = db.prepare(`
|
|
119
|
+
INSERT INTO reports
|
|
120
|
+
(project, score, grade, analyzed_at, static_json, runtime_json, suggestions)
|
|
121
|
+
VALUES
|
|
122
|
+
(?, ?, ?, ?, ?, ?, ?)
|
|
123
|
+
`);
|
|
124
|
+
|
|
125
|
+
const result = stmt.run(
|
|
126
|
+
body.projectName,
|
|
127
|
+
body.performanceScore,
|
|
128
|
+
grade,
|
|
129
|
+
body.analyzedAt,
|
|
130
|
+
JSON.stringify(body.static),
|
|
131
|
+
JSON.stringify(cleanedRuntime),
|
|
132
|
+
JSON.stringify(body.suggestions),
|
|
133
|
+
) as any;
|
|
134
|
+
|
|
135
|
+
const reportId: number = result.lastInsertRowid;
|
|
136
|
+
|
|
137
|
+
// ── Save screenshots with final filenames ────────────────
|
|
138
|
+
const savedScreenshots = saveScreenshots(reportId, pendingScreenshots);
|
|
139
|
+
|
|
140
|
+
// ── Patch runtime_json with final screenshot paths ───────
|
|
141
|
+
// Now that we have the reportId we can write the correct paths.
|
|
142
|
+
if (savedScreenshots.length > 0) {
|
|
143
|
+
const patchedRuntime = patchScreenshotPaths(
|
|
144
|
+
cleanedRuntime,
|
|
145
|
+
savedScreenshots,
|
|
146
|
+
);
|
|
147
|
+
db.prepare(
|
|
148
|
+
"UPDATE reports SET runtime_json = ? WHERE id = ?"
|
|
149
|
+
).run(JSON.stringify(patchedRuntime), reportId);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
res.status(201).json({
|
|
153
|
+
message: "Report saved successfully",
|
|
154
|
+
id: reportId,
|
|
155
|
+
screenshots: savedScreenshots.length,
|
|
156
|
+
});
|
|
157
|
+
} catch (err: any) {
|
|
158
|
+
console.error("POST /upload error:", err.message);
|
|
159
|
+
res.status(500).json({ error: "Internal server error" });
|
|
56
160
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
`);
|
|
65
|
-
|
|
66
|
-
// تنفيذ الاستعلام وحفظ جسم التقرير كـ string
|
|
67
|
-
const result = stmt.run(
|
|
68
|
-
report.projectName,
|
|
69
|
-
report.performanceScore,
|
|
70
|
-
grade,
|
|
71
|
-
report.analyzedAt,
|
|
72
|
-
JSON.stringify(report)
|
|
73
|
-
);
|
|
74
|
-
|
|
75
|
-
res.status(201).json({
|
|
76
|
-
message: "Report saved successfully",
|
|
77
|
-
id: result.lastInsertRowid,
|
|
78
|
-
});
|
|
79
|
-
} catch (err: any) {
|
|
80
|
-
console.error("Upload error:", err.message);
|
|
81
|
-
res.status(500).json({ error: "Internal server error" });
|
|
82
|
-
}
|
|
83
|
-
});
|
|
84
|
-
|
|
161
|
+
},
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// ── GET /api/reports/:id ─────────────────────────────────────
|
|
165
|
+
// Full report for one run — parses all three JSON columns back
|
|
166
|
+
// to objects and returns a unified response.
|
|
167
|
+
|
|
85
168
|
router.get("/:id", (req: Request, res: Response) => {
|
|
86
169
|
try {
|
|
87
170
|
const row = db.prepare(
|
|
88
171
|
"SELECT * FROM reports WHERE id = ?"
|
|
89
172
|
).get(req.params.id) as any;
|
|
90
|
-
|
|
173
|
+
|
|
91
174
|
if (!row) {
|
|
92
175
|
res.status(404).json({ error: "Report not found" });
|
|
93
176
|
return;
|
|
94
177
|
}
|
|
95
|
-
|
|
178
|
+
|
|
96
179
|
res.json({
|
|
97
|
-
id:
|
|
98
|
-
project:
|
|
99
|
-
score:
|
|
100
|
-
grade:
|
|
101
|
-
analyzedAt:
|
|
102
|
-
createdAt:
|
|
103
|
-
|
|
180
|
+
id: row.id,
|
|
181
|
+
project: row.project,
|
|
182
|
+
score: row.score,
|
|
183
|
+
grade: row.grade,
|
|
184
|
+
analyzedAt: row.analyzed_at,
|
|
185
|
+
createdAt: row.created_at,
|
|
186
|
+
// Parse the three JSON blobs back into objects
|
|
187
|
+
static: JSON.parse(row.static_json),
|
|
188
|
+
runtime: JSON.parse(row.runtime_json),
|
|
189
|
+
suggestions: JSON.parse(row.suggestions),
|
|
104
190
|
});
|
|
105
191
|
} catch (err: any) {
|
|
192
|
+
console.error("GET /:id error:", err.message);
|
|
106
193
|
res.status(500).json({ error: "Internal server error" });
|
|
107
194
|
}
|
|
108
195
|
});
|
|
109
|
-
|
|
110
|
-
export default router;
|
|
196
|
+
|
|
197
|
+
export default router;
|
|
198
|
+
|
|
199
|
+
// ─────────────────────────────────────────────────────────────
|
|
200
|
+
// HELPERS
|
|
201
|
+
// ─────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
function getMissingFields(body: any): string[] {
|
|
204
|
+
const required = ["projectName", "analyzedAt", "performanceScore", "static", "runtime", "suggestions"];
|
|
205
|
+
return required.filter(f => body?.[f] === undefined || body?.[f] === null);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
interface PendingScreenshot {
|
|
209
|
+
routeKey: string;
|
|
210
|
+
label: string;
|
|
211
|
+
buffer: Buffer;
|
|
212
|
+
// placeholder path written into cleanedRuntime — replaced after insert
|
|
213
|
+
tempPath: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Walk the runtime map, strip every screenshot.dataUrl,
|
|
218
|
+
* and collect them as Buffers ready to write to disk.
|
|
219
|
+
*
|
|
220
|
+
* Returns:
|
|
221
|
+
* cleanedRuntime — runtime map with dataUrls replaced by tempPath markers
|
|
222
|
+
* pendingScreenshots — list of screenshots to save once we have a reportId
|
|
223
|
+
*/
|
|
224
|
+
function extractScreenshots(
|
|
225
|
+
runtime: Record<string, any>,
|
|
226
|
+
): { cleanedRuntime: Record<string, any>; pendingScreenshots: PendingScreenshot[] } {
|
|
227
|
+
const pending: PendingScreenshot[] = [];
|
|
228
|
+
const cleaned: Record<string, any> = {};
|
|
229
|
+
|
|
230
|
+
for (const [routeKey, routeData] of Object.entries(runtime)) {
|
|
231
|
+
const routeClone = { ...routeData };
|
|
232
|
+
|
|
233
|
+
if (Array.isArray(routeClone.screenshots)) {
|
|
234
|
+
routeClone.screenshots = routeClone.screenshots.map((shot: any) => {
|
|
235
|
+
if (!shot.dataUrl || !shot.dataUrl.startsWith("data:image/png;base64,")) {
|
|
236
|
+
return shot;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const base64 = shot.dataUrl.replace("data:image/png;base64,", "");
|
|
240
|
+
const buffer = Buffer.from(base64, "base64");
|
|
241
|
+
|
|
242
|
+
// Sanitise routeKey for use in a filename — replace "/" and ":" with "-"
|
|
243
|
+
const safeRoute = routeKey.replace(/[/:]/g, "-").replace(/^-+/, "");
|
|
244
|
+
const safeLabel = shot.label.replace(/[^a-z0-9]/gi, "-");
|
|
245
|
+
const tempPath = `__PENDING__${safeRoute}__${safeLabel}`;
|
|
246
|
+
|
|
247
|
+
pending.push({ routeKey, label: shot.label, buffer, tempPath });
|
|
248
|
+
|
|
249
|
+
return { ...shot, dataUrl: tempPath };
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
cleaned[routeKey] = routeClone;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return { cleanedRuntime: cleaned, pendingScreenshots: pending };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
interface SavedScreenshot {
|
|
260
|
+
routeKey: string;
|
|
261
|
+
label: string;
|
|
262
|
+
tempPath: string;
|
|
263
|
+
filePath: string; // relative URL path served by express static
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Write each screenshot buffer to data/screenshots/<reportId>-<route>-<label>.png
|
|
268
|
+
* Returns the list of saved files with their final URL paths.
|
|
269
|
+
*/
|
|
270
|
+
function saveScreenshots(
|
|
271
|
+
reportId: number,
|
|
272
|
+
pending: PendingScreenshot[],
|
|
273
|
+
): SavedScreenshot[] {
|
|
274
|
+
const saved: SavedScreenshot[] = [];
|
|
275
|
+
|
|
276
|
+
for (const shot of pending) {
|
|
277
|
+
const safeRoute = shot.routeKey.replace(/[/:]/g, "-").replace(/^-+/, "");
|
|
278
|
+
const safeLabel = shot.label.replace(/[^a-z0-9]/gi, "-");
|
|
279
|
+
const filename = `${reportId}-${safeRoute}-${safeLabel}.png`;
|
|
280
|
+
const fullPath = path.join(screenshotsDir, filename);
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
fs.writeFileSync(fullPath, shot.buffer);
|
|
284
|
+
saved.push({
|
|
285
|
+
routeKey: shot.routeKey,
|
|
286
|
+
label: shot.label,
|
|
287
|
+
tempPath: shot.tempPath,
|
|
288
|
+
filePath: `/screenshots/${filename}`,
|
|
289
|
+
});
|
|
290
|
+
} catch (err: any) {
|
|
291
|
+
console.warn(`Could not save screenshot ${filename}: ${err.message}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return saved;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Replace the __PENDING__ markers in runtime_json with
|
|
300
|
+
* the final /screenshots/<file> URL paths.
|
|
301
|
+
*/
|
|
302
|
+
function patchScreenshotPaths(
|
|
303
|
+
runtime: Record<string, any>,
|
|
304
|
+
saved: SavedScreenshot[],
|
|
305
|
+
): Record<string, any> {
|
|
306
|
+
// Build a lookup from tempPath → filePath
|
|
307
|
+
const lookup: Record<string, string> = {};
|
|
308
|
+
for (const s of saved) lookup[s.tempPath] = s.filePath;
|
|
309
|
+
|
|
310
|
+
const patched: Record<string, any> = {};
|
|
311
|
+
|
|
312
|
+
for (const [routeKey, routeData] of Object.entries(runtime)) {
|
|
313
|
+
const routeClone = { ...routeData };
|
|
314
|
+
|
|
315
|
+
if (Array.isArray(routeClone.screenshots)) {
|
|
316
|
+
routeClone.screenshots = routeClone.screenshots.map((shot: any) => {
|
|
317
|
+
if (shot.dataUrl && lookup[shot.dataUrl]) {
|
|
318
|
+
return { ...shot, dataUrl: lookup[shot.dataUrl] };
|
|
319
|
+
}
|
|
320
|
+
return shot;
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
patched[routeKey] = routeClone;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return patched;
|
|
328
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ─────────────────────────────────────────────────────────────
|
|
3
|
+
// cli/src/commands/dashboard.ts
|
|
4
|
+
//
|
|
5
|
+
// react-doctor dashboard
|
|
6
|
+
//
|
|
7
|
+
// Opens the React Doctor dashboard in the browser.
|
|
8
|
+
//
|
|
9
|
+
// WHAT IT DOES:
|
|
10
|
+
// 1. Checks if the backend is already running on the port
|
|
11
|
+
// 2. If not — starts it automatically (same logic as --upload)
|
|
12
|
+
// 3. Opens http://localhost:PORT in the default browser
|
|
13
|
+
//
|
|
14
|
+
// This command is the natural companion to --upload.
|
|
15
|
+
// Workflow:
|
|
16
|
+
// react-doctor full ./my-app --upload ← runs analysis + saves report
|
|
17
|
+
// react-doctor dashboard ← opens the dashboard to view it
|
|
18
|
+
//
|
|
19
|
+
// Or in one shot:
|
|
20
|
+
// react-doctor full ./my-app --upload && react-doctor dashboard
|
|
21
|
+
// ─────────────────────────────────────────────────────────────
|
|
22
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
23
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
24
|
+
};
|
|
25
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.registerDashboardCommand = registerDashboardCommand;
|
|
27
|
+
const path_1 = __importDefault(require("path"));
|
|
28
|
+
const fs_1 = __importDefault(require("fs"));
|
|
29
|
+
const axios_1 = __importDefault(require("axios"));
|
|
30
|
+
const child_process_1 = require("child_process");
|
|
31
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
32
|
+
const ui_1 = require("../ui");
|
|
33
|
+
function registerDashboardCommand(program) {
|
|
34
|
+
program
|
|
35
|
+
.command("dashboard")
|
|
36
|
+
.description("Open the React Doctor dashboard (auto-starts backend if needed)")
|
|
37
|
+
.option("--port <port>", "Port the backend runs on", "3000")
|
|
38
|
+
.option("--api-key <key>", "API key for the backend", process.env.REACT_DOCTOR_API_KEY || "react-doctor-secret-key-change-this")
|
|
39
|
+
.option("--no-banner", "Skip the banner")
|
|
40
|
+
.action(async (options) => {
|
|
41
|
+
if (!options.noBanner)
|
|
42
|
+
(0, ui_1.printBanner)();
|
|
43
|
+
const port = options.port;
|
|
44
|
+
const apiUrl = `http://localhost:${port}`;
|
|
45
|
+
(0, ui_1.printSection)("Dashboard");
|
|
46
|
+
(0, ui_1.printInfo)("Backend URL", apiUrl);
|
|
47
|
+
console.log();
|
|
48
|
+
const spin = (0, ui_1.spinner)("Checking backend status...");
|
|
49
|
+
try {
|
|
50
|
+
// ── 1. Check if backend is already up ─────────────────
|
|
51
|
+
let backendRunning = false;
|
|
52
|
+
try {
|
|
53
|
+
await axios_1.default.get(`${apiUrl}/health`, { timeout: 2000 });
|
|
54
|
+
backendRunning = true;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
backendRunning = false;
|
|
58
|
+
}
|
|
59
|
+
// ── 2. Start backend if not running ───────────────────
|
|
60
|
+
if (!backendRunning) {
|
|
61
|
+
spin.text = " Backend not running — starting automatically...";
|
|
62
|
+
// Locate backend folder (sibling of cli/)
|
|
63
|
+
const projectRoot = path_1.default.resolve(__dirname, "..", "..", "..");
|
|
64
|
+
const backendRoot = path_1.default.resolve(projectRoot, "backend");
|
|
65
|
+
const backendDist = path_1.default.join(backendRoot, "dist", "index.js");
|
|
66
|
+
const backendSrc = path_1.default.join(backendRoot, "src", "index.ts");
|
|
67
|
+
let command;
|
|
68
|
+
let args;
|
|
69
|
+
if (fs_1.default.existsSync(backendDist)) {
|
|
70
|
+
command = "node";
|
|
71
|
+
args = [backendDist];
|
|
72
|
+
}
|
|
73
|
+
else if (fs_1.default.existsSync(backendSrc)) {
|
|
74
|
+
command = "npx";
|
|
75
|
+
args = ["ts-node", backendSrc];
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
spin.fail(chalk_1.default.red("Backend not found"));
|
|
79
|
+
(0, ui_1.printFail)(`Could not find backend at: ${backendRoot}\n\n` +
|
|
80
|
+
` Make sure the 'backend/' folder exists next to 'cli/'.`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
// Create data dir inside npm global cache for the backend DB
|
|
84
|
+
const dataDir = path_1.default.join(backendRoot, "data");
|
|
85
|
+
fs_1.default.mkdirSync(dataDir, { recursive: true });
|
|
86
|
+
(0, child_process_1.spawn)(command, args, {
|
|
87
|
+
stdio: "ignore",
|
|
88
|
+
detached: true,
|
|
89
|
+
env: {
|
|
90
|
+
...process.env,
|
|
91
|
+
API_KEY: options.apiKey,
|
|
92
|
+
PORT: port,
|
|
93
|
+
DB_PATH: path_1.default.join(dataDir, "reports.db"),
|
|
94
|
+
},
|
|
95
|
+
cwd: backendRoot,
|
|
96
|
+
}).unref(); // let CLI exit without killing the server
|
|
97
|
+
// Wait for backend to be ready (up to 15 seconds)
|
|
98
|
+
let ready = false;
|
|
99
|
+
let retries = 0;
|
|
100
|
+
while (!ready && retries < 15) {
|
|
101
|
+
try {
|
|
102
|
+
await axios_1.default.get(`${apiUrl}/health`, { timeout: 1000 });
|
|
103
|
+
ready = true;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
107
|
+
retries++;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (!ready) {
|
|
111
|
+
spin.fail(chalk_1.default.red("Backend failed to start"));
|
|
112
|
+
(0, ui_1.printFail)("Backend did not respond after 15 seconds.");
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
spin.succeed(chalk_1.default.green("Backend started successfully"));
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
spin.succeed(chalk_1.default.green("Backend already running"));
|
|
119
|
+
}
|
|
120
|
+
// ── 3. Open dashboard in default browser ──────────────
|
|
121
|
+
const dashboardUrl = apiUrl;
|
|
122
|
+
console.log();
|
|
123
|
+
(0, ui_1.printInfo)("Opening", dashboardUrl);
|
|
124
|
+
console.log();
|
|
125
|
+
// Cross-platform browser open
|
|
126
|
+
const openCmd = process.platform === "win32" ? ["cmd", ["/c", "start", dashboardUrl]] :
|
|
127
|
+
process.platform === "darwin" ? ["open", [dashboardUrl]] :
|
|
128
|
+
["xdg-open", [dashboardUrl]];
|
|
129
|
+
(0, child_process_1.spawn)(openCmd[0], openCmd[1], {
|
|
130
|
+
stdio: "ignore",
|
|
131
|
+
detached: true,
|
|
132
|
+
}).unref();
|
|
133
|
+
(0, ui_1.printDone)(`Dashboard opened at ${chalk_1.default.cyan(dashboardUrl)}`);
|
|
134
|
+
// ── 4. Show quick API reference ───────────────────────
|
|
135
|
+
console.log(chalk_1.default.gray(" Available endpoints:"));
|
|
136
|
+
console.log(chalk_1.default.cyan(` GET ${apiUrl}/health`));
|
|
137
|
+
console.log(chalk_1.default.cyan(` GET ${apiUrl}/api/reports`));
|
|
138
|
+
console.log(chalk_1.default.cyan(` GET ${apiUrl}/api/reports/:id`));
|
|
139
|
+
console.log(chalk_1.default.cyan(` GET ${apiUrl}/api/reports/project/:name`));
|
|
140
|
+
console.log(chalk_1.default.cyan(` POST ${apiUrl}/api/reports/upload`));
|
|
141
|
+
console.log();
|
|
142
|
+
console.log(chalk_1.default.gray(" Tip: run ") +
|
|
143
|
+
chalk_1.default.cyan("react-doctor full ./ --upload") +
|
|
144
|
+
chalk_1.default.gray(" to add a new report.\n"));
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
spin.fail(chalk_1.default.red("Dashboard failed to open"));
|
|
148
|
+
console.log(chalk_1.default.red(`\n ${err.message}\n`));
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
package/cli/dist/index.js
CHANGED
|
@@ -1,77 +1,51 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
3
|
// ─────────────────────────────────────────────────────────────
|
|
4
|
-
// cli/src/index.ts
|
|
5
|
-
//
|
|
6
|
-
// The CLI entry point. This is the file that runs when the
|
|
7
|
-
// user types "react-doctor" in their terminal.
|
|
8
|
-
//
|
|
9
|
-
// HOW IT WORKS:
|
|
10
|
-
// 1. Commander.js parses the command and flags from argv
|
|
11
|
-
// 2. The matching command handler is called
|
|
12
|
-
// 3. The handler imports core modules and runs the pipeline
|
|
13
|
-
//
|
|
14
|
-
// HOW THE BINARY REGISTRATION WORKS:
|
|
15
|
-
// package.json has a "bin" field:
|
|
16
|
-
// "bin": { "react-doctor": "./dist/index.js" }
|
|
17
|
-
//
|
|
18
|
-
// After "npm link" (dev) or "npm install" (production),
|
|
19
|
-
// npm creates a symlink from the system's bin directory
|
|
20
|
-
// to this file. That's what makes "react-doctor" a real
|
|
21
|
-
// terminal command available anywhere.
|
|
22
|
-
//
|
|
23
|
-
// THE SHEBANG (#!/usr/bin/env node) on line 1:
|
|
24
|
-
// This tells the OS to run this file with Node.js when
|
|
25
|
-
// called directly as a script. Without it, the OS doesn't
|
|
26
|
-
// know which interpreter to use.
|
|
4
|
+
// cli/src/index.ts — CLI entry point
|
|
27
5
|
// ─────────────────────────────────────────────────────────────
|
|
28
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
7
|
const commander_1 = require("commander");
|
|
30
8
|
const analyze_1 = require("./commands/analyze");
|
|
31
9
|
const profile_1 = require("./commands/profile");
|
|
32
10
|
const full_1 = require("./commands/full");
|
|
33
|
-
const
|
|
11
|
+
const dashboard_1 = require("./commands/dashboard");
|
|
34
12
|
const program = new commander_1.Command();
|
|
35
13
|
// ── Program metadata ──────────────────────────────────────────
|
|
36
14
|
program
|
|
37
15
|
.name("react-doctor")
|
|
38
16
|
.description("React performance analyzer — static analysis + runtime profiling + smart suggestions")
|
|
39
|
-
.version("1.0.
|
|
40
|
-
// ── Register
|
|
41
|
-
// Each function adds one command to the program.
|
|
42
|
-
// The order here is the order they appear in --help output.
|
|
17
|
+
.version("1.0.2");
|
|
18
|
+
// ── Register commands ─────────────────────────────────────────
|
|
43
19
|
(0, full_1.registerFullCommand)(program); // react-doctor full
|
|
44
20
|
(0, analyze_1.registerAnalyzeCommand)(program); // react-doctor analyze
|
|
45
21
|
(0, profile_1.registerProfileCommand)(program); // react-doctor profile
|
|
46
|
-
(0,
|
|
47
|
-
// ── Usage examples
|
|
22
|
+
(0, dashboard_1.registerDashboardCommand)(program); // react-doctor dashboard
|
|
23
|
+
// ── Usage examples ────────────────────────────────────────────
|
|
48
24
|
program.addHelpText("after", `
|
|
49
25
|
Examples:
|
|
50
|
-
$ react-doctor full ./my-app
|
|
51
|
-
$ react-doctor full ./my-app --mobile
|
|
52
|
-
$ react-doctor full ./my-app --desktop --mobile
|
|
53
|
-
$ react-doctor full ./my-app --cpu 4
|
|
54
|
-
$ react-doctor full ./my-app --throttle slow4g
|
|
55
|
-
$ react-doctor full ./my-app --throttle 3g
|
|
56
|
-
$ react-doctor full ./my-app --cpu 4 --throttle 3g
|
|
57
|
-
$ react-doctor full ./my-app --upload
|
|
26
|
+
$ react-doctor full ./my-app Run full diagnostic (desktop)
|
|
27
|
+
$ react-doctor full ./my-app --mobile Include mobile viewport
|
|
28
|
+
$ react-doctor full ./my-app --desktop --mobile Both desktop and mobile
|
|
29
|
+
$ react-doctor full ./my-app --cpu 4 Simulate slow Android device
|
|
30
|
+
$ react-doctor full ./my-app --throttle slow4g Simulate slow 4G network
|
|
31
|
+
$ react-doctor full ./my-app --throttle 3g Simulate 3G network
|
|
32
|
+
$ react-doctor full ./my-app --cpu 4 --throttle 3g Slow device + slow network
|
|
33
|
+
$ react-doctor full ./my-app --upload Run + save report to dashboard
|
|
58
34
|
|
|
59
|
-
$ react-doctor analyze ./my-app
|
|
60
|
-
$ react-doctor analyze ./my-app --full
|
|
35
|
+
$ react-doctor analyze ./my-app Static code analysis only
|
|
36
|
+
$ react-doctor analyze ./my-app --full Static + runtime + rules
|
|
61
37
|
|
|
62
|
-
$ react-doctor profile ./my-app
|
|
63
|
-
$ react-doctor profile ./my-app --mobile
|
|
64
|
-
$ react-doctor profile ./my-app --desktop --mobile
|
|
65
|
-
$ react-doctor profile ./my-app --cpu 4
|
|
66
|
-
$ react-doctor profile ./my-app --throttle slow4g
|
|
67
|
-
$ react-doctor profile ./my-app --throttle 3g
|
|
38
|
+
$ react-doctor profile ./my-app Runtime profiling only (desktop)
|
|
39
|
+
$ react-doctor profile ./my-app --mobile Mobile viewport
|
|
40
|
+
$ react-doctor profile ./my-app --desktop --mobile Both devices
|
|
41
|
+
$ react-doctor profile ./my-app --cpu 4 4x CPU slowdown simulation
|
|
42
|
+
$ react-doctor profile ./my-app --throttle slow4g Simulate slow 4G network
|
|
43
|
+
$ react-doctor profile ./my-app --throttle 3g Simulate 3G network
|
|
68
44
|
|
|
69
|
-
$ react-doctor
|
|
70
|
-
$ react-doctor
|
|
45
|
+
$ react-doctor dashboard Open dashboard (auto-starts backend)
|
|
46
|
+
$ react-doctor dashboard --port 4000 Use custom port
|
|
71
47
|
`);
|
|
72
48
|
// ── Show help if called with no arguments ─────────────────────
|
|
73
|
-
// Without this, calling "react-doctor" with no command just
|
|
74
|
-
// exits silently, which is confusing. This prints help instead.
|
|
75
49
|
if (process.argv.length < 3) {
|
|
76
50
|
program.help();
|
|
77
51
|
}
|