react-detective 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +137 -2
- package/dist/chunk-5TDNB3DH.js +2686 -0
- package/dist/chunk-6EJCTAJS.js +25 -0
- package/dist/chunk-E23QXCCY.js +43 -0
- package/dist/chunk-Z2Y65YOY.cjs +7 -0
- package/dist/cli.js +35 -0
- package/dist/client.d.ts +9 -0
- package/dist/client.js +2028 -0
- package/dist/init-JUCWNJPS.js +630 -0
- package/dist/loader.cjs +105 -0
- package/dist/loader.js +101 -0
- package/dist/next.cjs +58 -0
- package/dist/next.d.cts +24 -0
- package/dist/next.d.ts +22 -0
- package/dist/next.js +54 -0
- package/dist/react.d.ts +11 -0
- package/dist/react.js +26 -0
- package/dist/server.d.ts +292 -0
- package/dist/server.js +15 -0
- package/dist/vite.d.ts +17 -0
- package/dist/vite.js +132 -0
- package/package.json +84 -4
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
import {
|
|
2
|
+
findApiKey
|
|
3
|
+
} from "./chunk-E23QXCCY.js";
|
|
4
|
+
|
|
5
|
+
// src/init/index.ts
|
|
6
|
+
import { execFileSync, spawnSync } from "child_process";
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import path from "path";
|
|
9
|
+
import { parseArgs } from "util";
|
|
10
|
+
import readline from "readline/promises";
|
|
11
|
+
import { structuredPatch } from "diff";
|
|
12
|
+
|
|
13
|
+
// src/init/edits.ts
|
|
14
|
+
import { parse } from "@babel/parser";
|
|
15
|
+
import MagicString from "magic-string";
|
|
16
|
+
var changed = (code) => ({ status: "changed", code });
|
|
17
|
+
var manual = (reason) => ({ status: "manual", reason });
|
|
18
|
+
function parseModule(code, filename) {
|
|
19
|
+
const isTs = /\.[cm]?tsx?$/.test(filename);
|
|
20
|
+
const plugins = isTs ? filename.endsWith("x") ? ["typescript", "jsx"] : ["typescript"] : ["jsx"];
|
|
21
|
+
try {
|
|
22
|
+
return parse(code, { sourceType: "unambiguous", plugins });
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function optionsLiteral(opts = {}) {
|
|
28
|
+
return opts.model && opts.model !== "sonnet" ? `{ model: ${JSON.stringify(opts.model)} }` : "";
|
|
29
|
+
}
|
|
30
|
+
function insertImport(s, ast, line) {
|
|
31
|
+
let last;
|
|
32
|
+
for (const stmt of ast.program.body) {
|
|
33
|
+
if (stmt.type === "ImportDeclaration" || isRequireDeclaration(stmt)) last = stmt;
|
|
34
|
+
}
|
|
35
|
+
if (last) s.appendLeft(last.end, `
|
|
36
|
+
${line}`);
|
|
37
|
+
else {
|
|
38
|
+
const directive = ast.program.directives.at(-1);
|
|
39
|
+
if (directive) s.appendLeft(directive.end, `
|
|
40
|
+
${line}`);
|
|
41
|
+
else s.prepend(`${line}
|
|
42
|
+
`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function isRequireDeclaration(stmt) {
|
|
46
|
+
if (stmt.type !== "VariableDeclaration") return false;
|
|
47
|
+
return stmt.declarations.some((d) => d.init?.type === "CallExpression" && d.init.callee.type === "Identifier" && d.init.callee.name === "require");
|
|
48
|
+
}
|
|
49
|
+
function findVariable(ast, name) {
|
|
50
|
+
for (const stmt of ast.program.body) {
|
|
51
|
+
const decl = stmt.type === "ExportNamedDeclaration" ? stmt.declaration : stmt;
|
|
52
|
+
if (decl?.type !== "VariableDeclaration") continue;
|
|
53
|
+
for (const d of decl.declarations) {
|
|
54
|
+
if (d.id.type === "Identifier" && d.id.name === name && d.init) return d.init;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
function findFunction(ast, name) {
|
|
60
|
+
for (const stmt of ast.program.body) {
|
|
61
|
+
const decl = stmt.type === "ExportNamedDeclaration" ? stmt.declaration : stmt;
|
|
62
|
+
if (decl?.type === "FunctionDeclaration" && decl.id?.name === name) return decl;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
function returnedExpression(fn) {
|
|
67
|
+
if (fn.type !== "ArrowFunctionExpression" && fn.type !== "FunctionExpression" && fn.type !== "FunctionDeclaration") return null;
|
|
68
|
+
if (fn.body.type !== "BlockStatement") return fn.body;
|
|
69
|
+
const ret = [...fn.body.body].reverse().find((s) => s.type === "ReturnStatement");
|
|
70
|
+
return ret?.argument ?? null;
|
|
71
|
+
}
|
|
72
|
+
function unwrapTs(node) {
|
|
73
|
+
while (node.type === "TSSatisfiesExpression" || node.type === "TSAsExpression" || node.type === "TSNonNullExpression") node = node.expression;
|
|
74
|
+
return node;
|
|
75
|
+
}
|
|
76
|
+
function defaultExport(ast) {
|
|
77
|
+
for (const stmt of ast.program.body) {
|
|
78
|
+
if (stmt.type === "ExportDefaultDeclaration") return stmt.declaration;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
function moduleExports(ast) {
|
|
83
|
+
for (const stmt of ast.program.body) {
|
|
84
|
+
if (stmt.type !== "ExpressionStatement" || stmt.expression.type !== "AssignmentExpression") continue;
|
|
85
|
+
const left = stmt.expression.left;
|
|
86
|
+
if (left.type === "MemberExpression" && left.object.type === "Identifier" && left.object.name === "module" && left.property.type === "Identifier" && left.property.name === "exports")
|
|
87
|
+
return stmt.expression;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
function indentBefore(code, pos) {
|
|
92
|
+
const lineStart = code.lastIndexOf("\n", pos - 1) + 1;
|
|
93
|
+
const prefix = code.slice(lineStart, pos);
|
|
94
|
+
return /^[ \t]*$/.test(prefix) ? prefix : null;
|
|
95
|
+
}
|
|
96
|
+
function insertBeforeClosing(s, code, closingStart, child) {
|
|
97
|
+
const indent = indentBefore(code, closingStart);
|
|
98
|
+
if (indent === null) s.appendLeft(closingStart, child);
|
|
99
|
+
else s.appendLeft(closingStart, `${indent.includes(" ") ? " " : " "}${child}
|
|
100
|
+
${indent}`);
|
|
101
|
+
}
|
|
102
|
+
function resolveConfigObject(ast, node, depth = 0) {
|
|
103
|
+
if (!node || depth > 8) return null;
|
|
104
|
+
node = unwrapTs(node);
|
|
105
|
+
switch (node.type) {
|
|
106
|
+
case "ObjectExpression":
|
|
107
|
+
return node;
|
|
108
|
+
case "CallExpression":
|
|
109
|
+
for (const arg of node.arguments) {
|
|
110
|
+
const found = resolveConfigObject(ast, arg, depth + 1);
|
|
111
|
+
if (found) return found;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
case "ArrowFunctionExpression":
|
|
115
|
+
case "FunctionExpression":
|
|
116
|
+
case "FunctionDeclaration":
|
|
117
|
+
return resolveConfigObject(ast, returnedExpression(node), depth + 1);
|
|
118
|
+
case "Identifier":
|
|
119
|
+
return resolveConfigObject(ast, findVariable(ast, node.name) ?? findFunction(ast, node.name), depth + 1);
|
|
120
|
+
default:
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function propertyNamed(obj, name) {
|
|
125
|
+
for (const p of obj.properties) {
|
|
126
|
+
if (p.type !== "ObjectProperty") continue;
|
|
127
|
+
const key = p.key;
|
|
128
|
+
if (key.type === "Identifier" && key.name === name || key.type === "StringLiteral" && key.value === name) return p;
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
function addVitePlugin(code, filename, opts = {}) {
|
|
133
|
+
if (code.includes("react-detective/vite")) return { status: "already" };
|
|
134
|
+
const ast = parseModule(code, filename);
|
|
135
|
+
if (!ast) return manual(`couldn't parse ${filename}`);
|
|
136
|
+
const exported = defaultExport(ast) ?? moduleExports(ast)?.right;
|
|
137
|
+
const config = resolveConfigObject(ast, exported);
|
|
138
|
+
if (!config) return manual(`couldn't find the config object exported by ${filename}`);
|
|
139
|
+
const name = /\bdetective\b/.test(code) ? "reactDetective" : "detective";
|
|
140
|
+
const call = `${name}(${optionsLiteral(opts)})`;
|
|
141
|
+
const s = new MagicString(code);
|
|
142
|
+
const plugins = propertyNamed(config, "plugins");
|
|
143
|
+
if (plugins) {
|
|
144
|
+
const arr = unwrapTs(plugins.value);
|
|
145
|
+
if (arr.type !== "ArrayExpression") return manual(`\`plugins\` in ${filename} isn't an array literal`);
|
|
146
|
+
const first = arr.elements.find((e) => e);
|
|
147
|
+
if (!first) s.appendLeft(arr.start + 1, call);
|
|
148
|
+
else {
|
|
149
|
+
const indent = indentBefore(code, first.start);
|
|
150
|
+
s.appendLeft(first.start, indent === null ? `${call}, ` : `${call},
|
|
151
|
+
${indent}`);
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
const first = config.properties[0];
|
|
155
|
+
if (!first) s.appendLeft(config.start + 1, ` plugins: [${call}] `);
|
|
156
|
+
else {
|
|
157
|
+
const indent = indentBefore(code, first.start);
|
|
158
|
+
s.appendLeft(first.start, indent === null ? `plugins: [${call}], ` : `plugins: [${call}],
|
|
159
|
+
${indent}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
insertImport(s, ast, isCommonJs(ast) ? `const ${name} = require("react-detective/vite").default;` : `import ${name} from "react-detective/vite";`);
|
|
163
|
+
return changed(s.toString());
|
|
164
|
+
}
|
|
165
|
+
function isCommonJs(ast) {
|
|
166
|
+
return !ast.program.body.some((s) => s.type === "ImportDeclaration" || s.type === "ExportDefaultDeclaration" || s.type === "ExportNamedDeclaration") && !!moduleExports(ast);
|
|
167
|
+
}
|
|
168
|
+
function wrapNextConfig(code, filename, opts = {}) {
|
|
169
|
+
if (code.includes("react-detective/next")) return { status: "already" };
|
|
170
|
+
const ast = parseModule(code, filename);
|
|
171
|
+
if (!ast) return manual(`couldn't parse ${filename}`);
|
|
172
|
+
const extra = optionsLiteral(opts);
|
|
173
|
+
const close = extra ? `, ${extra})` : ")";
|
|
174
|
+
const s = new MagicString(code);
|
|
175
|
+
const exported = defaultExport(ast);
|
|
176
|
+
if (exported) {
|
|
177
|
+
if (exported.type === "ClassDeclaration" || exported.type === "TSInterfaceDeclaration" || exported.type === "TSDeclareFunction") {
|
|
178
|
+
return manual(`${filename} doesn't export a config`);
|
|
179
|
+
}
|
|
180
|
+
s.appendLeft(exported.start, "withDetective(");
|
|
181
|
+
s.appendRight(exported.end, close);
|
|
182
|
+
insertImport(s, ast, `import { withDetective } from "react-detective/next";`);
|
|
183
|
+
return changed(s.toString());
|
|
184
|
+
}
|
|
185
|
+
const assignment = moduleExports(ast);
|
|
186
|
+
if (assignment) {
|
|
187
|
+
s.appendLeft(assignment.right.start, "withDetective(");
|
|
188
|
+
s.appendRight(assignment.right.end, close);
|
|
189
|
+
insertImport(s, ast, `const { withDetective } = require("react-detective/next");`);
|
|
190
|
+
return changed(s.toString());
|
|
191
|
+
}
|
|
192
|
+
return manual(`couldn't find \`export default\` or \`module.exports\` in ${filename}`);
|
|
193
|
+
}
|
|
194
|
+
var DETECTIVE_IMPORT = `import { Detective } from "react-detective/react";`;
|
|
195
|
+
function findJsxElement(node, tag) {
|
|
196
|
+
if (node.type === "JSXElement") {
|
|
197
|
+
const name = node.openingElement.name;
|
|
198
|
+
if (name.type === "JSXIdentifier" && name.name === tag) return node;
|
|
199
|
+
}
|
|
200
|
+
for (const key of Object.keys(node)) {
|
|
201
|
+
if (key === "loc" || key === "leadingComments" || key === "trailingComments" || key === "innerComments") continue;
|
|
202
|
+
const value = node[key];
|
|
203
|
+
const children = Array.isArray(value) ? value : [value];
|
|
204
|
+
for (const child of children) {
|
|
205
|
+
if (child && typeof child === "object" && typeof child.type === "string") {
|
|
206
|
+
const found = findJsxElement(child, tag);
|
|
207
|
+
if (found) return found;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
function addToLayout(code, filename) {
|
|
214
|
+
if (code.includes("react-detective/react")) return { status: "already" };
|
|
215
|
+
const ast = parseModule(code, filename);
|
|
216
|
+
if (!ast) return manual(`couldn't parse ${filename}`);
|
|
217
|
+
const body = findJsxElement(ast.program, "body");
|
|
218
|
+
if (!body?.closingElement) return manual(`couldn't find <body>\u2026</body> in ${filename}`);
|
|
219
|
+
const s = new MagicString(code);
|
|
220
|
+
insertBeforeClosing(s, code, body.closingElement.start, "<Detective />");
|
|
221
|
+
insertImport(s, ast, DETECTIVE_IMPORT);
|
|
222
|
+
return changed(s.toString());
|
|
223
|
+
}
|
|
224
|
+
function addToApp(code, filename) {
|
|
225
|
+
if (code.includes("react-detective/react")) return { status: "already" };
|
|
226
|
+
const ast = parseModule(code, filename);
|
|
227
|
+
if (!ast) return manual(`couldn't parse ${filename}`);
|
|
228
|
+
let exported = defaultExport(ast);
|
|
229
|
+
if (exported) exported = unwrapTs(exported);
|
|
230
|
+
if (exported?.type === "Identifier") exported = findVariable(ast, exported.name) ?? findFunction(ast, exported.name);
|
|
231
|
+
const rendered = exported && returnedExpression(exported);
|
|
232
|
+
if (!rendered) return manual(`couldn't find what the default export of ${filename} renders`);
|
|
233
|
+
const s = new MagicString(code);
|
|
234
|
+
if (rendered.type === "JSXFragment") insertBeforeClosing(s, code, rendered.closingFragment.start, "<Detective />");
|
|
235
|
+
else if (rendered.type === "JSXElement") {
|
|
236
|
+
s.appendLeft(rendered.start, "<>");
|
|
237
|
+
s.appendRight(rendered.end, "<Detective /></>");
|
|
238
|
+
} else return manual(`the default export of ${filename} doesn't return JSX directly`);
|
|
239
|
+
insertImport(s, ast, DETECTIVE_IMPORT);
|
|
240
|
+
return changed(s.toString());
|
|
241
|
+
}
|
|
242
|
+
function newAppFile(typescript) {
|
|
243
|
+
return typescript ? `import type { AppProps } from "next/app";
|
|
244
|
+
${DETECTIVE_IMPORT}
|
|
245
|
+
|
|
246
|
+
export default function App({ Component, pageProps }: AppProps) {
|
|
247
|
+
return (
|
|
248
|
+
<>
|
|
249
|
+
<Component {...pageProps} />
|
|
250
|
+
<Detective />
|
|
251
|
+
</>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
` : `${DETECTIVE_IMPORT}
|
|
255
|
+
|
|
256
|
+
export default function App({ Component, pageProps }) {
|
|
257
|
+
return (
|
|
258
|
+
<>
|
|
259
|
+
<Component {...pageProps} />
|
|
260
|
+
<Detective />
|
|
261
|
+
</>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
`;
|
|
265
|
+
}
|
|
266
|
+
function setEnvVar(text, key, value) {
|
|
267
|
+
const line = `${key}=${value}`;
|
|
268
|
+
const re = new RegExp(`^[ \\t]*(?:export[ \\t]+)?${key}[ \\t]*=.*$`, "m");
|
|
269
|
+
if (re.test(text)) return text.replace(re, line);
|
|
270
|
+
if (!text) return `${line}
|
|
271
|
+
`;
|
|
272
|
+
return `${text}${text.endsWith("\n") ? "" : "\n"}${line}
|
|
273
|
+
`;
|
|
274
|
+
}
|
|
275
|
+
function isGitIgnored(gitignore, file) {
|
|
276
|
+
let ignored = false;
|
|
277
|
+
for (const raw of gitignore.split(/\r?\n/)) {
|
|
278
|
+
let pattern = raw.trim();
|
|
279
|
+
if (!pattern || pattern.startsWith("#")) continue;
|
|
280
|
+
const negate = pattern.startsWith("!");
|
|
281
|
+
if (negate) pattern = pattern.slice(1);
|
|
282
|
+
if (pattern.endsWith("/")) continue;
|
|
283
|
+
pattern = pattern.replace(/^\//, "").replace(/^\*\*\//, "");
|
|
284
|
+
if (pattern.includes("/")) continue;
|
|
285
|
+
const re = new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")}$`);
|
|
286
|
+
if (re.test(file)) ignored = !negate;
|
|
287
|
+
}
|
|
288
|
+
return ignored;
|
|
289
|
+
}
|
|
290
|
+
function addGitIgnore(text, entry) {
|
|
291
|
+
if (!text) return `${entry}
|
|
292
|
+
`;
|
|
293
|
+
return `${text}${text.endsWith("\n") ? "" : "\n"}${entry}
|
|
294
|
+
`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// src/init/index.ts
|
|
298
|
+
var ENV_FILE = ".env.local";
|
|
299
|
+
var MODELS = ["sonnet", "haiku", "opus"];
|
|
300
|
+
var JS_EXTS = [".ts", ".mts", ".js", ".mjs", ".cjs", ".tsx", ".jsx"];
|
|
301
|
+
var color = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
302
|
+
var paint = (code) => (s) => color ? `\x1B[${code}m${s}\x1B[0m` : s;
|
|
303
|
+
var bold = paint(1);
|
|
304
|
+
var dim = paint(2);
|
|
305
|
+
var red = paint(31);
|
|
306
|
+
var green = paint(32);
|
|
307
|
+
var yellow = paint(33);
|
|
308
|
+
var cyan = paint(36);
|
|
309
|
+
async function runInit(argv) {
|
|
310
|
+
const { values } = parseArgs({
|
|
311
|
+
args: argv,
|
|
312
|
+
options: {
|
|
313
|
+
root: { type: "string", short: "r" },
|
|
314
|
+
model: { type: "string", short: "m" },
|
|
315
|
+
yes: { type: "boolean", short: "y" },
|
|
316
|
+
"skip-install": { type: "boolean" },
|
|
317
|
+
help: { type: "boolean", short: "h" }
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
if (values.help) {
|
|
321
|
+
console.log(`Usage: react-detective init [--root .] [--model sonnet|haiku|opus|<id>] [--yes] [--skip-install]
|
|
322
|
+
|
|
323
|
+
Sets up React Detective in a Vite or Next.js project: installs it, adds it to your config,
|
|
324
|
+
and saves your Anthropic API key to ${ENV_FILE}. Shows every change before writing it.
|
|
325
|
+
|
|
326
|
+
--yes, -y accept the defaults and apply without asking (no API key is asked for)
|
|
327
|
+
--skip-install don't install the package`);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const root = path.resolve(values.root ?? process.cwd());
|
|
331
|
+
const pkg = readJson(path.join(root, "package.json"));
|
|
332
|
+
if (!pkg) fail(`No package.json in ${root}. Run this from your project's folder, or pass --root.`);
|
|
333
|
+
console.log(`
|
|
334
|
+
${bold("React Detective setup")} ${dim(root)}
|
|
335
|
+
`);
|
|
336
|
+
const framework = detectFramework(root, pkg);
|
|
337
|
+
if (!framework) {
|
|
338
|
+
console.log(`Couldn't find Vite or Next.js in this project. React Detective works with those two for now.
|
|
339
|
+
Using something else? Open an issue so we know to support it.`);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
console.log(`${green("\u2713")} Found ${framework === "next" ? "Next.js" : "Vite"}`);
|
|
343
|
+
const prompt = values.yes ? null : new Prompt();
|
|
344
|
+
try {
|
|
345
|
+
const model = values.model ?? (prompt ? await prompt.choose("Model for explanations and questions", MODELS, "sonnet") : "sonnet");
|
|
346
|
+
const opts = { model };
|
|
347
|
+
const changes = [];
|
|
348
|
+
const manual2 = [];
|
|
349
|
+
if (framework === "vite") planVite(root, opts, changes, manual2);
|
|
350
|
+
else planNext(root, opts, changes, manual2);
|
|
351
|
+
const pm = detectPackageManager(root);
|
|
352
|
+
const installed = !!(pkg.dependencies?.["react-detective"] || pkg.devDependencies?.["react-detective"]);
|
|
353
|
+
const install = installed || values["skip-install"] ? null : installCommand(pm);
|
|
354
|
+
let installFailed = false;
|
|
355
|
+
if (!changes.length && !install && !manual2.length) console.log(`${green("\u2713")} Already set up`);
|
|
356
|
+
else {
|
|
357
|
+
console.log(`
|
|
358
|
+
${bold("Changes")}`);
|
|
359
|
+
if (install) console.log(`
|
|
360
|
+
${cyan("$")} ${install.join(" ")}`);
|
|
361
|
+
for (const change of changes) printChange(change);
|
|
362
|
+
if (!changes.length && !install) console.log(dim("\nNothing to change automatically."));
|
|
363
|
+
if (changes.length || install) {
|
|
364
|
+
const ok = prompt ? await prompt.confirm("\nApply these changes?", true) : true;
|
|
365
|
+
if (!ok) {
|
|
366
|
+
console.log("Nothing was changed.");
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
for (const change of changes) writeChange(root, change);
|
|
371
|
+
if (install) {
|
|
372
|
+
console.log(`
|
|
373
|
+
${cyan("$")} ${install.join(" ")}`);
|
|
374
|
+
const result = spawnSync(install[0], install.slice(1), { cwd: root, stdio: "inherit", shell: process.platform === "win32" });
|
|
375
|
+
installFailed = result.status !== 0;
|
|
376
|
+
if (installFailed) console.log(red(`
|
|
377
|
+
Install failed. Run it yourself: ${install.join(" ")}`));
|
|
378
|
+
}
|
|
379
|
+
for (const step of manual2) {
|
|
380
|
+
console.log(`
|
|
381
|
+
${yellow("!")} ${bold(step.file)}: ${step.reason}. Add this yourself:
|
|
382
|
+
|
|
383
|
+
${step.snippet}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const hasKey = await askForApiKey(root, prompt);
|
|
387
|
+
console.log(`
|
|
388
|
+
${bold("Next")}`);
|
|
389
|
+
if (!hasKey) console.log(` \u2022 Add ANTHROPIC_API_KEY=sk-ant-... to ${ENV_FILE} when you're ready (make sure it's in .gitignore). No restart needed.`);
|
|
390
|
+
console.log(` \u2022 Start your dev server as usual, then press ${bold("Alt")} (or click the Detective badge) and click a component`);
|
|
391
|
+
console.log(` \u2022 Commit the ${bold(".react-detective/")} folder once it appears so your team shares explanations`);
|
|
392
|
+
if (installFailed || manual2.length) process.exitCode = 1;
|
|
393
|
+
} finally {
|
|
394
|
+
prompt?.close();
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
function planVite(root, opts, changes, manual2) {
|
|
398
|
+
const file = findFile(root, ["vite.config"], [".ts", ".mts", ".js", ".mjs", ".cjs"]);
|
|
399
|
+
const snippet = ` import detective from "react-detective/vite";
|
|
400
|
+
|
|
401
|
+
plugins: [detective(${optionsLiteral(opts)}), react()],`;
|
|
402
|
+
if (!file) {
|
|
403
|
+
manual2.push({ file: "vite.config.ts", reason: "no Vite config found", snippet });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
apply(root, file, addVitePlugin, opts, changes, manual2, snippet);
|
|
407
|
+
}
|
|
408
|
+
function planNext(root, opts, changes, manual2) {
|
|
409
|
+
const configFile = findFile(root, ["next.config"], [".ts", ".mts", ".js", ".mjs", ".cjs"]);
|
|
410
|
+
const extra = optionsLiteral(opts);
|
|
411
|
+
const configSnippet = ` import { withDetective } from "react-detective/next";
|
|
412
|
+
|
|
413
|
+
export default withDetective(nextConfig${extra ? `, ${extra}` : ""});`;
|
|
414
|
+
if (configFile) apply(root, configFile, wrapNextConfig, opts, changes, manual2, configSnippet);
|
|
415
|
+
else changes.push({ file: "next.config.mjs", before: null, after: `import { withDetective } from "react-detective/next";
|
|
416
|
+
|
|
417
|
+
/** @type {import('next').NextConfig} */
|
|
418
|
+
const nextConfig = {};
|
|
419
|
+
|
|
420
|
+
export default withDetective(nextConfig${extra ? `, ${extra}` : ""});
|
|
421
|
+
` });
|
|
422
|
+
const appDir = ["app", "src/app"].find((d) => isDir(path.join(root, d)));
|
|
423
|
+
const pagesDir = ["pages", "src/pages"].find((d) => isDir(path.join(root, d)));
|
|
424
|
+
const layoutSnippet = ` import { Detective } from "react-detective/react";
|
|
425
|
+
|
|
426
|
+
<body>
|
|
427
|
+
{children}
|
|
428
|
+
<Detective />
|
|
429
|
+
</body>`;
|
|
430
|
+
const appSnippet = ` import { Detective } from "react-detective/react";
|
|
431
|
+
|
|
432
|
+
return (
|
|
433
|
+
<>
|
|
434
|
+
<Component {...pageProps} />
|
|
435
|
+
<Detective />
|
|
436
|
+
</>
|
|
437
|
+
);`;
|
|
438
|
+
if (appDir) {
|
|
439
|
+
const layout = findFile(root, [`${appDir}/layout`], JS_EXTS);
|
|
440
|
+
if (layout) apply(root, layout, (code, name) => addToLayout(code, name), opts, changes, manual2, layoutSnippet);
|
|
441
|
+
else manual2.push({ file: `${appDir}/layout.tsx`, reason: "no root layout found", snippet: layoutSnippet });
|
|
442
|
+
}
|
|
443
|
+
if (pagesDir && (!appDir || hasPages(path.join(root, pagesDir)))) {
|
|
444
|
+
const app = findFile(root, [`${pagesDir}/_app`], JS_EXTS);
|
|
445
|
+
if (app) apply(root, app, (code, name) => addToApp(code, name), opts, changes, manual2, appSnippet);
|
|
446
|
+
else {
|
|
447
|
+
const ts = fs.existsSync(path.join(root, "tsconfig.json"));
|
|
448
|
+
changes.push({ file: `${pagesDir}/_app.${ts ? "tsx" : "jsx"}`, before: null, after: newAppFile(ts) });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (!appDir && !pagesDir) manual2.push({ file: "app/layout.tsx", reason: "no app/ or pages/ folder found", snippet: layoutSnippet });
|
|
452
|
+
}
|
|
453
|
+
function apply(root, file, edit, opts, changes, manual2, snippet) {
|
|
454
|
+
const before = fs.readFileSync(path.join(root, file), "utf8");
|
|
455
|
+
const result = edit(before, file, opts);
|
|
456
|
+
if (result.status === "changed") changes.push({ file, before, after: result.code });
|
|
457
|
+
else if (result.status === "already") console.log(`${green("\u2713")} ${file} already set up`);
|
|
458
|
+
else manual2.push({ file, reason: result.reason, snippet });
|
|
459
|
+
}
|
|
460
|
+
function writeChange(root, change) {
|
|
461
|
+
fs.mkdirSync(path.dirname(path.join(root, change.file)), { recursive: true });
|
|
462
|
+
fs.writeFileSync(path.join(root, change.file), change.after);
|
|
463
|
+
console.log(`${green("\u2713")} ${change.before === null ? "Created" : "Updated"} ${change.file}${change.summary ? dim(` (${change.summary})`) : ""}`);
|
|
464
|
+
}
|
|
465
|
+
async function askForApiKey(root, prompt) {
|
|
466
|
+
if (findApiKey(root)) {
|
|
467
|
+
console.log(`${green("\u2713")} Anthropic API key already set`);
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
if (!prompt) return false;
|
|
471
|
+
console.log(`
|
|
472
|
+
${bold("API key")} ${dim("(optional)")}`);
|
|
473
|
+
console.log(`New explanations are written by Claude using your own Anthropic API key. Saved ones work without it.`);
|
|
474
|
+
console.log(dim(`Get one at https://console.anthropic.com/settings/keys. Leave blank to add it later.`));
|
|
475
|
+
const key = await prompt.secret("API key");
|
|
476
|
+
if (!key) return false;
|
|
477
|
+
if (!key.startsWith("sk-ant-")) console.log(yellow(` That doesn't look like an Anthropic key (they start with sk-ant-), saving it anyway.`));
|
|
478
|
+
const changes = [];
|
|
479
|
+
planApiKey(root, key, changes);
|
|
480
|
+
for (const change of changes) writeChange(root, change);
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
function planApiKey(root, key, changes) {
|
|
484
|
+
const envPath = path.join(root, ENV_FILE);
|
|
485
|
+
const before = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf8") : null;
|
|
486
|
+
changes.push({ file: ENV_FILE, before, after: setEnvVar(before ?? "", "ANTHROPIC_API_KEY", key), summary: `ANTHROPIC_API_KEY=${mask(key)}` });
|
|
487
|
+
if (gitIgnores(root, ENV_FILE)) return;
|
|
488
|
+
const gitignorePath = path.join(root, ".gitignore");
|
|
489
|
+
const gi = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, "utf8") : null;
|
|
490
|
+
changes.push({ file: ".gitignore", before: gi, after: addGitIgnore(gi ?? "", ENV_FILE) });
|
|
491
|
+
}
|
|
492
|
+
function gitIgnores(root, file) {
|
|
493
|
+
try {
|
|
494
|
+
execFileSync("git", ["check-ignore", "-q", file], { cwd: root, stdio: "ignore" });
|
|
495
|
+
return true;
|
|
496
|
+
} catch (err) {
|
|
497
|
+
if (err?.status === 1) return false;
|
|
498
|
+
}
|
|
499
|
+
try {
|
|
500
|
+
return isGitIgnored(fs.readFileSync(path.join(root, ".gitignore"), "utf8"), file);
|
|
501
|
+
} catch {
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
var mask = (key) => key.length > 14 ? `${key.slice(0, 7)}\u2026${key.slice(-4)}` : "\u2026";
|
|
506
|
+
function detectFramework(root, pkg) {
|
|
507
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
508
|
+
if (deps.next || findFile(root, ["next.config"], JS_EXTS)) return "next";
|
|
509
|
+
if (deps.vite || findFile(root, ["vite.config"], JS_EXTS)) return "vite";
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
function detectPackageManager(root) {
|
|
513
|
+
const lockfiles = [
|
|
514
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
515
|
+
["yarn.lock", "yarn"],
|
|
516
|
+
["bun.lock", "bun"],
|
|
517
|
+
["bun.lockb", "bun"],
|
|
518
|
+
["package-lock.json", "npm"]
|
|
519
|
+
];
|
|
520
|
+
for (let dir = root; ; dir = path.dirname(dir)) {
|
|
521
|
+
for (const [file, pm] of lockfiles) if (fs.existsSync(path.join(dir, file))) return pm;
|
|
522
|
+
if (path.dirname(dir) === dir) break;
|
|
523
|
+
}
|
|
524
|
+
const agent = process.env.npm_config_user_agent ?? "";
|
|
525
|
+
return ["pnpm", "yarn", "bun"].find((pm) => agent.startsWith(pm)) ?? "npm";
|
|
526
|
+
}
|
|
527
|
+
function installCommand(pm) {
|
|
528
|
+
if (pm === "npm") return ["npm", "install", "--save-dev", "react-detective"];
|
|
529
|
+
return [pm, "add", pm === "bun" ? "-d" : "-D", "react-detective"];
|
|
530
|
+
}
|
|
531
|
+
function findFile(root, bases, exts) {
|
|
532
|
+
for (const base of bases) for (const ext of exts) if (fs.existsSync(path.join(root, base + ext))) return base + ext;
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
function hasPages(dir) {
|
|
536
|
+
return fs.readdirSync(dir).some((name) => name !== "api" && !name.startsWith("_"));
|
|
537
|
+
}
|
|
538
|
+
var isDir = (p) => fs.statSync(p, { throwIfNoEntry: false })?.isDirectory() ?? false;
|
|
539
|
+
function readJson(p) {
|
|
540
|
+
try {
|
|
541
|
+
return JSON.parse(fs.readFileSync(p, "utf8"));
|
|
542
|
+
} catch {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
function fail(message) {
|
|
547
|
+
console.error(red(message));
|
|
548
|
+
process.exit(1);
|
|
549
|
+
}
|
|
550
|
+
function printChange(change) {
|
|
551
|
+
const label = change.before === null ? green("new") : yellow("edit");
|
|
552
|
+
console.log(`
|
|
553
|
+
${label} ${bold(change.file)}`);
|
|
554
|
+
if (change.summary) {
|
|
555
|
+
console.log(` ${green(`+ ${change.summary}`)}`);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const patch = structuredPatch(change.file, change.file, change.before ?? "", change.after, "", "", { context: 2 });
|
|
559
|
+
for (const hunk of patch.hunks) {
|
|
560
|
+
console.log(dim(` @@ line ${hunk.newStart}`));
|
|
561
|
+
for (const line of hunk.lines) {
|
|
562
|
+
if (line.startsWith("\\")) continue;
|
|
563
|
+
const text = ` ${line[0]} ${line.slice(1)}`;
|
|
564
|
+
console.log(line[0] === "+" ? green(text) : line[0] === "-" ? red(text) : dim(text));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
var Prompt = class {
|
|
569
|
+
rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: !!process.stdin.isTTY });
|
|
570
|
+
lines = [];
|
|
571
|
+
waiting = null;
|
|
572
|
+
closed = false;
|
|
573
|
+
muted = false;
|
|
574
|
+
constructor() {
|
|
575
|
+
this.rl.on("line", (line) => {
|
|
576
|
+
if (this.waiting) this.take(line);
|
|
577
|
+
else this.lines.push(line);
|
|
578
|
+
});
|
|
579
|
+
this.rl.on("close", () => {
|
|
580
|
+
this.closed = true;
|
|
581
|
+
this.take(null);
|
|
582
|
+
});
|
|
583
|
+
const rl = this.rl;
|
|
584
|
+
const write = rl._writeToOutput?.bind(rl);
|
|
585
|
+
if (write) {
|
|
586
|
+
rl._writeToOutput = (s) => {
|
|
587
|
+
if (!this.muted) write(s);
|
|
588
|
+
else if (s.includes("\n")) write("\n");
|
|
589
|
+
else if (s.length === 1) write("*");
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
take(line) {
|
|
594
|
+
const resolve = this.waiting;
|
|
595
|
+
this.waiting = null;
|
|
596
|
+
resolve?.(line);
|
|
597
|
+
}
|
|
598
|
+
async ask(question, muted = false) {
|
|
599
|
+
this.rl.setPrompt(question);
|
|
600
|
+
this.rl.prompt();
|
|
601
|
+
let line;
|
|
602
|
+
if (this.lines.length) line = this.lines.shift();
|
|
603
|
+
else if (this.closed) line = null;
|
|
604
|
+
else {
|
|
605
|
+
this.muted = muted;
|
|
606
|
+
line = await new Promise((resolve) => this.waiting = resolve);
|
|
607
|
+
this.muted = false;
|
|
608
|
+
}
|
|
609
|
+
if (!process.stdin.isTTY) process.stdout.write(`${line === null ? "" : muted ? "*".repeat(line.length) : line}
|
|
610
|
+
`);
|
|
611
|
+
return (line ?? "").trim();
|
|
612
|
+
}
|
|
613
|
+
async choose(question, options, fallback) {
|
|
614
|
+
const list = options.map((o) => o === fallback ? bold(o) : o).join(" / ");
|
|
615
|
+
return await this.ask(`${cyan("?")} ${question} (${list}, or a model ID) ${dim(fallback)} `) || fallback;
|
|
616
|
+
}
|
|
617
|
+
async confirm(question, fallback) {
|
|
618
|
+
const answer = (await this.ask(`${question} ${dim(fallback ? "(Y/n)" : "(y/N)")} `)).toLowerCase();
|
|
619
|
+
return answer ? answer.startsWith("y") : fallback;
|
|
620
|
+
}
|
|
621
|
+
secret(question) {
|
|
622
|
+
return this.ask(`${cyan("?")} ${question}: `, true);
|
|
623
|
+
}
|
|
624
|
+
close() {
|
|
625
|
+
this.rl.close();
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
export {
|
|
629
|
+
runInit
|
|
630
|
+
};
|