crumbtrail 0.1.0 → 0.2.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/LICENSE +21 -0
- package/dist/bug-widget-LGIRLS76-WB3MQGAH.js +199 -0
- package/dist/chunk-MZDASWQ7.js +1570 -0
- package/dist/cli.cjs +440 -6125
- package/dist/cli.d.cts +35 -3
- package/dist/cli.d.ts +35 -3
- package/dist/cli.js +149 -26
- package/dist/{executor-DKDrkhxM.d.cts → executor-CW6Q5mhH.d.cts} +10 -5
- package/dist/{executor-DKDrkhxM.d.ts → executor-CW6Q5mhH.d.ts} +10 -5
- package/dist/index.cjs +145 -5952
- package/dist/index.d.cts +14 -3
- package/dist/index.d.ts +14 -3
- package/dist/index.js +3 -1
- package/package.json +12 -8
- package/dist/chunk-UFJ2BODB.js +0 -7401
|
@@ -0,0 +1,1570 @@
|
|
|
1
|
+
// src/detect.ts
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
function safeRead(file) {
|
|
5
|
+
try {
|
|
6
|
+
return readFileSync(file, "utf8");
|
|
7
|
+
} catch {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function readPackageJson(dir) {
|
|
12
|
+
const text = safeRead(path.join(dir, "package.json"));
|
|
13
|
+
if (text == null) return null;
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(text);
|
|
16
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function isDir(p) {
|
|
22
|
+
try {
|
|
23
|
+
return statSync(p).isDirectory();
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function isFile(p) {
|
|
29
|
+
try {
|
|
30
|
+
return statSync(p).isFile();
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function detectPackageManager(startDir) {
|
|
36
|
+
let dir = path.resolve(startDir);
|
|
37
|
+
while (true) {
|
|
38
|
+
if (existsSync(path.join(dir, "pnpm-lock.yaml"))) return "pnpm";
|
|
39
|
+
if (existsSync(path.join(dir, "bun.lockb")) || existsSync(path.join(dir, "bun.lock")))
|
|
40
|
+
return "bun";
|
|
41
|
+
if (existsSync(path.join(dir, "yarn.lock"))) return "yarn";
|
|
42
|
+
if (existsSync(path.join(dir, "package-lock.json"))) return "npm";
|
|
43
|
+
const parent = path.dirname(dir);
|
|
44
|
+
if (parent === dir) return null;
|
|
45
|
+
dir = parent;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function parsePnpmWorkspace(text) {
|
|
49
|
+
const out = [];
|
|
50
|
+
let inPackages = false;
|
|
51
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
52
|
+
const line = raw.replace(/#.*$/, "");
|
|
53
|
+
if (/^packages\s*:/.test(line)) {
|
|
54
|
+
inPackages = true;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!inPackages) continue;
|
|
58
|
+
const item = line.match(/^\s*-\s*["']?([^"'#]+?)["']?\s*$/);
|
|
59
|
+
if (item) {
|
|
60
|
+
out.push(item[1].trim());
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (/^\S/.test(line)) inPackages = false;
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
function expandWorkspaceGlobs(cwd, patterns) {
|
|
68
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
69
|
+
for (const pattern of patterns) {
|
|
70
|
+
if (pattern.startsWith("!")) continue;
|
|
71
|
+
const wildcard = pattern.endsWith("/*") || pattern.endsWith("/**");
|
|
72
|
+
if (wildcard) {
|
|
73
|
+
const base = path.join(cwd, pattern.replace(/\/\*\*?$/, ""));
|
|
74
|
+
if (!isDir(base)) continue;
|
|
75
|
+
for (const entry of readdirSync(base)) {
|
|
76
|
+
const full = path.join(base, entry);
|
|
77
|
+
if (isDir(full) && existsSync(path.join(full, "package.json"))) {
|
|
78
|
+
dirs.add(full);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} else if (!pattern.includes("*")) {
|
|
82
|
+
const full = path.join(cwd, pattern);
|
|
83
|
+
if (existsSync(path.join(full, "package.json"))) dirs.add(full);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return [...dirs].sort();
|
|
87
|
+
}
|
|
88
|
+
function detectWorkspaces(cwd, pkg) {
|
|
89
|
+
let patterns = null;
|
|
90
|
+
const wsYaml = path.join(cwd, "pnpm-workspace.yaml");
|
|
91
|
+
const wsYamlText = safeRead(wsYaml);
|
|
92
|
+
if (wsYamlText != null) {
|
|
93
|
+
patterns = parsePnpmWorkspace(wsYamlText);
|
|
94
|
+
} else if (pkg?.workspaces) {
|
|
95
|
+
patterns = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces.packages ?? [];
|
|
96
|
+
}
|
|
97
|
+
if (!patterns) return detectNxWorkspaces(cwd);
|
|
98
|
+
return expandWorkspaceGlobs(cwd, patterns).map((dir) => {
|
|
99
|
+
const p = readPackageJson(dir);
|
|
100
|
+
return { name: p?.name ?? path.basename(dir), dir };
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
function readJsonObject(file) {
|
|
104
|
+
const text = safeRead(file);
|
|
105
|
+
if (text == null) return null;
|
|
106
|
+
try {
|
|
107
|
+
const parsed = JSON.parse(text);
|
|
108
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function nxProjectName(dir) {
|
|
114
|
+
const proj = readJsonObject(path.join(dir, "project.json"));
|
|
115
|
+
if (proj && typeof proj.name === "string" && proj.name) return proj.name;
|
|
116
|
+
const p = readPackageJson(dir);
|
|
117
|
+
if (p?.name) return p.name;
|
|
118
|
+
return path.basename(dir);
|
|
119
|
+
}
|
|
120
|
+
function detectNxWorkspaces(cwd) {
|
|
121
|
+
if (!isFile(path.join(cwd, "nx.json"))) return null;
|
|
122
|
+
const nx = readJsonObject(path.join(cwd, "nx.json"));
|
|
123
|
+
const layout = nx?.workspaceLayout ?? {};
|
|
124
|
+
const appsDir = typeof layout.appsDir === "string" ? layout.appsDir : "apps";
|
|
125
|
+
const libsDir = typeof layout.libsDir === "string" ? layout.libsDir : "libs";
|
|
126
|
+
const found = /* @__PURE__ */ new Map();
|
|
127
|
+
if (isFile(path.join(cwd, "project.json"))) {
|
|
128
|
+
found.set(cwd, { name: nxProjectName(cwd), dir: cwd });
|
|
129
|
+
}
|
|
130
|
+
for (const base of /* @__PURE__ */ new Set([appsDir, libsDir])) {
|
|
131
|
+
const baseDir = path.join(cwd, base);
|
|
132
|
+
if (!isDir(baseDir)) continue;
|
|
133
|
+
for (const entry of readdirSync(baseDir)) {
|
|
134
|
+
const full = path.join(baseDir, entry);
|
|
135
|
+
if (!isDir(full)) continue;
|
|
136
|
+
if (isFile(path.join(full, "project.json")) || existsSync(path.join(full, "package.json"))) {
|
|
137
|
+
found.set(full, { name: nxProjectName(full), dir: full });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (found.size === 0) return null;
|
|
142
|
+
return [...found.values()].sort((a, b) => a.dir.localeCompare(b.dir));
|
|
143
|
+
}
|
|
144
|
+
function resolveViteEntry(cwd) {
|
|
145
|
+
const html = safeRead(path.join(cwd, "index.html"));
|
|
146
|
+
if (html == null) return null;
|
|
147
|
+
const tagRe = /<script\b[^>]*>/gi;
|
|
148
|
+
let m;
|
|
149
|
+
while ((m = tagRe.exec(html)) !== null) {
|
|
150
|
+
const tag = m[0];
|
|
151
|
+
if (!/\btype=["']module["']/i.test(tag)) continue;
|
|
152
|
+
const src = tag.match(/\bsrc=["']([^"']+)["']/i);
|
|
153
|
+
if (!src) continue;
|
|
154
|
+
let rel = src[1];
|
|
155
|
+
if (/^https?:/i.test(rel)) continue;
|
|
156
|
+
rel = rel.replace(/^\.?\//, "");
|
|
157
|
+
const full = path.join(cwd, rel);
|
|
158
|
+
if (isFile(full)) return full;
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
function resolveReactNativeEntry(cwd) {
|
|
163
|
+
const candidates = [
|
|
164
|
+
path.join("app", "_layout.tsx"),
|
|
165
|
+
path.join("app", "_layout.jsx"),
|
|
166
|
+
path.join("app", "_layout.js"),
|
|
167
|
+
path.join("src", "app", "_layout.tsx"),
|
|
168
|
+
path.join("src", "app", "_layout.jsx"),
|
|
169
|
+
path.join("src", "app", "_layout.js"),
|
|
170
|
+
"App.tsx",
|
|
171
|
+
"App.jsx",
|
|
172
|
+
"App.ts",
|
|
173
|
+
"App.js",
|
|
174
|
+
"index.js",
|
|
175
|
+
"index.ts"
|
|
176
|
+
];
|
|
177
|
+
for (const c of candidates) {
|
|
178
|
+
const full = path.join(cwd, c);
|
|
179
|
+
if (isFile(full)) return full;
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
function parseNodeInvocation(script) {
|
|
184
|
+
const m = script.match(
|
|
185
|
+
/\b(?:node|nodemon|ts-node|tsx)\b(?:\s+(?:--?[^\s]+))*\s+([^\s&|]+\.[cm]?[jt]s)\b/
|
|
186
|
+
);
|
|
187
|
+
return m ? m[1] : null;
|
|
188
|
+
}
|
|
189
|
+
function resolveNodeEntry(cwd, pkg) {
|
|
190
|
+
const candidates = [];
|
|
191
|
+
if (typeof pkg.main === "string") candidates.push(pkg.main);
|
|
192
|
+
if (typeof pkg.bin === "string") candidates.push(pkg.bin);
|
|
193
|
+
else if (pkg.bin && typeof pkg.bin === "object") {
|
|
194
|
+
const first = Object.values(pkg.bin)[0];
|
|
195
|
+
if (first) candidates.push(first);
|
|
196
|
+
}
|
|
197
|
+
if (pkg.scripts?.start) {
|
|
198
|
+
const fromStart = parseNodeInvocation(pkg.scripts.start);
|
|
199
|
+
if (fromStart) candidates.push(fromStart);
|
|
200
|
+
}
|
|
201
|
+
for (const c of candidates) {
|
|
202
|
+
const full = path.join(cwd, c);
|
|
203
|
+
if (isFile(full)) return full;
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
function resolveNestEntry(cwd) {
|
|
208
|
+
const candidates = [path.join("src", "main.ts"), path.join("src", "main.js")];
|
|
209
|
+
for (const c of candidates) {
|
|
210
|
+
const full = path.join(cwd, c);
|
|
211
|
+
if (isFile(full)) return full;
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
function resolveRemixEntry(cwd) {
|
|
216
|
+
const candidates = [
|
|
217
|
+
path.join("app", "entry.client.tsx"),
|
|
218
|
+
path.join("app", "entry.client.jsx"),
|
|
219
|
+
path.join("app", "entry.client.js")
|
|
220
|
+
];
|
|
221
|
+
for (const c of candidates) {
|
|
222
|
+
const full = path.join(cwd, c);
|
|
223
|
+
if (isFile(full)) return full;
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
function resolveAngularEntry(cwd) {
|
|
228
|
+
const full = path.join(cwd, "src", "main.ts");
|
|
229
|
+
return isFile(full) ? full : null;
|
|
230
|
+
}
|
|
231
|
+
function mergedDeps(pkg) {
|
|
232
|
+
if (!pkg) return {};
|
|
233
|
+
return { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
|
|
234
|
+
}
|
|
235
|
+
function textMentions(text, token) {
|
|
236
|
+
return new RegExp(`\\b${token}\\b`, "i").test(text);
|
|
237
|
+
}
|
|
238
|
+
function resolveOtlpStack(root) {
|
|
239
|
+
if (isFile(path.join(root, "manage.py"))) {
|
|
240
|
+
return {
|
|
241
|
+
stack: "django",
|
|
242
|
+
reason: "found manage.py (Django) \u2014 OTLP guidance"
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
const pyText = [
|
|
246
|
+
safeRead(path.join(root, "pyproject.toml")),
|
|
247
|
+
safeRead(path.join(root, "requirements.txt"))
|
|
248
|
+
].filter((t) => t != null).join("\n");
|
|
249
|
+
if (pyText) {
|
|
250
|
+
if (textMentions(pyText, "fastapi")) {
|
|
251
|
+
return {
|
|
252
|
+
stack: "fastapi",
|
|
253
|
+
reason: "found a `fastapi` dependency in pyproject.toml/requirements.txt \u2014 OTLP guidance"
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
if (textMentions(pyText, "flask")) {
|
|
257
|
+
return {
|
|
258
|
+
stack: "flask",
|
|
259
|
+
reason: "found a `flask` dependency in pyproject.toml/requirements.txt \u2014 OTLP guidance"
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (isFile(path.join(root, "go.mod"))) {
|
|
264
|
+
return { stack: "go", reason: "found go.mod (Go) \u2014 OTLP guidance" };
|
|
265
|
+
}
|
|
266
|
+
const gemfile = safeRead(path.join(root, "Gemfile"));
|
|
267
|
+
if (gemfile != null && textMentions(gemfile, "rails")) {
|
|
268
|
+
return {
|
|
269
|
+
stack: "rails",
|
|
270
|
+
reason: "found a Gemfile referencing rails \u2014 OTLP guidance"
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
if (readdirSync(root).some((entry) => entry.endsWith(".csproj"))) {
|
|
275
|
+
return {
|
|
276
|
+
stack: "dotnet",
|
|
277
|
+
reason: "found a *.csproj file (.NET) \u2014 OTLP guidance"
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
} catch {
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
var DENO_UNSUPPORTED_REASON = "Deno projects aren't supported yet";
|
|
285
|
+
var DOCKER_MARKER_FILES = [
|
|
286
|
+
"docker-compose.yml",
|
|
287
|
+
"docker-compose.yaml",
|
|
288
|
+
"compose.yml",
|
|
289
|
+
"compose.yaml",
|
|
290
|
+
"Dockerfile"
|
|
291
|
+
];
|
|
292
|
+
var DOCKER_COMING_SOON_NOTE = "Docker/Compose files found \u2014 infra evidence sources (e.g. docker) are coming soon.";
|
|
293
|
+
function hasDenoMarker(root) {
|
|
294
|
+
return isFile(path.join(root, "deno.json")) || isFile(path.join(root, "deno.jsonc"));
|
|
295
|
+
}
|
|
296
|
+
function hasDockerMarker(root) {
|
|
297
|
+
return DOCKER_MARKER_FILES.some((f) => isFile(path.join(root, f)));
|
|
298
|
+
}
|
|
299
|
+
var RECIPE_MATCHERS = [
|
|
300
|
+
[
|
|
301
|
+
// Ordered FIRST, ahead of every frontend-framework matcher. A Tauri app's
|
|
302
|
+
// frontend framework is incidental — a Tauri+Vite or Tauri+SvelteKit app
|
|
303
|
+
// carries `vite`/`@sveltejs/kit` deps and would otherwise match those
|
|
304
|
+
// matchers first, but we want the TauriTransport (local Rust store) wiring.
|
|
305
|
+
// The `@tauri-apps/*` dep + `src-tauri/` directory pair is the most specific
|
|
306
|
+
// signal, so it must win. The frontend entry is resolved like vite-spa.
|
|
307
|
+
"tauri",
|
|
308
|
+
({ root, deps, reasons }) => {
|
|
309
|
+
const hasTauriDep = "@tauri-apps/api" in deps || "@tauri-apps/cli" in deps;
|
|
310
|
+
if (!hasTauriDep || !existsSync(path.join(root, "src-tauri")))
|
|
311
|
+
return null;
|
|
312
|
+
reasons.push("found `@tauri-apps/*` dependency + src-tauri/ directory");
|
|
313
|
+
const entryFile = resolveViteEntry(root);
|
|
314
|
+
if (!entryFile)
|
|
315
|
+
reasons.push(
|
|
316
|
+
"could not resolve a local frontend entry from index.html"
|
|
317
|
+
);
|
|
318
|
+
return { entryFile, nextVersion: null };
|
|
319
|
+
}
|
|
320
|
+
],
|
|
321
|
+
[
|
|
322
|
+
"next",
|
|
323
|
+
({ deps, reasons }) => {
|
|
324
|
+
if (!("next" in deps)) return null;
|
|
325
|
+
reasons.push("found `next` dependency");
|
|
326
|
+
return { entryFile: null, nextVersion: deps.next ?? null };
|
|
327
|
+
}
|
|
328
|
+
],
|
|
329
|
+
[
|
|
330
|
+
"sveltekit",
|
|
331
|
+
({ deps, reasons }) => {
|
|
332
|
+
if (!("@sveltejs/kit" in deps)) return null;
|
|
333
|
+
reasons.push("found `@sveltejs/kit` dependency");
|
|
334
|
+
return { entryFile: null, nextVersion: null };
|
|
335
|
+
}
|
|
336
|
+
],
|
|
337
|
+
[
|
|
338
|
+
"nuxt",
|
|
339
|
+
({ deps, reasons }) => {
|
|
340
|
+
if (!("nuxt" in deps)) return null;
|
|
341
|
+
reasons.push("found `nuxt` dependency");
|
|
342
|
+
return { entryFile: null, nextVersion: null };
|
|
343
|
+
}
|
|
344
|
+
],
|
|
345
|
+
[
|
|
346
|
+
// Ordered before vite-spa (Remix runs on Vite) and before express (Remix
|
|
347
|
+
// custom-server apps carry express). Matches classic Remix v1/v2 via any
|
|
348
|
+
// `@remix-run/*` runtime dep, or RR7 framework mode via the
|
|
349
|
+
// `react-router` + `@react-router/dev` pair. A plain `react-router-dom` SPA
|
|
350
|
+
// (no `@react-router/dev`) deliberately falls through to vite-spa.
|
|
351
|
+
"remix",
|
|
352
|
+
({ root, deps, reasons }) => {
|
|
353
|
+
const classicRemix = "@remix-run/react" in deps || "@remix-run/node" in deps || "@remix-run/serve" in deps;
|
|
354
|
+
const rr7 = "react-router" in deps && "@react-router/dev" in deps;
|
|
355
|
+
if (!classicRemix && !rr7) return null;
|
|
356
|
+
reasons.push(
|
|
357
|
+
classicRemix ? "found `@remix-run/*` dependency" : "found `react-router` + `@react-router/dev` (React Router v7 framework mode)"
|
|
358
|
+
);
|
|
359
|
+
const entryFile = resolveRemixEntry(root);
|
|
360
|
+
if (!entryFile) reasons.push("could not resolve app/entry.client.*");
|
|
361
|
+
return { entryFile, nextVersion: null };
|
|
362
|
+
}
|
|
363
|
+
],
|
|
364
|
+
[
|
|
365
|
+
// Ordered before vite-spa (Astro is built on Vite). There is no single
|
|
366
|
+
// deterministic client entry, so the recipe always hands off a guided
|
|
367
|
+
// snippet — entry stays null by design (not ambiguity).
|
|
368
|
+
"astro",
|
|
369
|
+
({ deps, reasons }) => {
|
|
370
|
+
if (!("astro" in deps)) return null;
|
|
371
|
+
reasons.push("found `astro` dependency");
|
|
372
|
+
return { entryFile: null, nextVersion: null };
|
|
373
|
+
}
|
|
374
|
+
],
|
|
375
|
+
[
|
|
376
|
+
// Ordered before vite-spa (safety; Angular has no root index.html so it
|
|
377
|
+
// wouldn't match vite-spa anyway, but keep precedence explicit). The
|
|
378
|
+
// `@angular/core` dep alone is a sufficiently specific signal; angular.json
|
|
379
|
+
// is recorded only as a confirming reason.
|
|
380
|
+
"angular",
|
|
381
|
+
({ root, deps, reasons }) => {
|
|
382
|
+
if (!("@angular/core" in deps)) return null;
|
|
383
|
+
reasons.push("found `@angular/core` dependency");
|
|
384
|
+
if (existsSync(path.join(root, "angular.json")))
|
|
385
|
+
reasons.push("found angular.json");
|
|
386
|
+
const entryFile = resolveAngularEntry(root);
|
|
387
|
+
if (!entryFile) reasons.push("could not resolve src/main.ts");
|
|
388
|
+
return { entryFile, nextVersion: null };
|
|
389
|
+
}
|
|
390
|
+
],
|
|
391
|
+
[
|
|
392
|
+
"vite-spa",
|
|
393
|
+
({ root, deps, reasons }) => {
|
|
394
|
+
if (!("vite" in deps && existsSync(path.join(root, "index.html"))))
|
|
395
|
+
return null;
|
|
396
|
+
reasons.push("found `vite` dependency + index.html");
|
|
397
|
+
const entryFile = resolveViteEntry(root);
|
|
398
|
+
if (!entryFile)
|
|
399
|
+
reasons.push("could not resolve a local module entry from index.html");
|
|
400
|
+
return { entryFile, nextVersion: null };
|
|
401
|
+
}
|
|
402
|
+
],
|
|
403
|
+
[
|
|
404
|
+
// Ordered before every backend-JS matcher: Nest apps carry
|
|
405
|
+
// `@nestjs/platform-express` / `@nestjs/platform-fastify`, which pull in
|
|
406
|
+
// express/fastify — so this must win over those matchers. The bootstrap
|
|
407
|
+
// entry lives at src/main.ts (resolveNodeEntry can't find it).
|
|
408
|
+
"nestjs",
|
|
409
|
+
({ root, deps, reasons }) => {
|
|
410
|
+
if (!("@nestjs/core" in deps)) return null;
|
|
411
|
+
reasons.push("found `@nestjs/core` dependency");
|
|
412
|
+
const entryFile = resolveNestEntry(root);
|
|
413
|
+
if (!entryFile) reasons.push("could not resolve src/main.ts");
|
|
414
|
+
return { entryFile, nextVersion: null };
|
|
415
|
+
}
|
|
416
|
+
],
|
|
417
|
+
[
|
|
418
|
+
"express",
|
|
419
|
+
({ root, deps, pkg, reasons }) => {
|
|
420
|
+
if (!("express" in deps) || !pkg) return null;
|
|
421
|
+
reasons.push("found `express` dependency");
|
|
422
|
+
return { entryFile: resolveNodeEntry(root, pkg), nextVersion: null };
|
|
423
|
+
}
|
|
424
|
+
],
|
|
425
|
+
[
|
|
426
|
+
"hono",
|
|
427
|
+
({ root, deps, pkg, reasons }) => {
|
|
428
|
+
if (!("hono" in deps) || !pkg) return null;
|
|
429
|
+
reasons.push("found `hono` dependency");
|
|
430
|
+
return { entryFile: resolveNodeEntry(root, pkg), nextVersion: null };
|
|
431
|
+
}
|
|
432
|
+
],
|
|
433
|
+
[
|
|
434
|
+
"fastify",
|
|
435
|
+
({ root, deps, pkg, reasons }) => {
|
|
436
|
+
if (!("fastify" in deps) || !pkg) return null;
|
|
437
|
+
reasons.push("found `fastify` dependency");
|
|
438
|
+
return { entryFile: resolveNodeEntry(root, pkg), nextVersion: null };
|
|
439
|
+
}
|
|
440
|
+
],
|
|
441
|
+
[
|
|
442
|
+
// Placed before the generic `node` fallback. RN doesn't carry
|
|
443
|
+
// next/sveltekit/nuxt/vite+index.html deps, so it only collides with the
|
|
444
|
+
// node matcher (an RN `package.json` `main` can resolve a node entry). The
|
|
445
|
+
// `expo` / `react-native` dep is the specific signal that must win.
|
|
446
|
+
"react-native",
|
|
447
|
+
({ root, deps, reasons }) => {
|
|
448
|
+
if (!("expo" in deps || "react-native" in deps)) return null;
|
|
449
|
+
reasons.push("found `expo` or `react-native` dependency");
|
|
450
|
+
const entryFile = resolveReactNativeEntry(root);
|
|
451
|
+
if (!entryFile)
|
|
452
|
+
reasons.push("could not resolve an App/_layout/index entry");
|
|
453
|
+
return { entryFile, nextVersion: null };
|
|
454
|
+
}
|
|
455
|
+
],
|
|
456
|
+
[
|
|
457
|
+
"node",
|
|
458
|
+
({ root, pkg, reasons }) => {
|
|
459
|
+
if (!pkg) return null;
|
|
460
|
+
const nodeEntry = resolveNodeEntry(root, pkg);
|
|
461
|
+
if (!nodeEntry) return null;
|
|
462
|
+
reasons.push("resolved a Node server entry from package.json");
|
|
463
|
+
return { entryFile: nodeEntry, nextVersion: null };
|
|
464
|
+
}
|
|
465
|
+
],
|
|
466
|
+
[
|
|
467
|
+
// Relaxed vite-spa fallback. The strict vite-spa matcher above requires a
|
|
468
|
+
// root index.html; a Vite project whose index.html lives elsewhere (or is
|
|
469
|
+
// absent) would otherwise fall through to "no recipe matched". This relaxed
|
|
470
|
+
// matcher keys on the `vite` dep alone, but is ordered AFTER every backend
|
|
471
|
+
// matcher (node included) so a project that carries express/hono/etc. plus a
|
|
472
|
+
// vite devDep still detects its backend framework. Entry resolution still
|
|
473
|
+
// needs a root index.html (resolveViteEntry), so a rootless project yields a
|
|
474
|
+
// null entry → the guided fallback-ai plan, which is strictly better DX than
|
|
475
|
+
// no match at all.
|
|
476
|
+
"vite-spa",
|
|
477
|
+
({ root, deps, reasons }) => {
|
|
478
|
+
if (!("vite" in deps)) return null;
|
|
479
|
+
reasons.push(
|
|
480
|
+
"found `vite` dependency (no root index.html \u2014 guided fallback)"
|
|
481
|
+
);
|
|
482
|
+
const entryFile = resolveViteEntry(root);
|
|
483
|
+
if (!entryFile)
|
|
484
|
+
reasons.push("could not resolve a local module entry from index.html");
|
|
485
|
+
return { entryFile, nextVersion: null };
|
|
486
|
+
}
|
|
487
|
+
],
|
|
488
|
+
[
|
|
489
|
+
// Ordered LAST — the least-specific fallback. These non-JS backend markers
|
|
490
|
+
// are filesystem-only (no package.json needed) and must never pre-empt a JS
|
|
491
|
+
// project, so they sit strictly after the generic `node` matcher. A single
|
|
492
|
+
// `otlp` recipe carries the detected Stack out via `otlpStack`.
|
|
493
|
+
"otlp",
|
|
494
|
+
({ root, reasons }) => {
|
|
495
|
+
const hit = resolveOtlpStack(root);
|
|
496
|
+
if (!hit) return null;
|
|
497
|
+
reasons.push(hit.reason);
|
|
498
|
+
return { entryFile: null, nextVersion: null, otlpStack: hit.stack };
|
|
499
|
+
}
|
|
500
|
+
]
|
|
501
|
+
];
|
|
502
|
+
function matchRecipe(ctx) {
|
|
503
|
+
for (const [recipe, matcher] of RECIPE_MATCHERS) {
|
|
504
|
+
const hit = matcher(ctx);
|
|
505
|
+
if (hit)
|
|
506
|
+
return {
|
|
507
|
+
recipe,
|
|
508
|
+
entryFile: hit.entryFile,
|
|
509
|
+
nextVersion: hit.nextVersion,
|
|
510
|
+
otlpStack: hit.otlpStack ?? null
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
return { recipe: null, entryFile: null, nextVersion: null, otlpStack: null };
|
|
514
|
+
}
|
|
515
|
+
function detect(cwd) {
|
|
516
|
+
const reasons = [];
|
|
517
|
+
const root = path.resolve(cwd);
|
|
518
|
+
const packageJsonPath = existsSync(path.join(root, "package.json")) ? path.join(root, "package.json") : null;
|
|
519
|
+
const pkg = packageJsonPath ? readPackageJson(root) : null;
|
|
520
|
+
const packageManager = detectPackageManager(root);
|
|
521
|
+
const workspaces = detectWorkspaces(root, pkg);
|
|
522
|
+
const isMonorepo = !!workspaces && workspaces.length > 0;
|
|
523
|
+
const deps = mergedDeps(pkg);
|
|
524
|
+
const notes = [];
|
|
525
|
+
if (hasDockerMarker(root)) notes.push(DOCKER_COMING_SOON_NOTE);
|
|
526
|
+
const match = matchRecipe({ root, deps, pkg, reasons });
|
|
527
|
+
const recipe = match.recipe;
|
|
528
|
+
let entryFile = match.entryFile;
|
|
529
|
+
const nextVersion = match.nextVersion;
|
|
530
|
+
const otlpStack = match.otlpStack;
|
|
531
|
+
let ambiguous = false;
|
|
532
|
+
if (isMonorepo) {
|
|
533
|
+
ambiguous = true;
|
|
534
|
+
entryFile = null;
|
|
535
|
+
reasons.push(
|
|
536
|
+
`monorepo root with ${workspaces.length} workspace package(s); pick a workspace to wire`
|
|
537
|
+
);
|
|
538
|
+
} else if (!recipe) {
|
|
539
|
+
ambiguous = true;
|
|
540
|
+
if (!pkg && hasDenoMarker(root)) reasons.push(DENO_UNSUPPORTED_REASON);
|
|
541
|
+
else reasons.push("no recipe matched");
|
|
542
|
+
} else if ((recipe === "tauri" || recipe === "remix" || recipe === "angular" || recipe === "vite-spa" || recipe === "nestjs" || recipe === "express" || recipe === "hono" || recipe === "fastify" || recipe === "react-native" || recipe === "node") && !entryFile) {
|
|
543
|
+
ambiguous = true;
|
|
544
|
+
}
|
|
545
|
+
return {
|
|
546
|
+
cwd: root,
|
|
547
|
+
packageJsonPath,
|
|
548
|
+
recipe,
|
|
549
|
+
packageManager,
|
|
550
|
+
entryFile,
|
|
551
|
+
nextVersion,
|
|
552
|
+
otlpStack,
|
|
553
|
+
isMonorepo,
|
|
554
|
+
workspaces: workspaces ?? [],
|
|
555
|
+
ambiguous,
|
|
556
|
+
reasons,
|
|
557
|
+
notes
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// src/inject/text.ts
|
|
562
|
+
var BOM = "\uFEFF";
|
|
563
|
+
var DIRECTIVE_RE = /^\s*(['"])use (?:client|strict|server)\1\s*;?\s*$/;
|
|
564
|
+
function analyzeSource(text) {
|
|
565
|
+
let bom = "";
|
|
566
|
+
let body = text;
|
|
567
|
+
if (body.charCodeAt(0) === 65279) {
|
|
568
|
+
bom = BOM;
|
|
569
|
+
body = body.slice(1);
|
|
570
|
+
}
|
|
571
|
+
const eol = /\r\n/.test(body) ? "\r\n" : "\n";
|
|
572
|
+
return { bom, eol, lines: body.split(/\r?\n/) };
|
|
573
|
+
}
|
|
574
|
+
function prologueEnd(lines) {
|
|
575
|
+
let end = 0;
|
|
576
|
+
let idx = 0;
|
|
577
|
+
if (lines[0]?.startsWith("#!")) {
|
|
578
|
+
end = 1;
|
|
579
|
+
idx = 1;
|
|
580
|
+
}
|
|
581
|
+
for (; idx < lines.length; idx++) {
|
|
582
|
+
const line = lines[idx];
|
|
583
|
+
if (line.trim() === "") continue;
|
|
584
|
+
if (DIRECTIVE_RE.test(line)) {
|
|
585
|
+
end = idx + 1;
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
return end;
|
|
591
|
+
}
|
|
592
|
+
function referencesCrumbtrail(text) {
|
|
593
|
+
return /crumbtrail-core|crumbtrail-node/.test(text);
|
|
594
|
+
}
|
|
595
|
+
function prependIntoSource(existing, block) {
|
|
596
|
+
const { bom, eol, lines } = analyzeSource(existing);
|
|
597
|
+
const end = prologueEnd(lines);
|
|
598
|
+
const blockLines = block.replace(/\n+$/, "").split("\n");
|
|
599
|
+
const pre = lines.slice(0, end);
|
|
600
|
+
const post = lines.slice(end);
|
|
601
|
+
const out = [...pre];
|
|
602
|
+
if (pre.length && pre[pre.length - 1].trim() !== "") out.push("");
|
|
603
|
+
out.push(...blockLines);
|
|
604
|
+
const postHasContent = post.some((l) => l.trim() !== "");
|
|
605
|
+
if (postHasContent && post[0].trim() !== "") out.push("");
|
|
606
|
+
out.push(...post);
|
|
607
|
+
return bom + out.join(eol);
|
|
608
|
+
}
|
|
609
|
+
function withTrailingNewline(text) {
|
|
610
|
+
return text.endsWith("\n") ? text : text + "\n";
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// src/inject/snippets.ts
|
|
614
|
+
function clientInitSnippet(endpoint, apiKey) {
|
|
615
|
+
return [
|
|
616
|
+
'import { Crumbtrail, PRESET_PASSIVE } from "crumbtrail-core";',
|
|
617
|
+
"",
|
|
618
|
+
"Crumbtrail.init({",
|
|
619
|
+
" ...PRESET_PASSIVE,",
|
|
620
|
+
` httpEndpoint: ${JSON.stringify(endpoint)},`,
|
|
621
|
+
` httpAuthToken: ${JSON.stringify(apiKey)},`,
|
|
622
|
+
"});"
|
|
623
|
+
].join("\n");
|
|
624
|
+
}
|
|
625
|
+
function nuxtPluginSnippet(endpoint, apiKey) {
|
|
626
|
+
return [
|
|
627
|
+
'import { Crumbtrail, PRESET_PASSIVE } from "crumbtrail-core";',
|
|
628
|
+
"",
|
|
629
|
+
"export default defineNuxtPlugin(() => {",
|
|
630
|
+
" Crumbtrail.init({",
|
|
631
|
+
" ...PRESET_PASSIVE,",
|
|
632
|
+
` httpEndpoint: ${JSON.stringify(endpoint)},`,
|
|
633
|
+
` httpAuthToken: ${JSON.stringify(apiKey)},`,
|
|
634
|
+
" });",
|
|
635
|
+
"});"
|
|
636
|
+
].join("\n");
|
|
637
|
+
}
|
|
638
|
+
function nodeInitSnippet(endpoint) {
|
|
639
|
+
return [
|
|
640
|
+
"// Crumbtrail \u2014 auto-captures uncaught exceptions, unhandled rejections, and",
|
|
641
|
+
"// console.error. Key is read from process.env.CRUMBTRAIL_KEY, which autoCapture",
|
|
642
|
+
"// loads from your .env (written by the CLI). Express apps can also add",
|
|
643
|
+
"// createCrumbtrailExpressMiddleware for per-request capture.",
|
|
644
|
+
'import("crumbtrail-node")',
|
|
645
|
+
` .then(({ autoCapture }) => autoCapture({ endpoint: ${JSON.stringify(endpoint)} }))`,
|
|
646
|
+
" .catch(() => {});"
|
|
647
|
+
].join("\n");
|
|
648
|
+
}
|
|
649
|
+
function singleQuoted(value) {
|
|
650
|
+
return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
651
|
+
}
|
|
652
|
+
function nestInitSnippet(endpoint) {
|
|
653
|
+
return [
|
|
654
|
+
"// Crumbtrail \u2014 auto-captures uncaught exceptions, unhandled rejections, and",
|
|
655
|
+
"// console.error. Key is read from process.env.CRUMBTRAIL_KEY, which autoCapture",
|
|
656
|
+
"// loads from your .env (written by the CLI). Express apps can also add",
|
|
657
|
+
"// createCrumbtrailExpressMiddleware for per-request capture.",
|
|
658
|
+
"import('crumbtrail-node')",
|
|
659
|
+
` .then(({ autoCapture }) => autoCapture({ endpoint: ${singleQuoted(endpoint)} }))`,
|
|
660
|
+
" .catch(() => {});"
|
|
661
|
+
].join("\n");
|
|
662
|
+
}
|
|
663
|
+
function reactNativeInitSnippet(endpoint, apiKey) {
|
|
664
|
+
return [
|
|
665
|
+
'import { createReactNativeCrumbtrail } from "crumbtrail-react-native";',
|
|
666
|
+
"",
|
|
667
|
+
"createReactNativeCrumbtrail({",
|
|
668
|
+
" config: {",
|
|
669
|
+
` httpEndpoint: ${JSON.stringify(endpoint)},`,
|
|
670
|
+
` httpAuthToken: ${JSON.stringify(apiKey)},`,
|
|
671
|
+
" },",
|
|
672
|
+
"});"
|
|
673
|
+
].join("\n");
|
|
674
|
+
}
|
|
675
|
+
function tauriInitSnippet() {
|
|
676
|
+
return [
|
|
677
|
+
'import { Crumbtrail, PRESET_PASSIVE } from "crumbtrail-core";',
|
|
678
|
+
'import { TauriTransport } from "crumbtrail-tauri";',
|
|
679
|
+
"",
|
|
680
|
+
"Crumbtrail.init({ ...PRESET_PASSIVE, transportInstance: new TauriTransport() });"
|
|
681
|
+
].join("\n");
|
|
682
|
+
}
|
|
683
|
+
function envKeyLine(apiKey) {
|
|
684
|
+
return `CRUMBTRAIL_KEY=${apiKey}`;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// src/inject/io.ts
|
|
688
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
689
|
+
import { execFileSync } from "child_process";
|
|
690
|
+
import path2 from "path";
|
|
691
|
+
function realGitStatus(cwd, target) {
|
|
692
|
+
let out;
|
|
693
|
+
try {
|
|
694
|
+
const env = Object.fromEntries(
|
|
695
|
+
Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))
|
|
696
|
+
);
|
|
697
|
+
out = execFileSync("git", ["status", "--porcelain", "--", target], {
|
|
698
|
+
cwd,
|
|
699
|
+
encoding: "utf8",
|
|
700
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
701
|
+
env
|
|
702
|
+
});
|
|
703
|
+
} catch {
|
|
704
|
+
return { isRepo: false, tracked: false, dirty: false };
|
|
705
|
+
}
|
|
706
|
+
const line = out.split("\n").find((l) => l.trim().length > 0);
|
|
707
|
+
if (!line) {
|
|
708
|
+
return { isRepo: true, tracked: true, dirty: false };
|
|
709
|
+
}
|
|
710
|
+
const code = line.slice(0, 2);
|
|
711
|
+
const untracked = code === "??";
|
|
712
|
+
return { isRepo: true, tracked: !untracked, dirty: true };
|
|
713
|
+
}
|
|
714
|
+
var defaultInjectIO = {
|
|
715
|
+
exists: (p) => existsSync2(p),
|
|
716
|
+
readFile: (p) => {
|
|
717
|
+
try {
|
|
718
|
+
return readFileSync2(p, "utf8");
|
|
719
|
+
} catch {
|
|
720
|
+
return null;
|
|
721
|
+
}
|
|
722
|
+
},
|
|
723
|
+
gitStatus: realGitStatus,
|
|
724
|
+
/**
|
|
725
|
+
* Every .gitignore from `cwd` up to (and including) the git root, concatenated.
|
|
726
|
+
*
|
|
727
|
+
* Walking up is what makes this correct in a monorepo: `.env` is almost always
|
|
728
|
+
* ignored by the ROOT .gitignore, not by `packages/api/.gitignore` — and a
|
|
729
|
+
* package-local-only read would tell every service in the repo that its ingest
|
|
730
|
+
* key is about to be committed when it isn't.
|
|
731
|
+
*/
|
|
732
|
+
readGitignore: (cwd) => {
|
|
733
|
+
const parts = [];
|
|
734
|
+
let dir = path2.resolve(cwd);
|
|
735
|
+
while (true) {
|
|
736
|
+
try {
|
|
737
|
+
parts.push(readFileSync2(path2.join(dir, ".gitignore"), "utf8"));
|
|
738
|
+
} catch {
|
|
739
|
+
}
|
|
740
|
+
if (existsSync2(path2.join(dir, ".git"))) break;
|
|
741
|
+
const parent = path2.dirname(dir);
|
|
742
|
+
if (parent === dir) break;
|
|
743
|
+
dir = parent;
|
|
744
|
+
}
|
|
745
|
+
return parts.length > 0 ? parts.join("\n") : null;
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
// src/inject/recipes.ts
|
|
750
|
+
import path3 from "path";
|
|
751
|
+
|
|
752
|
+
// ../core/dist/index.js
|
|
753
|
+
var CRUMBTRAIL_SESSION_HEADER = "X-Crumbtrail-Session-Id";
|
|
754
|
+
var CRUMBTRAIL_REQUEST_HEADER = "X-Crumbtrail-Request-Id";
|
|
755
|
+
var CRUMBTRAIL_SESSION_HEADER_LOWER = CRUMBTRAIL_SESSION_HEADER.toLowerCase();
|
|
756
|
+
var CRUMBTRAIL_REQUEST_HEADER_LOWER = CRUMBTRAIL_REQUEST_HEADER.toLowerCase();
|
|
757
|
+
var STABLE_ID_ATTRS = ["data-bug-id", "data-testid", "data-test", "id"];
|
|
758
|
+
function stableAttr(el) {
|
|
759
|
+
for (const attr of STABLE_ID_ATTRS) {
|
|
760
|
+
const value = el.getAttribute?.(attr);
|
|
761
|
+
if (value) return `${attr}=${value}`;
|
|
762
|
+
}
|
|
763
|
+
return void 0;
|
|
764
|
+
}
|
|
765
|
+
function segment(el) {
|
|
766
|
+
const tag = el.tagName.toLowerCase();
|
|
767
|
+
const attr = stableAttr(el);
|
|
768
|
+
if (attr) return `${tag}[${attr}]`;
|
|
769
|
+
const parent = el.parentElement;
|
|
770
|
+
if (!parent) return tag;
|
|
771
|
+
let index = 1;
|
|
772
|
+
for (const sibling of Array.from(parent.children)) {
|
|
773
|
+
if (sibling === el) break;
|
|
774
|
+
if (sibling.tagName === el.tagName) index++;
|
|
775
|
+
}
|
|
776
|
+
return `${tag}:nth-of-type(${index})`;
|
|
777
|
+
}
|
|
778
|
+
function computeElementPath(el) {
|
|
779
|
+
const parts = [];
|
|
780
|
+
let current = el;
|
|
781
|
+
let guard = 0;
|
|
782
|
+
while (current && guard++ < 50) {
|
|
783
|
+
const seg = segment(current);
|
|
784
|
+
parts.unshift(seg);
|
|
785
|
+
if (seg.includes("[")) break;
|
|
786
|
+
current = current.parentElement;
|
|
787
|
+
}
|
|
788
|
+
return parts.join(">");
|
|
789
|
+
}
|
|
790
|
+
function hashString(input) {
|
|
791
|
+
let hash = 2166136261;
|
|
792
|
+
for (let i = 0; i < input.length; i++) {
|
|
793
|
+
hash ^= input.charCodeAt(i);
|
|
794
|
+
hash = Math.imul(hash, 16777619);
|
|
795
|
+
}
|
|
796
|
+
return (hash >>> 0).toString(36);
|
|
797
|
+
}
|
|
798
|
+
function computeElementSignature(el) {
|
|
799
|
+
const path5 = computeElementPath(el);
|
|
800
|
+
return { path: path5, sig: hashString(path5) };
|
|
801
|
+
}
|
|
802
|
+
var WebTargetDescriptorResolver = class {
|
|
803
|
+
resolve(element) {
|
|
804
|
+
try {
|
|
805
|
+
const signature = computeElementSignature(element);
|
|
806
|
+
return {
|
|
807
|
+
componentName: element.tagName.toLowerCase(),
|
|
808
|
+
ancestryHash: signature.sig,
|
|
809
|
+
selector: signature.path
|
|
810
|
+
};
|
|
811
|
+
} catch {
|
|
812
|
+
return void 0;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
var webTargetDescriptorResolver = new WebTargetDescriptorResolver();
|
|
817
|
+
|
|
818
|
+
// ../install-shared/dist/index.js
|
|
819
|
+
var JS_STACKS = [
|
|
820
|
+
"nextjs",
|
|
821
|
+
"react",
|
|
822
|
+
"vue",
|
|
823
|
+
"svelte",
|
|
824
|
+
"vite",
|
|
825
|
+
"express",
|
|
826
|
+
"hono",
|
|
827
|
+
"node"
|
|
828
|
+
];
|
|
829
|
+
var BACKEND_JS_STACKS = ["express", "hono", "node"];
|
|
830
|
+
var OTLP_STACKS = [
|
|
831
|
+
"django",
|
|
832
|
+
"flask",
|
|
833
|
+
"fastapi",
|
|
834
|
+
"dotnet",
|
|
835
|
+
"go",
|
|
836
|
+
"rails"
|
|
837
|
+
];
|
|
838
|
+
function getInstallVariant(stack) {
|
|
839
|
+
const backendJs = BACKEND_JS_STACKS.includes(stack);
|
|
840
|
+
if (JS_STACKS.includes(stack)) {
|
|
841
|
+
return { stack, kind: "js", backendJs, comingSoon: false };
|
|
842
|
+
}
|
|
843
|
+
if (OTLP_STACKS.includes(stack)) {
|
|
844
|
+
return { stack, kind: "otlp", backendJs: false, comingSoon: false };
|
|
845
|
+
}
|
|
846
|
+
return { stack, kind: "infra", backendJs: false, comingSoon: true };
|
|
847
|
+
}
|
|
848
|
+
var OTLP_CAPABILITY_FACTS = {
|
|
849
|
+
paths: ["/v1/traces", "/v1/logs"],
|
|
850
|
+
protocols: ["http/protobuf", "http/json"],
|
|
851
|
+
authHeaders: ["X-Crumbtrail-Auth", "Authorization: Bearer"],
|
|
852
|
+
compression: { recommended: "none", accepted: ["none", "gzip"] },
|
|
853
|
+
sessionAttribute: "crumbtrail.session.id"
|
|
854
|
+
};
|
|
855
|
+
function otlpAuthHeaderValue(apiKey) {
|
|
856
|
+
return `X-Crumbtrail-Auth=${apiKey}`;
|
|
857
|
+
}
|
|
858
|
+
function otlpBearerHeaderValue(apiKey) {
|
|
859
|
+
return `Authorization=Bearer%20${apiKey}`;
|
|
860
|
+
}
|
|
861
|
+
function buildOtlpSnippets({
|
|
862
|
+
endpoint,
|
|
863
|
+
apiKey
|
|
864
|
+
}) {
|
|
865
|
+
const [protobuf, jsonProtocol] = OTLP_CAPABILITY_FACTS.protocols;
|
|
866
|
+
return {
|
|
867
|
+
env: [
|
|
868
|
+
`OTEL_EXPORTER_OTLP_ENDPOINT=${endpoint}`,
|
|
869
|
+
`OTEL_EXPORTER_OTLP_PROTOCOL=${protobuf} # or ${jsonProtocol}`,
|
|
870
|
+
`OTEL_EXPORTER_OTLP_COMPRESSION=${OTLP_CAPABILITY_FACTS.compression.recommended} # recommended; gzip is also accepted`
|
|
871
|
+
].join("\n"),
|
|
872
|
+
authHeader: [
|
|
873
|
+
`OTEL_EXPORTER_OTLP_HEADERS=${otlpAuthHeaderValue(apiKey)}`,
|
|
874
|
+
`# Or, if your exporter sends a Bearer token (note the %20-escaped space):`,
|
|
875
|
+
`# OTEL_EXPORTER_OTLP_HEADERS=${otlpBearerHeaderValue(apiKey)}`
|
|
876
|
+
].join("\n"),
|
|
877
|
+
sessionAttr: `OTEL_RESOURCE_ATTRIBUTES=${OTLP_CAPABILITY_FACTS.sessionAttribute}=<your-session-id>`,
|
|
878
|
+
note: `Crumbtrail's OTLP receiver appends ${OTLP_CAPABILITY_FACTS.paths.join(" and ")} to the endpoint above \u2014 don't include those paths yourself. Set the ${OTLP_CAPABILITY_FACTS.sessionAttribute} resource attribute to join backend spans to the frontend session.`
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
function buildAgentPrompt(stack, keys) {
|
|
882
|
+
const { kind, backendJs } = getInstallVariant(stack);
|
|
883
|
+
const { endpoint, apiKey } = keys;
|
|
884
|
+
const header = [
|
|
885
|
+
"You are setting up Crumbtrail in this project. Make ONLY the changes below,",
|
|
886
|
+
"do not refactor or touch anything else, then verify the build still passes.",
|
|
887
|
+
"",
|
|
888
|
+
`Ingest endpoint: ${endpoint}`,
|
|
889
|
+
`Ingest key: ${apiKey}`,
|
|
890
|
+
""
|
|
891
|
+
];
|
|
892
|
+
if (kind === "otlp") {
|
|
893
|
+
return [
|
|
894
|
+
...header,
|
|
895
|
+
"This is a non-JS backend that already uses OpenTelemetry. Do NOT install a",
|
|
896
|
+
"second SDK. Instead, add Crumbtrail as an additional OTLP/HTTP exporter:",
|
|
897
|
+
` 1. Set OTEL_EXPORTER_OTLP_ENDPOINT=${endpoint} (the exporter appends`,
|
|
898
|
+
" /v1/traces and /v1/logs \u2014 do not add those paths).",
|
|
899
|
+
` 2. Send the auth header X-Crumbtrail-Auth: ${apiKey} on every export`,
|
|
900
|
+
" (or Authorization: Bearer <key> if your exporter prefers that).",
|
|
901
|
+
" 3. Stamp the resource attribute crumbtrail.session.id so spans/logs join",
|
|
902
|
+
" the right session.",
|
|
903
|
+
" 4. Keep your existing exporter \u2014 add Crumbtrail alongside it.",
|
|
904
|
+
" 5. Verify the app still builds and starts."
|
|
905
|
+
].join("\n");
|
|
906
|
+
}
|
|
907
|
+
const jsLines = [
|
|
908
|
+
...header,
|
|
909
|
+
"This is a JavaScript/TypeScript project. Do the following:",
|
|
910
|
+
" 1. Install the SDK: npm install crumbtrail-core",
|
|
911
|
+
' 2. Import the SDK: import { Crumbtrail, PRESET_PASSIVE } from "crumbtrail-core";',
|
|
912
|
+
" 3. Initialize once at the app entry point with PRESET_PASSIVE:",
|
|
913
|
+
" Crumbtrail.init({",
|
|
914
|
+
" ...PRESET_PASSIVE,",
|
|
915
|
+
` httpEndpoint: "${endpoint}",`,
|
|
916
|
+
` httpAuthToken: "${apiKey}",`,
|
|
917
|
+
" });"
|
|
918
|
+
];
|
|
919
|
+
if (backendJs && stack === "express") {
|
|
920
|
+
jsLines.push(
|
|
921
|
+
" 4. This is an Express backend \u2014 also install crumbtrail-node and capture",
|
|
922
|
+
" server errors and requests with its Express middleware:",
|
|
923
|
+
" import {",
|
|
924
|
+
" createCrumbtrailExpressMiddleware,",
|
|
925
|
+
" createCrumbtrailExpressErrorMiddleware,",
|
|
926
|
+
' } from "crumbtrail-node";',
|
|
927
|
+
` const opts = { endpoint: "${endpoint}", authToken: "${apiKey}" };`,
|
|
928
|
+
" app.use(createCrumbtrailExpressMiddleware(opts)); // before your routes",
|
|
929
|
+
" app.use(createCrumbtrailExpressErrorMiddleware(opts)); // after your routes",
|
|
930
|
+
" 5. Change nothing else, then verify the build still passes."
|
|
931
|
+
);
|
|
932
|
+
} else if (backendJs) {
|
|
933
|
+
jsLines.push(
|
|
934
|
+
" 4. This is also a backend. Install crumbtrail-node; it ships no framework",
|
|
935
|
+
" middleware for this stack, so open a headless session and record",
|
|
936
|
+
" server-side events against it:",
|
|
937
|
+
' import { startHeadlessSession } from "crumbtrail-node";',
|
|
938
|
+
" const session = await startHeadlessSession({",
|
|
939
|
+
` endpoint: "${endpoint}",`,
|
|
940
|
+
` authToken: "${apiKey}",`,
|
|
941
|
+
' sessionId: "<your-session-id>",',
|
|
942
|
+
" });",
|
|
943
|
+
" // session.record(event) for server events; session.end() when the request ends.",
|
|
944
|
+
" 5. Change nothing else, then verify the build still passes."
|
|
945
|
+
);
|
|
946
|
+
} else {
|
|
947
|
+
jsLines.push(
|
|
948
|
+
" 4. Change nothing else, then verify the build still passes."
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
return jsLines.join("\n");
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// src/recipe-registry.ts
|
|
955
|
+
var RECIPE_REGISTRY = {
|
|
956
|
+
tauri: {
|
|
957
|
+
stack: "vite",
|
|
958
|
+
// no "tauri" Stack id — Tauri frontends are typically vite
|
|
959
|
+
sdkPackages: ["crumbtrail-core", "crumbtrail-tauri"],
|
|
960
|
+
serviceName: "app",
|
|
961
|
+
kind: "inject"
|
|
962
|
+
},
|
|
963
|
+
next: {
|
|
964
|
+
stack: "nextjs",
|
|
965
|
+
sdkPackages: ["crumbtrail-core"],
|
|
966
|
+
serviceName: "web",
|
|
967
|
+
kind: "inject"
|
|
968
|
+
},
|
|
969
|
+
sveltekit: {
|
|
970
|
+
stack: "svelte",
|
|
971
|
+
// no "sveltekit" Stack id — svelte is the closest js stack
|
|
972
|
+
sdkPackages: ["crumbtrail-core"],
|
|
973
|
+
serviceName: "web",
|
|
974
|
+
kind: "inject"
|
|
975
|
+
},
|
|
976
|
+
nuxt: {
|
|
977
|
+
stack: "vue",
|
|
978
|
+
// no "nuxt" Stack id — vue is the closest js stack
|
|
979
|
+
sdkPackages: ["crumbtrail-core"],
|
|
980
|
+
serviceName: "web",
|
|
981
|
+
kind: "inject"
|
|
982
|
+
},
|
|
983
|
+
remix: {
|
|
984
|
+
stack: "react",
|
|
985
|
+
// no "remix" Stack id — react is the closest js stack
|
|
986
|
+
sdkPackages: ["crumbtrail-core"],
|
|
987
|
+
serviceName: "web",
|
|
988
|
+
kind: "inject"
|
|
989
|
+
},
|
|
990
|
+
astro: {
|
|
991
|
+
stack: "vite",
|
|
992
|
+
// no "astro" Stack id — vite is the closest generic frontend stack
|
|
993
|
+
sdkPackages: ["crumbtrail-core"],
|
|
994
|
+
serviceName: "web",
|
|
995
|
+
kind: "inject"
|
|
996
|
+
},
|
|
997
|
+
angular: {
|
|
998
|
+
stack: "vite",
|
|
999
|
+
// no "angular" Stack id — vite is the closest generic frontend stack
|
|
1000
|
+
sdkPackages: ["crumbtrail-core"],
|
|
1001
|
+
serviceName: "web",
|
|
1002
|
+
kind: "inject"
|
|
1003
|
+
},
|
|
1004
|
+
"vite-spa": {
|
|
1005
|
+
stack: "vite",
|
|
1006
|
+
sdkPackages: ["crumbtrail-core"],
|
|
1007
|
+
serviceName: "web",
|
|
1008
|
+
kind: "inject"
|
|
1009
|
+
},
|
|
1010
|
+
nestjs: {
|
|
1011
|
+
stack: "node",
|
|
1012
|
+
// no "nestjs" Stack id — node is the backend-JS stack
|
|
1013
|
+
sdkPackages: ["crumbtrail-core", "crumbtrail-node"],
|
|
1014
|
+
serviceName: "api",
|
|
1015
|
+
kind: "inject"
|
|
1016
|
+
},
|
|
1017
|
+
express: {
|
|
1018
|
+
stack: "express",
|
|
1019
|
+
sdkPackages: ["crumbtrail-core", "crumbtrail-node"],
|
|
1020
|
+
serviceName: "api",
|
|
1021
|
+
kind: "inject"
|
|
1022
|
+
},
|
|
1023
|
+
hono: {
|
|
1024
|
+
stack: "hono",
|
|
1025
|
+
sdkPackages: ["crumbtrail-core", "crumbtrail-node"],
|
|
1026
|
+
serviceName: "api",
|
|
1027
|
+
kind: "inject"
|
|
1028
|
+
},
|
|
1029
|
+
fastify: {
|
|
1030
|
+
stack: "node",
|
|
1031
|
+
// no dedicated "fastify" Stack id — node is the backend-JS stack
|
|
1032
|
+
sdkPackages: ["crumbtrail-core", "crumbtrail-node"],
|
|
1033
|
+
serviceName: "api",
|
|
1034
|
+
kind: "inject"
|
|
1035
|
+
},
|
|
1036
|
+
"react-native": {
|
|
1037
|
+
stack: "react",
|
|
1038
|
+
// no "react-native" Stack id — react is the closest js stack
|
|
1039
|
+
sdkPackages: ["crumbtrail-core", "crumbtrail-react-native"],
|
|
1040
|
+
serviceName: "app",
|
|
1041
|
+
kind: "inject"
|
|
1042
|
+
},
|
|
1043
|
+
node: {
|
|
1044
|
+
stack: "node",
|
|
1045
|
+
sdkPackages: ["crumbtrail-core", "crumbtrail-node"],
|
|
1046
|
+
serviceName: "api",
|
|
1047
|
+
kind: "inject"
|
|
1048
|
+
},
|
|
1049
|
+
otlp: {
|
|
1050
|
+
// PLACEHOLDER ONLY. `otlp` is the single recipe that carries a VARIABLE
|
|
1051
|
+
// detected Stack (django/flask/fastapi/go/rails/dotnet). This static value is
|
|
1052
|
+
// NOT authoritative — every call site (provision.ts createService,
|
|
1053
|
+
// recipes.ts buildAgentPrompt) must prefer `DetectResult.otlpStack` and only
|
|
1054
|
+
// fall back to this when a detected stack is somehow absent.
|
|
1055
|
+
stack: "django",
|
|
1056
|
+
// Empty: this backend already speaks OpenTelemetry, so there is no SDK to
|
|
1057
|
+
// install. installSdk() must guard the empty list and skip spawning entirely.
|
|
1058
|
+
sdkPackages: [],
|
|
1059
|
+
serviceName: "backend",
|
|
1060
|
+
kind: "otlp"
|
|
1061
|
+
}
|
|
1062
|
+
};
|
|
1063
|
+
|
|
1064
|
+
// src/inject/recipes.ts
|
|
1065
|
+
function skipPlan(input, warnings = []) {
|
|
1066
|
+
return {
|
|
1067
|
+
recipe: input.recipe,
|
|
1068
|
+
kind: "skip-already-wired",
|
|
1069
|
+
targetPath: null,
|
|
1070
|
+
content: null,
|
|
1071
|
+
warnings: [
|
|
1072
|
+
...warnings,
|
|
1073
|
+
"Project already references Crumbtrail \u2014 nothing to inject."
|
|
1074
|
+
]
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
function fallbackPlan(input, snippet, warnings, envAction) {
|
|
1078
|
+
return {
|
|
1079
|
+
recipe: input.recipe,
|
|
1080
|
+
kind: "fallback-ai",
|
|
1081
|
+
targetPath: null,
|
|
1082
|
+
content: null,
|
|
1083
|
+
snippet,
|
|
1084
|
+
// Thread the real endpoint/apiKey through — never hardcode.
|
|
1085
|
+
agentPrompt: buildAgentPrompt(RECIPE_REGISTRY[input.recipe].stack, {
|
|
1086
|
+
endpoint: input.endpoint,
|
|
1087
|
+
apiKey: input.apiKey
|
|
1088
|
+
}),
|
|
1089
|
+
warnings,
|
|
1090
|
+
envAction
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
function createPlan(input, target, block, warnings = [], envAction) {
|
|
1094
|
+
return {
|
|
1095
|
+
recipe: input.recipe,
|
|
1096
|
+
kind: "create",
|
|
1097
|
+
targetPath: target,
|
|
1098
|
+
content: withTrailingNewline(block),
|
|
1099
|
+
warnings,
|
|
1100
|
+
envAction
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
function prependWithPreflight(input, io, target, block, warnings = [], envAction) {
|
|
1104
|
+
const existing = io.readFile(target);
|
|
1105
|
+
if (existing == null) {
|
|
1106
|
+
return fallbackPlan(
|
|
1107
|
+
input,
|
|
1108
|
+
block,
|
|
1109
|
+
[
|
|
1110
|
+
...warnings,
|
|
1111
|
+
`Could not read ${target}; use the snippet or AI prompt to wire it manually.`
|
|
1112
|
+
],
|
|
1113
|
+
envAction
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
if (referencesCrumbtrail(existing)) {
|
|
1117
|
+
return skipPlan(input, warnings);
|
|
1118
|
+
}
|
|
1119
|
+
const status = io.gitStatus(input.cwd, target);
|
|
1120
|
+
if (status.dirty && !input.options?.force) {
|
|
1121
|
+
return {
|
|
1122
|
+
recipe: input.recipe,
|
|
1123
|
+
kind: "needs-confirm-dirty",
|
|
1124
|
+
targetPath: target,
|
|
1125
|
+
content: block,
|
|
1126
|
+
warnings: [
|
|
1127
|
+
...warnings,
|
|
1128
|
+
`${target} has uncommitted changes \u2014 confirm (or re-run with force) before prepending.`
|
|
1129
|
+
],
|
|
1130
|
+
envAction
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
return {
|
|
1134
|
+
recipe: input.recipe,
|
|
1135
|
+
kind: "prepend",
|
|
1136
|
+
targetPath: target,
|
|
1137
|
+
content: block,
|
|
1138
|
+
warnings,
|
|
1139
|
+
envAction
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
function installedNextVersion(cwd, io) {
|
|
1143
|
+
const text = io.readFile(
|
|
1144
|
+
path3.join(cwd, "node_modules", "next", "package.json")
|
|
1145
|
+
);
|
|
1146
|
+
if (text == null) return null;
|
|
1147
|
+
try {
|
|
1148
|
+
const pkg = JSON.parse(text);
|
|
1149
|
+
return typeof pkg.version === "string" ? pkg.version : null;
|
|
1150
|
+
} catch {
|
|
1151
|
+
return null;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
function projectAlreadyWired(cwd, io) {
|
|
1155
|
+
const text = io.readFile(path3.join(cwd, "package.json"));
|
|
1156
|
+
if (text == null) return false;
|
|
1157
|
+
try {
|
|
1158
|
+
const pkg = JSON.parse(text);
|
|
1159
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
1160
|
+
return "crumbtrail-core" in deps || "crumbtrail-node" in deps;
|
|
1161
|
+
} catch {
|
|
1162
|
+
return false;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
function supportsInstrumentationClient(version) {
|
|
1166
|
+
if (!version) return true;
|
|
1167
|
+
const m = version.match(/(\d+)(?:\.(\d+))?/);
|
|
1168
|
+
if (!m) return true;
|
|
1169
|
+
const major = Number(m[1]);
|
|
1170
|
+
const minor = m[2] ? Number(m[2]) : 0;
|
|
1171
|
+
if (major > 15) return true;
|
|
1172
|
+
if (major < 15) return false;
|
|
1173
|
+
return minor >= 3;
|
|
1174
|
+
}
|
|
1175
|
+
function buildEnvAction(cwd, apiKey, io) {
|
|
1176
|
+
const envPath = path3.join(cwd, ".env");
|
|
1177
|
+
const existing = io.readFile(envPath);
|
|
1178
|
+
if (existing != null && /^CRUMBTRAIL_KEY=/m.test(existing)) {
|
|
1179
|
+
return { warning: "CRUMBTRAIL_KEY already present in .env \u2014 left as-is." };
|
|
1180
|
+
}
|
|
1181
|
+
const gitignore = io.readGitignore(cwd);
|
|
1182
|
+
const covered = gitignore != null && /^\s*\.env\b/m.test(gitignore);
|
|
1183
|
+
return {
|
|
1184
|
+
action: {
|
|
1185
|
+
targetPath: envPath,
|
|
1186
|
+
line: envKeyLine(apiKey),
|
|
1187
|
+
gitignoreWarning: covered ? void 0 : "`.env` is not covered by .gitignore \u2014 add `.env` so your ingest key is not committed."
|
|
1188
|
+
},
|
|
1189
|
+
warning: covered ? void 0 : "`.env` is not gitignored; the appended key could be committed."
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
function firstExistingDir(io, ...dirs) {
|
|
1193
|
+
return dirs.find((d) => io.exists(d)) ?? null;
|
|
1194
|
+
}
|
|
1195
|
+
function planNext(input, io) {
|
|
1196
|
+
const { cwd } = input;
|
|
1197
|
+
const block = clientInitSnippet(input.endpoint, input.apiKey);
|
|
1198
|
+
const usesSrc = io.exists(path3.join(cwd, "src", "app")) || io.exists(path3.join(cwd, "src", "pages"));
|
|
1199
|
+
const baseDir = usesSrc ? path3.join(cwd, "src") : cwd;
|
|
1200
|
+
const effectiveVersion = installedNextVersion(cwd, io) ?? input.nextVersion;
|
|
1201
|
+
if (supportsInstrumentationClient(effectiveVersion)) {
|
|
1202
|
+
const target = path3.join(baseDir, "instrumentation-client.ts");
|
|
1203
|
+
if (io.exists(target)) {
|
|
1204
|
+
const existing = io.readFile(target);
|
|
1205
|
+
if (existing && referencesCrumbtrail(existing)) return skipPlan(input);
|
|
1206
|
+
return prependWithPreflight(input, io, target, block);
|
|
1207
|
+
}
|
|
1208
|
+
return createPlan(input, target, block);
|
|
1209
|
+
}
|
|
1210
|
+
const pagesApp = firstExistingDir(
|
|
1211
|
+
io,
|
|
1212
|
+
path3.join(baseDir, "pages", "_app.tsx"),
|
|
1213
|
+
path3.join(baseDir, "pages", "_app.jsx")
|
|
1214
|
+
) ?? null;
|
|
1215
|
+
if (pagesApp) {
|
|
1216
|
+
return prependWithPreflight(input, io, pagesApp, block, [
|
|
1217
|
+
"Older Next.js \u2014 prepending into pages/_app; move to instrumentation-client.ts after upgrading to 15.3+."
|
|
1218
|
+
]);
|
|
1219
|
+
}
|
|
1220
|
+
const appLayout = firstExistingDir(
|
|
1221
|
+
io,
|
|
1222
|
+
path3.join(baseDir, "app", "layout.tsx"),
|
|
1223
|
+
path3.join(baseDir, "app", "layout.jsx")
|
|
1224
|
+
) ?? null;
|
|
1225
|
+
if (appLayout) {
|
|
1226
|
+
return fallbackPlan(input, block, [
|
|
1227
|
+
`Next <15.3 with only an app-router root layout: client init can't be prepended into app/layout (a Server Component that never ships to the browser). Add the snippet to a "use client" module imported by the root layout, or upgrade to Next 15.3+ for the auto-loaded instrumentation-client.ts.`
|
|
1228
|
+
]);
|
|
1229
|
+
}
|
|
1230
|
+
return fallbackPlan(input, block, [
|
|
1231
|
+
"Older Next.js detected but no app/layout or pages/_app file was found."
|
|
1232
|
+
]);
|
|
1233
|
+
}
|
|
1234
|
+
function planSvelteKit(input, io) {
|
|
1235
|
+
const target = path3.join(input.cwd, "src", "hooks.client.ts");
|
|
1236
|
+
const block = clientInitSnippet(input.endpoint, input.apiKey);
|
|
1237
|
+
if (io.exists(target)) {
|
|
1238
|
+
return prependWithPreflight(input, io, target, block);
|
|
1239
|
+
}
|
|
1240
|
+
return createPlan(input, target, block);
|
|
1241
|
+
}
|
|
1242
|
+
function planNuxt(input, io) {
|
|
1243
|
+
const { cwd } = input;
|
|
1244
|
+
const baseDir = io.exists(path3.join(cwd, "app")) ? path3.join(cwd, "app") : cwd;
|
|
1245
|
+
const target = path3.join(baseDir, "plugins", "crumbtrail.client.ts");
|
|
1246
|
+
const block = nuxtPluginSnippet(input.endpoint, input.apiKey);
|
|
1247
|
+
if (io.exists(target)) {
|
|
1248
|
+
const existing = io.readFile(target);
|
|
1249
|
+
if (existing && referencesCrumbtrail(existing)) return skipPlan(input);
|
|
1250
|
+
return fallbackPlan(input, block, [
|
|
1251
|
+
`${target} already exists and isn't Crumbtrail's \u2014 wire it manually.`
|
|
1252
|
+
]);
|
|
1253
|
+
}
|
|
1254
|
+
return createPlan(input, target, block);
|
|
1255
|
+
}
|
|
1256
|
+
function planVite(input, io) {
|
|
1257
|
+
const block = clientInitSnippet(input.endpoint, input.apiKey);
|
|
1258
|
+
if (!input.entryFile) {
|
|
1259
|
+
return fallbackPlan(input, block, [
|
|
1260
|
+
"Could not resolve the Vite entry from index.html \u2014 wire it manually."
|
|
1261
|
+
]);
|
|
1262
|
+
}
|
|
1263
|
+
return prependWithPreflight(input, io, input.entryFile, block);
|
|
1264
|
+
}
|
|
1265
|
+
function planNode(input, io) {
|
|
1266
|
+
const block = input.recipe === "nestjs" ? nestInitSnippet(input.endpoint) : nodeInitSnippet(input.endpoint);
|
|
1267
|
+
const { action, warning } = buildEnvAction(input.cwd, input.apiKey, io);
|
|
1268
|
+
const envWarnings = warning ? [warning] : [];
|
|
1269
|
+
if (!input.entryFile) {
|
|
1270
|
+
return fallbackPlan(
|
|
1271
|
+
input,
|
|
1272
|
+
block,
|
|
1273
|
+
[
|
|
1274
|
+
"Could not resolve the Node server entry \u2014 wire it manually.",
|
|
1275
|
+
...envWarnings
|
|
1276
|
+
],
|
|
1277
|
+
action
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1280
|
+
return prependWithPreflight(
|
|
1281
|
+
input,
|
|
1282
|
+
io,
|
|
1283
|
+
input.entryFile,
|
|
1284
|
+
block,
|
|
1285
|
+
envWarnings,
|
|
1286
|
+
action
|
|
1287
|
+
);
|
|
1288
|
+
}
|
|
1289
|
+
function planRemix(input, io) {
|
|
1290
|
+
const block = clientInitSnippet(input.endpoint, input.apiKey);
|
|
1291
|
+
if (!input.entryFile) {
|
|
1292
|
+
return fallbackPlan(input, block, [
|
|
1293
|
+
"Could not resolve app/entry.client.* \u2014 on a React Router 7 default template the client entry is hidden, so run `npx react-router reveal` to unhide app/entry.client.tsx (and entry.server.tsx), then re-run the wizard. Otherwise add the snippet to your Remix client entry manually (do not let the CLI create it; it would omit hydrateRoot)."
|
|
1294
|
+
]);
|
|
1295
|
+
}
|
|
1296
|
+
return prependWithPreflight(input, io, input.entryFile, block);
|
|
1297
|
+
}
|
|
1298
|
+
function planAstro(input, _io) {
|
|
1299
|
+
const block = clientInitSnippet(input.endpoint, input.apiKey);
|
|
1300
|
+
return fallbackPlan(input, block, [
|
|
1301
|
+
"Astro has no single client entry \u2014 add this snippet inside a client-side <script> in a shared layout (e.g. src/layouts/*.astro) so it runs on every page."
|
|
1302
|
+
]);
|
|
1303
|
+
}
|
|
1304
|
+
function planAngular(input, io) {
|
|
1305
|
+
const block = clientInitSnippet(input.endpoint, input.apiKey);
|
|
1306
|
+
if (!input.entryFile) {
|
|
1307
|
+
return fallbackPlan(input, block, [
|
|
1308
|
+
"Could not resolve the Angular entry (src/main.ts) \u2014 wire it manually."
|
|
1309
|
+
]);
|
|
1310
|
+
}
|
|
1311
|
+
return prependWithPreflight(input, io, input.entryFile, block);
|
|
1312
|
+
}
|
|
1313
|
+
function planReactNative(input, io) {
|
|
1314
|
+
const block = reactNativeInitSnippet(input.endpoint, input.apiKey);
|
|
1315
|
+
if (!input.entryFile) {
|
|
1316
|
+
return fallbackPlan(input, block, [
|
|
1317
|
+
"Could not resolve the React Native entry (App/_layout/index) \u2014 wire it manually."
|
|
1318
|
+
]);
|
|
1319
|
+
}
|
|
1320
|
+
return prependWithPreflight(input, io, input.entryFile, block);
|
|
1321
|
+
}
|
|
1322
|
+
var TAURI_RUST_WARNINGS = [
|
|
1323
|
+
"Tauri also needs a Rust step the CLI can't do: register the plugin in src-tauri \u2014 add `tauri-plugin-crumbtrail` to Cargo.toml and `.plugin(tauri_plugin_crumbtrail::init())` in lib.rs (crumbtrail-tauri README, step 1).",
|
|
1324
|
+
"Grant the plugin permission: add `crumbtrail:default` to src-tauri/capabilities/default.json, or every Crumbtrail invoke fails (crumbtrail-tauri README, step 2)."
|
|
1325
|
+
];
|
|
1326
|
+
function planTauri(input, io) {
|
|
1327
|
+
const block = tauriInitSnippet();
|
|
1328
|
+
if (!input.entryFile) {
|
|
1329
|
+
return fallbackPlan(input, block, [
|
|
1330
|
+
"Could not resolve the Tauri frontend entry from index.html \u2014 wire it manually.",
|
|
1331
|
+
...TAURI_RUST_WARNINGS
|
|
1332
|
+
]);
|
|
1333
|
+
}
|
|
1334
|
+
return prependWithPreflight(input, io, input.entryFile, block, [
|
|
1335
|
+
...TAURI_RUST_WARNINGS
|
|
1336
|
+
]);
|
|
1337
|
+
}
|
|
1338
|
+
function planOtlp(input) {
|
|
1339
|
+
const stack = input.stack ?? RECIPE_REGISTRY[input.recipe].stack;
|
|
1340
|
+
const otlp = buildOtlpSnippets({
|
|
1341
|
+
endpoint: input.endpoint,
|
|
1342
|
+
apiKey: input.apiKey
|
|
1343
|
+
});
|
|
1344
|
+
const snippet = [
|
|
1345
|
+
otlp.env,
|
|
1346
|
+
"",
|
|
1347
|
+
otlp.authHeader,
|
|
1348
|
+
"",
|
|
1349
|
+
otlp.sessionAttr,
|
|
1350
|
+
"",
|
|
1351
|
+
`# ${otlp.note}`
|
|
1352
|
+
].join("\n");
|
|
1353
|
+
return {
|
|
1354
|
+
recipe: input.recipe,
|
|
1355
|
+
kind: "otlp-guidance",
|
|
1356
|
+
targetPath: null,
|
|
1357
|
+
content: null,
|
|
1358
|
+
snippet,
|
|
1359
|
+
agentPrompt: buildAgentPrompt(stack, {
|
|
1360
|
+
endpoint: input.endpoint,
|
|
1361
|
+
apiKey: input.apiKey
|
|
1362
|
+
}),
|
|
1363
|
+
warnings: []
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
function buildPlan(input, io = defaultInjectIO) {
|
|
1367
|
+
if (projectAlreadyWired(input.cwd, io)) {
|
|
1368
|
+
return skipPlan(input);
|
|
1369
|
+
}
|
|
1370
|
+
switch (input.recipe) {
|
|
1371
|
+
case "tauri":
|
|
1372
|
+
return planTauri(input, io);
|
|
1373
|
+
case "react-native":
|
|
1374
|
+
return planReactNative(input, io);
|
|
1375
|
+
case "next":
|
|
1376
|
+
return planNext(input, io);
|
|
1377
|
+
case "sveltekit":
|
|
1378
|
+
return planSvelteKit(input, io);
|
|
1379
|
+
case "nuxt":
|
|
1380
|
+
return planNuxt(input, io);
|
|
1381
|
+
case "remix":
|
|
1382
|
+
return planRemix(input, io);
|
|
1383
|
+
case "astro":
|
|
1384
|
+
return planAstro(input, io);
|
|
1385
|
+
case "angular":
|
|
1386
|
+
return planAngular(input, io);
|
|
1387
|
+
case "vite-spa":
|
|
1388
|
+
return planVite(input, io);
|
|
1389
|
+
case "nestjs":
|
|
1390
|
+
case "express":
|
|
1391
|
+
case "hono":
|
|
1392
|
+
case "fastify":
|
|
1393
|
+
case "node":
|
|
1394
|
+
return planNode(input, io);
|
|
1395
|
+
case "otlp":
|
|
1396
|
+
return planOtlp(input);
|
|
1397
|
+
default: {
|
|
1398
|
+
const exhaustive = input.recipe;
|
|
1399
|
+
throw new Error(`Unknown recipe: ${String(exhaustive)}`);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// src/inject/executor.ts
|
|
1405
|
+
import {
|
|
1406
|
+
existsSync as existsSync3,
|
|
1407
|
+
mkdirSync,
|
|
1408
|
+
readFileSync as readFileSync3,
|
|
1409
|
+
rmSync,
|
|
1410
|
+
writeFileSync
|
|
1411
|
+
} from "fs";
|
|
1412
|
+
import path4 from "path";
|
|
1413
|
+
var defaultExecutorIO = {
|
|
1414
|
+
exists: (p) => existsSync3(p),
|
|
1415
|
+
readFile: (p) => {
|
|
1416
|
+
try {
|
|
1417
|
+
return readFileSync3(p, "utf8");
|
|
1418
|
+
} catch {
|
|
1419
|
+
return null;
|
|
1420
|
+
}
|
|
1421
|
+
},
|
|
1422
|
+
writeFile: (p, content) => writeFileSync(p, content),
|
|
1423
|
+
mkdirp: (dir) => {
|
|
1424
|
+
mkdirSync(dir, { recursive: true });
|
|
1425
|
+
},
|
|
1426
|
+
remove: (p) => {
|
|
1427
|
+
rmSync(p, { force: true });
|
|
1428
|
+
}
|
|
1429
|
+
};
|
|
1430
|
+
function ensureTrailingNewline(text) {
|
|
1431
|
+
if (text === "") return "";
|
|
1432
|
+
return text.endsWith("\n") ? text : text + "\n";
|
|
1433
|
+
}
|
|
1434
|
+
function applyAllOrNothing(ops, io) {
|
|
1435
|
+
const preimages = [];
|
|
1436
|
+
const written = [];
|
|
1437
|
+
try {
|
|
1438
|
+
for (const op of ops) {
|
|
1439
|
+
const existed = io.exists(op.path);
|
|
1440
|
+
const prior = existed ? io.readFile(op.path) : null;
|
|
1441
|
+
preimages.push({ path: op.path, existed, content: prior });
|
|
1442
|
+
let next;
|
|
1443
|
+
switch (op.op) {
|
|
1444
|
+
case "create":
|
|
1445
|
+
if (existed) {
|
|
1446
|
+
throw new Error(`refusing to overwrite existing file: ${op.path}`);
|
|
1447
|
+
}
|
|
1448
|
+
next = op.content;
|
|
1449
|
+
break;
|
|
1450
|
+
case "prepend":
|
|
1451
|
+
next = prependIntoSource(prior ?? "", op.block);
|
|
1452
|
+
break;
|
|
1453
|
+
case "env": {
|
|
1454
|
+
const base = prior ?? "";
|
|
1455
|
+
if (/^CRUMBTRAIL_KEY=/m.test(base)) {
|
|
1456
|
+
preimages.pop();
|
|
1457
|
+
continue;
|
|
1458
|
+
}
|
|
1459
|
+
next = ensureTrailingNewline(base) + op.line + "\n";
|
|
1460
|
+
break;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
io.mkdirp(path4.dirname(op.path));
|
|
1464
|
+
io.writeFile(op.path, next);
|
|
1465
|
+
written.push(op.path);
|
|
1466
|
+
}
|
|
1467
|
+
return written;
|
|
1468
|
+
} catch (err) {
|
|
1469
|
+
for (const pre of preimages.reverse()) {
|
|
1470
|
+
if (!pre.existed) io.remove(pre.path);
|
|
1471
|
+
else if (pre.content != null) io.writeFile(pre.path, pre.content);
|
|
1472
|
+
}
|
|
1473
|
+
throw err;
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
function executePlan(plan, io = defaultExecutorIO, options = {}) {
|
|
1477
|
+
if (plan.kind === "skip-already-wired") {
|
|
1478
|
+
return {
|
|
1479
|
+
kind: plan.kind,
|
|
1480
|
+
written: [],
|
|
1481
|
+
skipped: true,
|
|
1482
|
+
message: "Already wired \u2014 skipped."
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
if (plan.kind === "fallback-ai") {
|
|
1486
|
+
return {
|
|
1487
|
+
kind: plan.kind,
|
|
1488
|
+
written: [],
|
|
1489
|
+
skipped: true,
|
|
1490
|
+
message: "Ambiguous \u2014 emitted snippet + AI prompt instead of editing files."
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
if (plan.kind === "otlp-guidance") {
|
|
1494
|
+
return {
|
|
1495
|
+
kind: plan.kind,
|
|
1496
|
+
written: [],
|
|
1497
|
+
skipped: true,
|
|
1498
|
+
message: "OTLP backend \u2014 printed setup guidance instead of editing files."
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
if (plan.kind === "needs-confirm-dirty" && !options.confirmDirty) {
|
|
1502
|
+
return {
|
|
1503
|
+
kind: plan.kind,
|
|
1504
|
+
written: [],
|
|
1505
|
+
skipped: true,
|
|
1506
|
+
message: "Target is dirty \u2014 confirm or pass force to apply."
|
|
1507
|
+
};
|
|
1508
|
+
}
|
|
1509
|
+
const ops = [];
|
|
1510
|
+
if (plan.targetPath && plan.content != null) {
|
|
1511
|
+
if (plan.kind === "create") {
|
|
1512
|
+
ops.push({
|
|
1513
|
+
op: "create",
|
|
1514
|
+
path: plan.targetPath,
|
|
1515
|
+
content: withTrailingNewline(plan.content)
|
|
1516
|
+
});
|
|
1517
|
+
} else {
|
|
1518
|
+
ops.push({ op: "prepend", path: plan.targetPath, block: plan.content });
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
if (plan.envAction) {
|
|
1522
|
+
ops.push({
|
|
1523
|
+
op: "env",
|
|
1524
|
+
path: plan.envAction.targetPath,
|
|
1525
|
+
line: plan.envAction.line
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
const written = applyAllOrNothing(ops, io);
|
|
1529
|
+
return {
|
|
1530
|
+
kind: plan.kind,
|
|
1531
|
+
written,
|
|
1532
|
+
skipped: written.length === 0,
|
|
1533
|
+
message: written.length === 0 ? "Nothing to write." : `Wrote ${written.length} file(s).`
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
export {
|
|
1538
|
+
detectPackageManager,
|
|
1539
|
+
parsePnpmWorkspace,
|
|
1540
|
+
resolveViteEntry,
|
|
1541
|
+
resolveReactNativeEntry,
|
|
1542
|
+
parseNodeInvocation,
|
|
1543
|
+
resolveNestEntry,
|
|
1544
|
+
resolveRemixEntry,
|
|
1545
|
+
resolveAngularEntry,
|
|
1546
|
+
resolveOtlpStack,
|
|
1547
|
+
DENO_UNSUPPORTED_REASON,
|
|
1548
|
+
DOCKER_COMING_SOON_NOTE,
|
|
1549
|
+
matchRecipe,
|
|
1550
|
+
detect,
|
|
1551
|
+
analyzeSource,
|
|
1552
|
+
prologueEnd,
|
|
1553
|
+
referencesCrumbtrail,
|
|
1554
|
+
prependIntoSource,
|
|
1555
|
+
withTrailingNewline,
|
|
1556
|
+
clientInitSnippet,
|
|
1557
|
+
nuxtPluginSnippet,
|
|
1558
|
+
nodeInitSnippet,
|
|
1559
|
+
nestInitSnippet,
|
|
1560
|
+
reactNativeInitSnippet,
|
|
1561
|
+
tauriInitSnippet,
|
|
1562
|
+
envKeyLine,
|
|
1563
|
+
defaultInjectIO,
|
|
1564
|
+
RECIPE_REGISTRY,
|
|
1565
|
+
projectAlreadyWired,
|
|
1566
|
+
supportsInstrumentationClient,
|
|
1567
|
+
buildPlan,
|
|
1568
|
+
defaultExecutorIO,
|
|
1569
|
+
executePlan
|
|
1570
|
+
};
|