crumbtrail 0.1.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/dist/cli.js ADDED
@@ -0,0 +1,2048 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ DENO_UNSUPPORTED_REASON,
4
+ RECIPE_REGISTRY,
5
+ buildPlan,
6
+ defaultInjectIO,
7
+ detect,
8
+ executePlan,
9
+ projectAlreadyWired
10
+ } from "./chunk-UFJ2BODB.js";
11
+
12
+ // src/cli.ts
13
+ import { readFileSync as readFileSync2 } from "fs";
14
+ import path4 from "path";
15
+ import { spawnSync } from "child_process";
16
+
17
+ // src/auth.ts
18
+ import { createHash, randomBytes } from "crypto";
19
+ import { spawn } from "child_process";
20
+ import {
21
+ chmodSync,
22
+ existsSync,
23
+ mkdirSync,
24
+ readFileSync,
25
+ rmSync,
26
+ writeFileSync
27
+ } from "fs";
28
+ import http from "http";
29
+ import os from "os";
30
+ import path from "path";
31
+
32
+ // src/net.ts
33
+ var DEFAULT_ENDPOINT = "https://crumbtrail-cloud-production.up.railway.app";
34
+ function normalizeBase(base) {
35
+ return base.replace(/\/+$/, "");
36
+ }
37
+ function resolveEndpoint(flagEndpoint, env = process.env) {
38
+ const chosen = flagEndpoint && flagEndpoint.trim() || env.CRUMBTRAIL_BASE_URL && env.CRUMBTRAIL_BASE_URL.trim() || DEFAULT_ENDPOINT;
39
+ return normalizeBase(chosen);
40
+ }
41
+ var ApiError = class extends Error {
42
+ status;
43
+ /** Machine-readable `code` from the cloud jsonError envelope, when present. */
44
+ code;
45
+ /** Parsed JSON body, when the response carried one. */
46
+ body;
47
+ constructor(message, opts) {
48
+ super(message);
49
+ this.name = "ApiError";
50
+ this.status = opts.status;
51
+ this.code = opts.code;
52
+ this.body = opts.body;
53
+ }
54
+ };
55
+ var NetworkError = class extends Error {
56
+ cause;
57
+ constructor(message, cause) {
58
+ super(message);
59
+ this.name = "NetworkError";
60
+ this.cause = cause;
61
+ }
62
+ };
63
+ function isTransient(err) {
64
+ const code = err && typeof err === "object" && "code" in err ? String(err.code) : "";
65
+ const causeCode = err && typeof err === "object" && "cause" in err && err.cause && typeof err.cause === "object" && "code" in err.cause ? String(
66
+ err.cause.code
67
+ ) : "";
68
+ return [code, causeCode].some(
69
+ (c) => ["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "EPIPE"].includes(c)
70
+ );
71
+ }
72
+ async function rawRequest(method, url, opts) {
73
+ const doFetch = opts.fetchImpl ?? fetch;
74
+ const headers = { ...opts.headers ?? {} };
75
+ if (opts.token) headers.authorization = `Bearer ${opts.token}`;
76
+ let bodyInit;
77
+ if (opts.body !== void 0) {
78
+ headers["content-type"] = headers["content-type"] ?? "application/json";
79
+ bodyInit = JSON.stringify(opts.body);
80
+ }
81
+ const res = await doFetch(url, {
82
+ method,
83
+ headers,
84
+ body: bodyInit,
85
+ signal: opts.signal
86
+ });
87
+ const text = await res.text();
88
+ return { status: res.status, text };
89
+ }
90
+ function parseJson(text) {
91
+ if (!text) return void 0;
92
+ try {
93
+ return JSON.parse(text);
94
+ } catch {
95
+ return void 0;
96
+ }
97
+ }
98
+ async function requestJson(url, opts = {}) {
99
+ const method = (opts.method ?? "GET").toUpperCase();
100
+ const retry = opts.retry !== false;
101
+ const where = `${method} ${url}`;
102
+ let raw;
103
+ try {
104
+ raw = await rawRequest(method, url, opts);
105
+ } catch (err) {
106
+ if (retry && isTransient(err)) {
107
+ try {
108
+ raw = await rawRequest(method, url, opts);
109
+ } catch (err2) {
110
+ throw new NetworkError(
111
+ `Request failed (${where}): ${describe(err2)}`,
112
+ err2
113
+ );
114
+ }
115
+ } else {
116
+ throw new NetworkError(
117
+ `Request failed (${where}): ${describe(err)}`,
118
+ err
119
+ );
120
+ }
121
+ }
122
+ if (raw.status >= 500 && retry) {
123
+ try {
124
+ raw = await rawRequest(method, url, opts);
125
+ } catch (err) {
126
+ throw new NetworkError(
127
+ `Request failed (${where}): ${describe(err)}`,
128
+ err
129
+ );
130
+ }
131
+ }
132
+ const parsed = parseJson(raw.text);
133
+ if (raw.status >= 200 && raw.status < 300) {
134
+ return parsed;
135
+ }
136
+ const envelope = parsed && typeof parsed === "object" ? parsed : {};
137
+ const code = typeof envelope.code === "string" ? envelope.code : void 0;
138
+ const errMsg = typeof envelope.error === "string" ? envelope.error : typeof envelope.message === "string" ? envelope.message : raw.text || "request failed";
139
+ throw new ApiError(`${errMsg} (${where}) [${raw.status}]`, {
140
+ status: raw.status,
141
+ code,
142
+ body: parsed
143
+ });
144
+ }
145
+ function describe(err) {
146
+ if (err instanceof Error) return err.message;
147
+ return String(err);
148
+ }
149
+
150
+ // src/ui.ts
151
+ import readline from "readline";
152
+ var useColor = process.stdout.isTTY === true && process.env.NO_COLOR == null;
153
+ function paint(code, s) {
154
+ return useColor ? `\x1B[${code}m${s}\x1B[0m` : s;
155
+ }
156
+ var color = {
157
+ bold: (s) => paint("1", s),
158
+ dim: (s) => paint("2", s),
159
+ green: (s) => paint("32", s),
160
+ cyan: (s) => paint("36", s),
161
+ yellow: (s) => paint("33", s),
162
+ red: (s) => paint("31", s)
163
+ };
164
+ function maskKey(key) {
165
+ if (key.length <= 12) return "\u2022".repeat(key.length);
166
+ return `${key.slice(0, 8)}\u2026${key.slice(-4)}`;
167
+ }
168
+ var consoleUi = {
169
+ out: (line = "") => process.stdout.write(line + "\n"),
170
+ err: (line = "") => process.stderr.write(line + "\n"),
171
+ status: (line = "") => {
172
+ if (process.stdout.isTTY !== true) return;
173
+ process.stdout.write(`\r\x1B[2K${line}`);
174
+ }
175
+ };
176
+ function parseSelection(input, items) {
177
+ const trimmed = input.trim().toLowerCase();
178
+ const selectable = (i) => items[i].selectable;
179
+ if (!trimmed) {
180
+ return {
181
+ ok: true,
182
+ indices: items.map((it, i) => it.checked && it.selectable ? i : -1).filter((i) => i >= 0)
183
+ };
184
+ }
185
+ if (trimmed === "none") return { ok: true, indices: [] };
186
+ if (trimmed === "all") {
187
+ return {
188
+ ok: true,
189
+ indices: items.map((_, i) => i).filter(selectable)
190
+ };
191
+ }
192
+ const picked = /* @__PURE__ */ new Set();
193
+ for (const token of trimmed.split(/[,\s]+/).filter(Boolean)) {
194
+ const range = token.match(/^(\d+)-(\d+)$/);
195
+ const bounds = range ? [Number(range[1]), Number(range[2])] : /^\d+$/.test(token) ? [Number(token), Number(token)] : null;
196
+ if (!bounds) {
197
+ return {
198
+ ok: false,
199
+ error: `"${token}" isn't a number, a range like 1-3, "all", or "none".`
200
+ };
201
+ }
202
+ const [lo, hi] = bounds;
203
+ if (lo < 1 || hi > items.length || lo > hi) {
204
+ return {
205
+ ok: false,
206
+ error: `"${token}" is out of range \u2014 pick between 1 and ${items.length}.`
207
+ };
208
+ }
209
+ for (let n = lo; n <= hi; n += 1) {
210
+ const i = n - 1;
211
+ if (!selectable(i)) {
212
+ return {
213
+ ok: false,
214
+ error: `${n} (${items[i].label}) has no supported framework \u2014 it can't be wired.`
215
+ };
216
+ }
217
+ picked.add(i);
218
+ }
219
+ }
220
+ return { ok: true, indices: [...picked].sort((a, b) => a - b) };
221
+ }
222
+ function renderItem(item, index) {
223
+ const hint = item.hint ? ` ${color.dim(item.hint)}` : "";
224
+ if (!item.selectable) {
225
+ return ` ${color.dim("-")} ${color.dim("\xB7")} ${color.dim(item.label)}${hint}`;
226
+ }
227
+ const n = color.cyan(String(index + 1).padStart(2));
228
+ return ` ${n}. ${item.checked ? "[x]" : "[ ]"} ${item.label}${hint}`;
229
+ }
230
+ function rl() {
231
+ return readline.createInterface({
232
+ input: process.stdin,
233
+ output: process.stdout
234
+ });
235
+ }
236
+ function question(prompt) {
237
+ const r = rl();
238
+ return new Promise((resolve) => {
239
+ r.question(prompt, (answer) => {
240
+ r.close();
241
+ resolve(answer);
242
+ });
243
+ });
244
+ }
245
+ var stdinPrompter = {
246
+ async ask(q, def) {
247
+ const suffix = def ? ` ${color.dim(`(${def})`)}` : "";
248
+ const answer = (await question(`${q}${suffix} `)).trim();
249
+ return answer || def || "";
250
+ },
251
+ async confirm(q, def = true) {
252
+ const hint = def ? "Y/n" : "y/N";
253
+ const answer = (await question(`${q} ${color.dim(`[${hint}]`)} `)).trim().toLowerCase();
254
+ if (!answer) return def;
255
+ return answer === "y" || answer === "yes";
256
+ },
257
+ async select(q, labels, def = 0) {
258
+ const r = rl();
259
+ try {
260
+ while (true) {
261
+ const lines = [
262
+ q,
263
+ ...labels.map((l, i) => ` ${color.cyan(String(i + 1))}. ${l}`)
264
+ ];
265
+ process.stdout.write(lines.join("\n") + "\n");
266
+ const answer = await new Promise((resolve) => {
267
+ r.question(
268
+ `Choose ${color.dim(`(1-${labels.length}, default ${def + 1})`)}: `,
269
+ resolve
270
+ );
271
+ });
272
+ const trimmed = answer.trim();
273
+ if (!trimmed) return def;
274
+ const n = Number(trimmed);
275
+ if (Number.isInteger(n) && n >= 1 && n <= labels.length) return n - 1;
276
+ process.stdout.write(
277
+ color.yellow(`Enter a number between 1 and ${labels.length}.
278
+ `)
279
+ );
280
+ }
281
+ } finally {
282
+ r.close();
283
+ }
284
+ },
285
+ async multiSelect(q, items) {
286
+ const r = rl();
287
+ try {
288
+ while (true) {
289
+ const lines = [q, ...items.map(renderItem)];
290
+ process.stdout.write(lines.join("\n") + "\n");
291
+ const answer = await new Promise((resolve) => {
292
+ r.question(
293
+ `Enter numbers ${color.dim('(e.g. 1,3 or 1-2), "all", "none", or Enter for the checked defaults')}: `,
294
+ resolve
295
+ );
296
+ });
297
+ const parsed = parseSelection(answer, items);
298
+ if (parsed.ok) return parsed.indices;
299
+ process.stdout.write(color.yellow(`${parsed.error}
300
+ `));
301
+ }
302
+ } finally {
303
+ r.close();
304
+ }
305
+ }
306
+ };
307
+ function readStdinLine() {
308
+ const stdin = process.stdin;
309
+ let settled = false;
310
+ let onData;
311
+ let onEnd;
312
+ let buffer = "";
313
+ const promise = new Promise((resolve) => {
314
+ const finish = (value) => {
315
+ if (settled) return;
316
+ settled = true;
317
+ if (onData) stdin.off("data", onData);
318
+ if (onEnd) stdin.off("end", onEnd);
319
+ stdin.pause();
320
+ resolve(value);
321
+ };
322
+ onData = (chunk) => {
323
+ buffer += chunk.toString("utf8");
324
+ const nl = buffer.indexOf("\n");
325
+ if (nl >= 0) finish(buffer.slice(0, nl).trim());
326
+ };
327
+ onEnd = () => finish(buffer.trim() || void 0);
328
+ stdin.resume();
329
+ stdin.on("data", onData);
330
+ stdin.on("end", onEnd);
331
+ });
332
+ const cancel = () => {
333
+ if (settled) return;
334
+ settled = true;
335
+ if (onData) stdin.off("data", onData);
336
+ if (onEnd) stdin.off("end", onEnd);
337
+ stdin.pause();
338
+ };
339
+ return { promise, cancel };
340
+ }
341
+
342
+ // src/auth.ts
343
+ function pkcePair() {
344
+ const verifier = randomBytes(32).toString("base64url");
345
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
346
+ return { verifier, challenge };
347
+ }
348
+ function configDir(env = process.env) {
349
+ const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.trim();
350
+ const base = xdg || path.join(env.HOME || os.homedir(), ".config");
351
+ return path.join(base, "crumbtrail");
352
+ }
353
+ function authFilePath(env = process.env) {
354
+ return path.join(configDir(env), "auth.json");
355
+ }
356
+ function loadAuth(env = process.env) {
357
+ try {
358
+ const raw = readFileSync(authFilePath(env), "utf8");
359
+ const parsed = JSON.parse(raw);
360
+ if (typeof parsed.token === "string" && parsed.token) {
361
+ return {
362
+ token: parsed.token,
363
+ expiresAt: String(parsed.expiresAt ?? ""),
364
+ endpoint: String(parsed.endpoint ?? "")
365
+ };
366
+ }
367
+ } catch {
368
+ }
369
+ return void 0;
370
+ }
371
+ function saveAuth(auth, env = process.env) {
372
+ const dir = configDir(env);
373
+ mkdirSync(dir, { recursive: true, mode: 448 });
374
+ const file = authFilePath(env);
375
+ writeFileSync(file, JSON.stringify(auth, null, 2) + "\n", { mode: 384 });
376
+ chmodSync(file, 384);
377
+ }
378
+ function clearAuth(env = process.env) {
379
+ const file = authFilePath(env);
380
+ if (!existsSync(file)) return false;
381
+ rmSync(file, { force: true });
382
+ return true;
383
+ }
384
+ function openBrowser(url, spawnFn = spawn) {
385
+ const platform = process.platform;
386
+ let cmd;
387
+ let args;
388
+ if (platform === "darwin") {
389
+ cmd = "open";
390
+ args = [url];
391
+ } else if (platform === "win32") {
392
+ cmd = "cmd";
393
+ args = ["/c", "start", "", url];
394
+ } else {
395
+ cmd = "xdg-open";
396
+ args = [url];
397
+ }
398
+ return new Promise((resolve) => {
399
+ try {
400
+ const child = spawnFn(cmd, args, { stdio: "ignore", detached: true });
401
+ let settled = false;
402
+ child.on("error", () => {
403
+ if (settled) return;
404
+ settled = true;
405
+ resolve(false);
406
+ });
407
+ child.on("spawn", () => {
408
+ if (settled) return;
409
+ settled = true;
410
+ resolve(true);
411
+ });
412
+ child.unref();
413
+ } catch {
414
+ resolve(false);
415
+ }
416
+ });
417
+ }
418
+ function canUseBrowser(noBrowser, env = process.env) {
419
+ if (noBrowser) return false;
420
+ if (process.platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY) {
421
+ return false;
422
+ }
423
+ return true;
424
+ }
425
+ var CALLBACK_PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Crumbtrail CLI</title>
426
+ <style>body{font-family:system-ui,sans-serif;background:#0f172a;color:#e2e8f0;display:grid;place-items:center;height:100vh;margin:0}
427
+ .card{background:#1e293b;padding:2rem 2.5rem;border-radius:12px;text-align:center;box-shadow:0 10px 40px rgba(0,0,0,.4)}
428
+ h1{color:#319B7C;margin:0 0 .5rem;font-size:1.25rem}p{margin:0;color:#94a3b8}</style></head>
429
+ <body><div class="card"><h1>Crumbtrail connected \u2713</h1><p>You can close this tab and return to your terminal.</p></div></body></html>`;
430
+ function startCallbackServer() {
431
+ return new Promise((resolve, reject) => {
432
+ let resolveCode;
433
+ let rejectCode;
434
+ const waitForCode = new Promise((res, rej) => {
435
+ resolveCode = res;
436
+ rejectCode = rej;
437
+ });
438
+ waitForCode.catch(() => {
439
+ });
440
+ const server = http.createServer((req, res) => {
441
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
442
+ if (url.pathname !== "/callback") {
443
+ res.writeHead(404);
444
+ res.end();
445
+ return;
446
+ }
447
+ const code = url.searchParams.get("code");
448
+ res.writeHead(code ? 200 : 400, {
449
+ "Content-Type": "text/html; charset=utf-8"
450
+ });
451
+ res.end(CALLBACK_PAGE);
452
+ if (code) resolveCode(code);
453
+ });
454
+ server.on("error", reject);
455
+ server.listen(0, "127.0.0.1", () => {
456
+ const addr = server.address();
457
+ if (!addr || typeof addr !== "object") {
458
+ reject(new Error("callback server failed to bind"));
459
+ return;
460
+ }
461
+ resolve({
462
+ port: addr.port,
463
+ waitForCode,
464
+ close: () => {
465
+ rejectCode(new Error("cancelled"));
466
+ server.close();
467
+ }
468
+ });
469
+ });
470
+ });
471
+ }
472
+ async function exchangeCode(base, args, fetchImpl) {
473
+ return requestJson(`${base}/api/cli/token`, {
474
+ method: "POST",
475
+ body: { code: args.code, verifier: args.verifier },
476
+ fetchImpl
477
+ });
478
+ }
479
+ async function validateToken(base, token, fetchImpl) {
480
+ try {
481
+ await requestJson(`${base}/api/projects`, { token, fetchImpl });
482
+ return "valid";
483
+ } catch (err) {
484
+ if (err instanceof ApiError && err.status === 401) return "invalid";
485
+ throw err;
486
+ }
487
+ }
488
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
489
+ async function loginBrowser(opts) {
490
+ const { verifier, challenge } = pkcePair();
491
+ const server = await startCallbackServer();
492
+ const authorizeUrl = `${opts.base}/cli/authorize?port=${server.port}&challenge=${challenge}`;
493
+ const open = opts.openFn ?? openBrowser;
494
+ const opened = await open(authorizeUrl);
495
+ if (!opened) {
496
+ server.close();
497
+ throw new Error("could not open a browser");
498
+ }
499
+ opts.ui.out(`Opened your browser to authorize the CLI:`);
500
+ opts.ui.out(` ${color.cyan(authorizeUrl)}`);
501
+ opts.ui.out(
502
+ color.dim(`Waiting for approval\u2026 (or paste the code shown in the browser)`)
503
+ );
504
+ const stdin = readStdinLine();
505
+ const pastedCode = stdin.promise.then(
506
+ (line) => line ? line : new Promise(() => {
507
+ })
508
+ );
509
+ const deadlineMs = opts.browserDeadlineMs ?? 5 * 60 * 1e3;
510
+ let deadlineTimer;
511
+ const deadline = new Promise((_, reject) => {
512
+ deadlineTimer = setTimeout(() => {
513
+ reject(
514
+ new Error(
515
+ "Browser authorization timed out \u2014 run `crumbtrail login` again."
516
+ )
517
+ );
518
+ }, deadlineMs);
519
+ deadlineTimer.unref?.();
520
+ });
521
+ try {
522
+ const code = await Promise.race([server.waitForCode, pastedCode, deadline]);
523
+ return await exchangeCode(opts.base, { code, verifier }, opts.fetchImpl);
524
+ } finally {
525
+ if (deadlineTimer) clearTimeout(deadlineTimer);
526
+ stdin.cancel();
527
+ server.close();
528
+ }
529
+ }
530
+ async function loginDevice(opts) {
531
+ const device = await requestJson(`${opts.base}/api/cli/device`, {
532
+ method: "POST",
533
+ body: {},
534
+ fetchImpl: opts.fetchImpl
535
+ });
536
+ opts.ui.out("");
537
+ opts.ui.out(`To authorize this CLI, visit:`);
538
+ opts.ui.out(` ${color.cyan(device.verificationUri)}`);
539
+ opts.ui.out(`and enter the code: ${color.bold(device.userCode)}`);
540
+ opts.ui.out(color.dim("Waiting for approval\u2026"));
541
+ const intervalMs = opts.pollIntervalMs ?? Math.max(1, device.interval) * 1e3;
542
+ const deadline = Date.now() + Math.max(1, device.expiresIn) * 1e3;
543
+ while (true) {
544
+ if (Date.now() > deadline) {
545
+ throw new Error(
546
+ "Device authorization expired \u2014 run `crumbtrail login` again."
547
+ );
548
+ }
549
+ await sleep(intervalMs);
550
+ try {
551
+ return await requestJson(`${opts.base}/api/cli/token`, {
552
+ method: "POST",
553
+ body: { deviceCode: device.deviceCode },
554
+ fetchImpl: opts.fetchImpl,
555
+ // Don't retry-on-5xx here; the polling loop is the retry.
556
+ retry: false
557
+ });
558
+ } catch (err) {
559
+ if (err instanceof ApiError && err.code === "authorization_pending") {
560
+ continue;
561
+ }
562
+ throw err;
563
+ }
564
+ }
565
+ }
566
+ async function login(opts) {
567
+ const env = opts.env ?? process.env;
568
+ if (canUseBrowser(opts.noBrowser ?? false, env)) {
569
+ try {
570
+ return await loginBrowser(opts);
571
+ } catch {
572
+ opts.ui.out(
573
+ color.dim(
574
+ "Browser hand-off unavailable \u2014 falling back to device code."
575
+ )
576
+ );
577
+ return loginDevice(opts);
578
+ }
579
+ }
580
+ return loginDevice(opts);
581
+ }
582
+ async function ensureToken(opts) {
583
+ const env = opts.env ?? process.env;
584
+ const stored = loadAuth(env);
585
+ if (stored && stored.token && stored.endpoint === opts.base) {
586
+ const state = await validateToken(opts.base, stored.token, opts.fetchImpl);
587
+ if (state === "valid") {
588
+ opts.ui.out(color.dim("Using your saved Crumbtrail login."));
589
+ return stored.token;
590
+ }
591
+ clearAuth(env);
592
+ opts.ui.out(color.dim("Saved login expired \u2014 signing in again."));
593
+ }
594
+ const minted = await login(opts);
595
+ saveAuth(
596
+ { token: minted.token, expiresAt: minted.expiresAt, endpoint: opts.base },
597
+ env
598
+ );
599
+ opts.ui.out(color.green("Logged in \u2713"));
600
+ return minted.token;
601
+ }
602
+
603
+ // src/provision.ts
604
+ var UpgradeRequiredError = class extends Error {
605
+ upgradeUrl;
606
+ constructor(message, upgradeUrl) {
607
+ super(message);
608
+ this.name = "UpgradeRequiredError";
609
+ this.upgradeUrl = upgradeUrl;
610
+ }
611
+ };
612
+ function inferProjectName(pkgName, gitDirBasename) {
613
+ const fromPkg = pkgName?.trim();
614
+ if (fromPkg) {
615
+ const last = fromPkg.split("/").pop();
616
+ if (last) return last;
617
+ }
618
+ const fromDir = gitDirBasename?.trim();
619
+ if (fromDir) return fromDir;
620
+ return "my-app";
621
+ }
622
+ function inferServiceName(recipe, workspaceName) {
623
+ const ws = workspaceName?.trim();
624
+ if (ws) {
625
+ const last = ws.split("/").pop();
626
+ if (last) return last;
627
+ }
628
+ return RECIPE_REGISTRY[recipe].serviceName;
629
+ }
630
+ async function listProjects(base, token, fetchImpl) {
631
+ const res = await requestJson(
632
+ `${base}/api/projects`,
633
+ { token, fetchImpl }
634
+ );
635
+ return Array.isArray(res.projects) ? res.projects : [];
636
+ }
637
+ async function createProject(base, token, name, fetchImpl) {
638
+ try {
639
+ return await requestJson(`${base}/api/projects`, {
640
+ method: "POST",
641
+ token,
642
+ body: { name },
643
+ fetchImpl
644
+ });
645
+ } catch (err) {
646
+ if (err instanceof ApiError && err.status === 402) {
647
+ const body = err.body && typeof err.body === "object" ? err.body : {};
648
+ const copy = typeof body.error === "string" ? body.error : "The free tier includes one project. Upgrade to add more.";
649
+ const url = typeof body.upgradeUrl === "string" ? body.upgradeUrl : typeof body.billingUrl === "string" ? body.billingUrl : void 0;
650
+ throw new UpgradeRequiredError(copy, url);
651
+ }
652
+ throw err;
653
+ }
654
+ }
655
+ async function createService(base, token, projectId, args, fetchImpl) {
656
+ return requestJson(`${base}/api/projects/${projectId}/services`, {
657
+ method: "POST",
658
+ token,
659
+ body: { name: args.name, stack: args.stack },
660
+ fetchImpl
661
+ });
662
+ }
663
+ async function createServiceKey(base, token, serviceId, fetchImpl) {
664
+ const res = await requestJson(
665
+ `${base}/api/services/${serviceId}/keys`,
666
+ { method: "POST", token, body: {}, fetchImpl }
667
+ );
668
+ return res.apiKey;
669
+ }
670
+ async function resolveProject(input) {
671
+ const { base, token, ui, prompter, fetchImpl } = input;
672
+ let project;
673
+ if (input.projectId) {
674
+ project = { id: input.projectId, name: input.projectId };
675
+ } else {
676
+ const existing = await listProjects(base, token, fetchImpl);
677
+ if (existing.length > 0 && !input.assumeYes) {
678
+ const labels = [
679
+ `Create a new project (${input.defaultProjectName})`,
680
+ ...existing.map((p) => p.name)
681
+ ];
682
+ const choice = await prompter.select(
683
+ "Which project should this app report to?",
684
+ labels,
685
+ 0
686
+ );
687
+ if (choice === 0) {
688
+ const name = await prompter.ask(
689
+ "New project name",
690
+ input.defaultProjectName
691
+ );
692
+ project = await createProject(base, token, name, fetchImpl);
693
+ } else {
694
+ project = existing[choice - 1];
695
+ }
696
+ } else {
697
+ project = await createProject(
698
+ base,
699
+ token,
700
+ input.defaultProjectName,
701
+ fetchImpl
702
+ );
703
+ }
704
+ }
705
+ ui.out(`${color.green("\u2713")} Project: ${color.bold(project.name)}`);
706
+ return project;
707
+ }
708
+ async function provisionService(input) {
709
+ const { base, token, ui, fetchImpl } = input;
710
+ const serviceStack = input.stack ?? RECIPE_REGISTRY[input.recipe].stack;
711
+ const service = await createService(
712
+ base,
713
+ token,
714
+ input.projectId,
715
+ { name: input.serviceName, stack: serviceStack },
716
+ fetchImpl
717
+ );
718
+ ui.out(`${color.green("\u2713")} Service: ${color.bold(service.name)}`);
719
+ const apiKey = await createServiceKey(base, token, service.id, fetchImpl);
720
+ ui.out(`${color.green("\u2713")} Minted an ingest key.`);
721
+ return { serviceId: service.id, serviceName: service.name, apiKey };
722
+ }
723
+ function uniqueServiceNames(candidates) {
724
+ const taken = /* @__PURE__ */ new Set();
725
+ return candidates.map((c) => {
726
+ const options = [
727
+ c.name,
728
+ c.relDir.split("/").filter(Boolean).pop() ?? c.name,
729
+ // Distinguish apps/web from packages/web: use the parent too.
730
+ c.relDir.split("/").filter(Boolean).join("-")
731
+ ];
732
+ for (const option of options) {
733
+ if (option && !taken.has(option)) {
734
+ taken.add(option);
735
+ return option;
736
+ }
737
+ }
738
+ let n = 2;
739
+ while (taken.has(`${c.name}-${n}`)) n += 1;
740
+ const fallback = `${c.name}-${n}`;
741
+ taken.add(fallback);
742
+ return fallback;
743
+ });
744
+ }
745
+ async function provisionFlow(input) {
746
+ const project = await resolveProject(input);
747
+ let serviceName = input.defaultServiceName;
748
+ if (!input.assumeYes) {
749
+ serviceName = await input.prompter.ask(
750
+ "Service name",
751
+ input.defaultServiceName
752
+ );
753
+ }
754
+ const service = await provisionService({
755
+ base: input.base,
756
+ token: input.token,
757
+ projectId: project.id,
758
+ recipe: input.recipe,
759
+ stack: input.stack,
760
+ serviceName,
761
+ ui: input.ui,
762
+ fetchImpl: input.fetchImpl
763
+ });
764
+ return {
765
+ projectId: project.id,
766
+ projectName: project.name,
767
+ serviceId: service.serviceId,
768
+ serviceName: service.serviceName,
769
+ apiKey: service.apiKey
770
+ };
771
+ }
772
+
773
+ // src/verify.ts
774
+ import { randomBytes as randomBytes2 } from "crypto";
775
+
776
+ // src/poll.ts
777
+ var DEFAULT_INGEST_POLL_CONFIG = {
778
+ initialDelayMs: 3e3,
779
+ maxDelayMs: 1e4,
780
+ timeoutMs: 5 * 60 * 1e3
781
+ };
782
+ function initialIngestPollState() {
783
+ return { status: "waiting", attempts: 0, elapsedMs: 0 };
784
+ }
785
+ function nextPollDelayMs(state, config = DEFAULT_INGEST_POLL_CONFIG) {
786
+ if (state.status !== "waiting") return 0;
787
+ const grown = config.initialDelayMs * (state.attempts + 1);
788
+ return Math.min(grown, config.maxDelayMs);
789
+ }
790
+ function recordPollAttempt(state, found, waitedMs, config = DEFAULT_INGEST_POLL_CONFIG) {
791
+ if (state.status !== "waiting") return state;
792
+ const attempts = state.attempts + 1;
793
+ const elapsedMs = state.elapsedMs + Math.max(0, waitedMs);
794
+ if (found) return { status: "found", attempts, elapsedMs };
795
+ if (elapsedMs >= config.timeoutMs)
796
+ return { status: "timedout", attempts, elapsedMs };
797
+ return { status: "waiting", attempts, elapsedMs };
798
+ }
799
+
800
+ // src/verify.ts
801
+ var CLI_CHECK_PREFIX = "cli-check-";
802
+ function syntheticSessionId() {
803
+ return `${CLI_CHECK_PREFIX}${randomBytes2(8).toString("hex")}`;
804
+ }
805
+ function firstRealSession(sessions, wizardStart) {
806
+ return sessions.find((s) => {
807
+ if (s.id.startsWith(CLI_CHECK_PREFIX)) return false;
808
+ if (wizardStart == null) return true;
809
+ const started = s.startedAt ? Date.parse(s.startedAt) : NaN;
810
+ return Number.isFinite(started) && started >= wizardStart;
811
+ });
812
+ }
813
+ async function syntheticCheck(base, apiKey, fetchImpl) {
814
+ const sessionId = syntheticSessionId();
815
+ const headers = { "X-Crumbtrail-Auth": apiKey };
816
+ await requestJson(`${base}/api/session/start`, {
817
+ method: "POST",
818
+ headers,
819
+ body: { sessionId, metadata: { source: "cli-check" } },
820
+ fetchImpl
821
+ });
822
+ await requestJson(`${base}/api/events`, {
823
+ method: "POST",
824
+ headers,
825
+ body: {
826
+ sessionId,
827
+ events: [
828
+ { t: Date.now(), k: "con", d: { level: "log", args: ["cli-check"] } }
829
+ ]
830
+ },
831
+ fetchImpl
832
+ });
833
+ await requestJson(`${base}/api/session/end`, {
834
+ method: "POST",
835
+ headers,
836
+ body: { sessionId },
837
+ fetchImpl
838
+ });
839
+ }
840
+ function realSleep(ms, signal) {
841
+ return new Promise((resolve) => {
842
+ if (signal?.aborted) return resolve();
843
+ const timer = setTimeout(resolve, ms);
844
+ signal?.addEventListener(
845
+ "abort",
846
+ () => {
847
+ clearTimeout(timer);
848
+ resolve();
849
+ },
850
+ { once: true }
851
+ );
852
+ });
853
+ }
854
+ async function fetchSessions(base, token, projectId, fetchImpl) {
855
+ const res = await requestJson(
856
+ `${base}/api/sessions?projectId=${encodeURIComponent(projectId)}`,
857
+ { token, fetchImpl }
858
+ );
859
+ return Array.isArray(res.sessions) ? res.sessions : [];
860
+ }
861
+ async function pollForRealEvent(opts) {
862
+ const config = opts.config ?? DEFAULT_INGEST_POLL_CONFIG;
863
+ const sleep2 = opts.sleepFn ?? realSleep;
864
+ opts.ui.out("");
865
+ opts.ui.out(
866
+ color.bold("Now start your dev server and load a page in your browser.")
867
+ );
868
+ if (!opts.ui.status) {
869
+ opts.ui.out(
870
+ color.dim("Waiting for the first real event\u2026 (Ctrl-C to skip)")
871
+ );
872
+ }
873
+ let state = initialIngestPollState();
874
+ let sessionId;
875
+ while (state.status === "waiting") {
876
+ const delay = nextPollDelayMs(state, config);
877
+ opts.ui.status?.(
878
+ color.dim(
879
+ `Waiting for the first real event\u2026 ${Math.round((state.elapsedMs + delay) / 1e3)}s (Ctrl-C to skip)`
880
+ )
881
+ );
882
+ await sleep2(delay, opts.signal);
883
+ if (opts.signal?.aborted) {
884
+ opts.ui.status?.();
885
+ return { outcome: "cancelled" };
886
+ }
887
+ let found;
888
+ try {
889
+ const sessions = await fetchSessions(
890
+ opts.base,
891
+ opts.token,
892
+ opts.projectId,
893
+ opts.fetchImpl
894
+ );
895
+ const real = firstRealSession(sessions, opts.wizardStart);
896
+ found = real !== void 0;
897
+ if (real) sessionId = real.id;
898
+ } catch {
899
+ found = false;
900
+ }
901
+ state = recordPollAttempt(state, found, delay, config);
902
+ }
903
+ opts.ui.status?.();
904
+ return state.status === "found" ? { outcome: "found", sessionId } : { outcome: "timedout" };
905
+ }
906
+ function realSessionsByService(sessions, wizardStart) {
907
+ const found = /* @__PURE__ */ new Map();
908
+ for (const s of [...sessions].reverse()) {
909
+ if (!s.serviceId || found.has(s.serviceId)) continue;
910
+ if (s.id.startsWith(CLI_CHECK_PREFIX)) continue;
911
+ if (wizardStart != null) {
912
+ const started = s.startedAt ? Date.parse(s.startedAt) : NaN;
913
+ if (!Number.isFinite(started) || started < wizardStart) continue;
914
+ }
915
+ found.set(s.serviceId, s.id);
916
+ }
917
+ return found;
918
+ }
919
+ async function pollForServices(opts) {
920
+ const config = opts.config ?? DEFAULT_INGEST_POLL_CONFIG;
921
+ const sleep2 = opts.sleepFn ?? realSleep;
922
+ const total = opts.serviceIds.length;
923
+ const wanted = new Set(opts.serviceIds);
924
+ const found = /* @__PURE__ */ new Map();
925
+ opts.ui.out("");
926
+ opts.ui.out(color.bold("Now start your services so they can report in."));
927
+ if (!opts.ui.status) {
928
+ opts.ui.out(color.dim("Waiting for first events\u2026 (Ctrl-C to skip)"));
929
+ }
930
+ let state = initialIngestPollState();
931
+ while (state.status === "waiting") {
932
+ const delay = nextPollDelayMs(state, config);
933
+ opts.ui.status?.(
934
+ color.dim(
935
+ `Waiting for first events\u2026 ${found.size}/${total} services \xB7 ${Math.round((state.elapsedMs + delay) / 1e3)}s (Ctrl-C to skip)`
936
+ )
937
+ );
938
+ await sleep2(delay, opts.signal);
939
+ if (opts.signal?.aborted) {
940
+ opts.ui.status?.();
941
+ return { outcome: "cancelled", found: Object.fromEntries(found) };
942
+ }
943
+ try {
944
+ const sessions = await fetchSessions(
945
+ opts.base,
946
+ opts.token,
947
+ opts.projectId,
948
+ opts.fetchImpl
949
+ );
950
+ for (const [serviceId, sessionId] of realSessionsByService(
951
+ sessions,
952
+ opts.wizardStart
953
+ )) {
954
+ if (!wanted.has(serviceId) || found.has(serviceId)) continue;
955
+ found.set(serviceId, sessionId);
956
+ opts.ui.status?.();
957
+ opts.onFound?.(serviceId, sessionId);
958
+ }
959
+ } catch {
960
+ }
961
+ state = recordPollAttempt(state, found.size === total, delay, config);
962
+ }
963
+ opts.ui.status?.();
964
+ return {
965
+ outcome: state.status === "found" ? "found" : "timedout",
966
+ found: Object.fromEntries(found)
967
+ };
968
+ }
969
+
970
+ // src/discover.ts
971
+ import fs from "fs";
972
+ import path3 from "path";
973
+
974
+ // src/otlp-guide.ts
975
+ import path2 from "path";
976
+ var OTLP_GUIDE_FILENAME = "CRUMBTRAIL-OTLP.md";
977
+ function renderOtlpGuide(input) {
978
+ return [
979
+ `# Crumbtrail \u2014 ${input.serviceName}`,
980
+ "",
981
+ `Detected stack: **${input.stack}**`,
982
+ `Ingest endpoint: ${input.endpoint}`,
983
+ "",
984
+ `This service is already provisioned in Crumbtrail and has its own ingest key.`,
985
+ `There is no SDK to install: ${input.stack} speaks OpenTelemetry, so Crumbtrail`,
986
+ `accepts its traces directly. Everything below is the one manual step.`,
987
+ "",
988
+ "## 1. Configure the OTLP exporter",
989
+ "",
990
+ "```sh",
991
+ input.snippet,
992
+ "```",
993
+ "",
994
+ "## 2. Or hand this to a coding agent",
995
+ "",
996
+ "```",
997
+ input.agentPrompt,
998
+ "```",
999
+ "",
1000
+ "## Keep the key out of git",
1001
+ "",
1002
+ "The ingest key above is a live credential. Move it into your secret store or",
1003
+ "environment config and make sure this file (or wherever the key lands) is not",
1004
+ "committed to a public repository.",
1005
+ ""
1006
+ ].join("\n");
1007
+ }
1008
+ function otlpGuidePlan(dir, body) {
1009
+ return {
1010
+ recipe: "otlp",
1011
+ kind: "create",
1012
+ targetPath: path2.join(dir, OTLP_GUIDE_FILENAME),
1013
+ content: body,
1014
+ warnings: []
1015
+ };
1016
+ }
1017
+
1018
+ // src/discover.ts
1019
+ var SCAN_PARENTS = ["apps", "services", "packages"];
1020
+ var SKIP_DIRS = /* @__PURE__ */ new Set([
1021
+ "node_modules",
1022
+ "dist",
1023
+ "build",
1024
+ "out",
1025
+ "coverage",
1026
+ "target",
1027
+ "vendor",
1028
+ "__pycache__",
1029
+ ".next",
1030
+ ".git"
1031
+ ]);
1032
+ var MAX_SCAN_DIRS = 200;
1033
+ function defaultReadDir(dir) {
1034
+ try {
1035
+ return fs.readdirSync(dir);
1036
+ } catch {
1037
+ return [];
1038
+ }
1039
+ }
1040
+ function defaultIsDir(p) {
1041
+ try {
1042
+ return fs.statSync(p).isDirectory();
1043
+ } catch {
1044
+ return false;
1045
+ }
1046
+ }
1047
+ function defaultIsFile(p) {
1048
+ try {
1049
+ return fs.statSync(p).isFile();
1050
+ } catch {
1051
+ return false;
1052
+ }
1053
+ }
1054
+ function looksLikeLibrary(recipe, pkg) {
1055
+ if (recipe !== "node" || !pkg) return false;
1056
+ if (pkg.bin) return false;
1057
+ const scripts = pkg.scripts ?? {};
1058
+ return !["start", "dev", "serve", "start:prod"].some((s) => scripts[s]);
1059
+ }
1060
+ function readPkg(dir, isFile) {
1061
+ const file = path3.join(dir, "package.json");
1062
+ if (!isFile(file)) return null;
1063
+ try {
1064
+ return JSON.parse(fs.readFileSync(file, "utf8"));
1065
+ } catch {
1066
+ return null;
1067
+ }
1068
+ }
1069
+ function classify(root, dir, source, fallbackName, deps) {
1070
+ const detected = deps.detect(dir);
1071
+ const recipe = detected.recipe;
1072
+ const pkg = readPkg(dir, deps.isFile);
1073
+ const flags = [];
1074
+ const isOtlp = recipe === "otlp";
1075
+ const wired = isOtlp ? deps.isFile(path3.join(dir, OTLP_GUIDE_FILENAME)) : recipe != null && deps.alreadyWired(dir);
1076
+ if (recipe == null) flags.push("no-recipe");
1077
+ if (isOtlp) flags.push("otlp");
1078
+ if (looksLikeLibrary(recipe, pkg)) flags.push("likely-library");
1079
+ if (detected.ambiguous && recipe != null) flags.push("ambiguous");
1080
+ if (wired) flags.push("already-wired");
1081
+ const selectable = recipe != null;
1082
+ const defaultChecked = selectable && source === "workspace" && RECIPE_REGISTRY[recipe].kind === "inject" && !detected.ambiguous && !flags.includes("likely-library") && !wired;
1083
+ return {
1084
+ dir,
1085
+ name: pkg?.name?.split("/").pop() ?? fallbackName,
1086
+ relDir: path3.relative(root, dir) || ".",
1087
+ source,
1088
+ detected,
1089
+ recipe,
1090
+ flags,
1091
+ defaultChecked,
1092
+ selectable
1093
+ };
1094
+ }
1095
+ function discoverServices(root, rootResult, overrides = {}) {
1096
+ const isFile = overrides.isFile ?? defaultIsFile;
1097
+ const isDir = overrides.isDir ?? defaultIsDir;
1098
+ const readDir = overrides.readDir ?? defaultReadDir;
1099
+ const deps = {
1100
+ detect: overrides.detect ?? detect,
1101
+ alreadyWired: overrides.alreadyWired ?? ((dir) => projectAlreadyWired(dir, defaultInjectIO)),
1102
+ isFile
1103
+ };
1104
+ const byDir = /* @__PURE__ */ new Map();
1105
+ for (const ws of rootResult.workspaces) {
1106
+ const dir = path3.resolve(ws.dir);
1107
+ if (byDir.has(dir)) continue;
1108
+ byDir.set(dir, classify(root, dir, "workspace", ws.name, deps));
1109
+ }
1110
+ let scanned = 0;
1111
+ const parents = [
1112
+ ...SCAN_PARENTS.map((p) => path3.join(root, p)).filter(isDir),
1113
+ root
1114
+ ];
1115
+ for (const parent of parents) {
1116
+ for (const entry of readDir(parent)) {
1117
+ if (scanned >= MAX_SCAN_DIRS) break;
1118
+ if (entry.startsWith(".") || SKIP_DIRS.has(entry)) continue;
1119
+ const dir = path3.join(parent, entry);
1120
+ if (!isDir(dir) || byDir.has(dir)) continue;
1121
+ scanned += 1;
1122
+ const candidate = classify(root, dir, "scan", entry, deps);
1123
+ if (candidate.recipe === "otlp") byDir.set(dir, candidate);
1124
+ }
1125
+ }
1126
+ return [...byDir.values()].sort((a, b) => {
1127
+ if (a.source !== b.source) return a.source === "workspace" ? -1 : 1;
1128
+ return a.relDir.localeCompare(b.relDir);
1129
+ });
1130
+ }
1131
+
1132
+ // src/cli.ts
1133
+ function readVersion() {
1134
+ try {
1135
+ const pkg = JSON.parse(
1136
+ readFileSync2(path4.join(__dirnameCompat(), "..", "package.json"), "utf8")
1137
+ );
1138
+ return pkg.version ?? "0.0.0";
1139
+ } catch {
1140
+ return "0.0.0";
1141
+ }
1142
+ }
1143
+ function __dirnameCompat() {
1144
+ if (typeof __dirname !== "undefined") return __dirname;
1145
+ return process.cwd();
1146
+ }
1147
+ function parseArgs(argv) {
1148
+ const args = argv.slice(2);
1149
+ const parsed = {
1150
+ command: "wizard",
1151
+ yes: false,
1152
+ noBrowser: false,
1153
+ skipVerify: false,
1154
+ all: false
1155
+ };
1156
+ let commandSet = false;
1157
+ for (let i = 0; i < args.length; i++) {
1158
+ const a = args[i];
1159
+ switch (a) {
1160
+ case "--help":
1161
+ case "-h":
1162
+ parsed.command = "help";
1163
+ return parsed;
1164
+ case "--version":
1165
+ case "-v":
1166
+ parsed.command = "version";
1167
+ return parsed;
1168
+ case "--yes":
1169
+ case "-y":
1170
+ parsed.yes = true;
1171
+ break;
1172
+ case "--no-browser":
1173
+ parsed.noBrowser = true;
1174
+ break;
1175
+ case "--skip-verify":
1176
+ parsed.skipVerify = true;
1177
+ break;
1178
+ case "--project":
1179
+ parsed.project = args[++i];
1180
+ break;
1181
+ case "--endpoint":
1182
+ parsed.endpoint = args[++i];
1183
+ break;
1184
+ case "--all":
1185
+ parsed.all = true;
1186
+ break;
1187
+ case "--only":
1188
+ (parsed.only ??= []).push(args[++i]);
1189
+ break;
1190
+ default:
1191
+ if (a.startsWith("--project=")) {
1192
+ parsed.project = a.slice("--project=".length);
1193
+ } else if (a.startsWith("--endpoint=")) {
1194
+ parsed.endpoint = a.slice("--endpoint=".length);
1195
+ } else if (a.startsWith("--only=")) {
1196
+ (parsed.only ??= []).push(a.slice("--only=".length));
1197
+ } else if (!commandSet && (a === "login" || a === "logout")) {
1198
+ parsed.command = a;
1199
+ commandSet = true;
1200
+ } else if (!a.startsWith("-")) {
1201
+ parsed.unknown = a;
1202
+ } else {
1203
+ parsed.unknown = a;
1204
+ }
1205
+ }
1206
+ }
1207
+ return parsed;
1208
+ }
1209
+ var USAGE = `crumbtrail \u2014 set up Crumbtrail in your app
1210
+
1211
+ Usage:
1212
+ crumbtrail [options] Run the setup wizard (detect \u2192 login \u2192 wire \u2192 verify)
1213
+ crumbtrail login Log in and cache a token, nothing else
1214
+ crumbtrail logout Delete the cached token
1215
+
1216
+ In a monorepo, run it from the repo root: it scans every workspace and service,
1217
+ shows you what it found, and wires the ones you pick.
1218
+
1219
+ Options:
1220
+ --yes, -y Skip confirmations (required with --project in CI)
1221
+ --project <id> Attach to an existing project (skip creation)
1222
+ --only <name> Monorepo: wire only this service (repeatable)
1223
+ --all Monorepo: wire every service it can, no prompt
1224
+ --no-browser Use the device-code login flow
1225
+ --skip-verify Don't wait for the first event
1226
+ --endpoint <url> Cloud endpoint (else $CRUMBTRAIL_BASE_URL, else default)
1227
+ --help, -h Show this help
1228
+ --version, -v Print the version`;
1229
+ function sdkPackagesFor(recipe) {
1230
+ return RECIPE_REGISTRY[recipe].sdkPackages;
1231
+ }
1232
+ function pmInvocation(pm) {
1233
+ switch (pm) {
1234
+ case "pnpm":
1235
+ return { cmd: "pnpm", add: "add" };
1236
+ case "yarn":
1237
+ return { cmd: "yarn", add: "add" };
1238
+ case "bun":
1239
+ return { cmd: "bun", add: "add" };
1240
+ default:
1241
+ return { cmd: "npm", add: "install" };
1242
+ }
1243
+ }
1244
+ function realSpawn(cmd, args, cwd) {
1245
+ const res = spawnSync(cmd, args, { cwd, stdio: "inherit" });
1246
+ if (res.error) return 1;
1247
+ return res.status ?? 1;
1248
+ }
1249
+ async function discoverTarballUrls(base, packages, fetchImpl) {
1250
+ try {
1251
+ const doFetch = fetchImpl ?? fetch;
1252
+ const res = await doFetch(`${base}/install/manifest.json`);
1253
+ if (!res.ok) return [];
1254
+ const body = await res.json();
1255
+ const files = Array.isArray(body.files) ? body.files.filter((f) => typeof f === "string") : [];
1256
+ const urls = [];
1257
+ for (const pkg of packages) {
1258
+ const file = files.find(
1259
+ (f) => f.startsWith(`${pkg}-`) && f.endsWith(".tgz")
1260
+ );
1261
+ if (!file) return [];
1262
+ urls.push(`${base}/install/${file}`);
1263
+ }
1264
+ return urls;
1265
+ } catch {
1266
+ return [];
1267
+ }
1268
+ }
1269
+ async function installSdk(input) {
1270
+ const packages = sdkPackagesFor(input.recipe);
1271
+ if (packages.length === 0) {
1272
+ return { installed: false, packages: [] };
1273
+ }
1274
+ const { cmd, add } = pmInvocation(input.packageManager);
1275
+ const run = input.spawnFn ?? realSpawn;
1276
+ input.ui.out(
1277
+ `Installing SDK: ${color.cyan(`${cmd} ${add} ${packages.join(" ")}`)}`
1278
+ );
1279
+ const code = run(cmd, [add, ...packages], input.cwd);
1280
+ if (code === 0) {
1281
+ return { installed: true, packages };
1282
+ }
1283
+ const tarballs = await discoverTarballUrls(
1284
+ input.base,
1285
+ packages,
1286
+ input.fetchImpl
1287
+ );
1288
+ if (tarballs.length === packages.length) {
1289
+ input.ui.out(
1290
+ color.dim(
1291
+ `Registry unavailable \u2014 installing from ${input.base}/install tarballs\u2026`
1292
+ )
1293
+ );
1294
+ const fallbackCode = run(cmd, [add, ...tarballs], input.cwd);
1295
+ if (fallbackCode === 0) {
1296
+ return {
1297
+ installed: true,
1298
+ packages,
1299
+ note: `Installed ${packages.join(", ")} from the deploy's install tarballs (registry unavailable).`
1300
+ };
1301
+ }
1302
+ return {
1303
+ installed: false,
1304
+ packages,
1305
+ note: `SDK install via ${cmd} failed; the ${input.base}/install tarball fallback also failed \u2014 wire manually or run: curl -fsSL ${input.base}/install.sh | sh`
1306
+ };
1307
+ }
1308
+ return {
1309
+ installed: false,
1310
+ packages,
1311
+ note: `SDK install via ${cmd} failed \u2014 install the tarballs instead: curl -fsSL ${input.base}/install.sh | sh`
1312
+ };
1313
+ }
1314
+ function defaultDeps() {
1315
+ return {
1316
+ detect,
1317
+ ensureToken,
1318
+ provisionFlow,
1319
+ installSdk,
1320
+ buildPlan,
1321
+ executePlan,
1322
+ syntheticCheck,
1323
+ pollForRealEvent,
1324
+ discoverServices,
1325
+ resolveProject,
1326
+ provisionService,
1327
+ pollForServices,
1328
+ openBrowserFn: openBrowser,
1329
+ ui: consoleUi,
1330
+ prompter: stdinPrompter,
1331
+ env: process.env,
1332
+ cwd: process.cwd(),
1333
+ isTTY: !!(process.stdout.isTTY && process.stdin.isTTY),
1334
+ fetchImpl: void 0
1335
+ };
1336
+ }
1337
+ function readPkgName(dir) {
1338
+ try {
1339
+ const pkg = JSON.parse(
1340
+ readFileSync2(path4.join(dir, "package.json"), "utf8")
1341
+ );
1342
+ return typeof pkg.name === "string" ? pkg.name : null;
1343
+ } catch {
1344
+ return null;
1345
+ }
1346
+ }
1347
+ async function runWizard(parsed, deps) {
1348
+ const { ui } = deps;
1349
+ const base = resolveEndpoint(parsed.endpoint, deps.env);
1350
+ const wizardStart = Date.now();
1351
+ ui.out(color.bold("\nCrumbtrail setup"));
1352
+ ui.out(color.dim(`Endpoint: ${base}
1353
+ `));
1354
+ const cwd = deps.cwd;
1355
+ const result = deps.detect(cwd);
1356
+ if (result.isMonorepo) {
1357
+ return runBatchWizard(parsed, deps, { base, wizardStart, root: result });
1358
+ }
1359
+ if (!result.recipe) {
1360
+ const isDeno = result.reasons.includes(DENO_UNSUPPORTED_REASON);
1361
+ if (isDeno) {
1362
+ ui.err(
1363
+ color.red(
1364
+ "Deno projects aren't supported yet \u2014 Crumbtrail can't wire this one."
1365
+ )
1366
+ );
1367
+ } else {
1368
+ ui.err(color.red("Couldn't detect a supported framework here."));
1369
+ }
1370
+ for (const r of result.reasons) ui.err(color.dim(` \xB7 ${r}`));
1371
+ for (const n of result.notes) ui.err(color.dim(` \xB7 ${n}`));
1372
+ if (!isDeno) {
1373
+ ui.err(
1374
+ "Supported: Next.js, SvelteKit, Nuxt, Remix, Astro, Angular, Vite SPA, NestJS, Express, Hono, Fastify, a Node server, or a non-JS backend that speaks OpenTelemetry (Django, Flask, FastAPI, Go, Rails, .NET)."
1375
+ );
1376
+ }
1377
+ return 1;
1378
+ }
1379
+ ui.out(`${color.green("\u2713")} Detected ${color.bold(result.recipe)} project.`);
1380
+ for (const n of result.notes) ui.out(color.dim(` \xB7 ${n}`));
1381
+ let token;
1382
+ try {
1383
+ token = await deps.ensureToken({
1384
+ base,
1385
+ ui,
1386
+ noBrowser: parsed.noBrowser,
1387
+ fetchImpl: deps.fetchImpl,
1388
+ env: deps.env
1389
+ });
1390
+ } catch (err) {
1391
+ ui.err(color.red(`Login failed: ${errMessage(err)}`));
1392
+ return 1;
1393
+ }
1394
+ const pkgName = readPkgName(cwd);
1395
+ const defaultProjectName = inferProjectName(pkgName, path4.basename(cwd));
1396
+ const defaultServiceName = inferServiceName(result.recipe);
1397
+ let provisioned;
1398
+ try {
1399
+ provisioned = await deps.provisionFlow({
1400
+ base,
1401
+ token,
1402
+ recipe: result.recipe,
1403
+ stack: result.otlpStack,
1404
+ ui,
1405
+ prompter: deps.prompter,
1406
+ assumeYes: parsed.yes,
1407
+ projectId: parsed.project,
1408
+ defaultProjectName,
1409
+ defaultServiceName,
1410
+ fetchImpl: deps.fetchImpl
1411
+ });
1412
+ } catch (err) {
1413
+ if (err instanceof UpgradeRequiredError) {
1414
+ ui.err("");
1415
+ ui.err(color.yellow(err.message));
1416
+ if (err.upgradeUrl) ui.err(`Upgrade: ${color.cyan(err.upgradeUrl)}`);
1417
+ return 1;
1418
+ }
1419
+ ui.err(color.red(`Provisioning failed: ${errMessage(err)}`));
1420
+ return 1;
1421
+ }
1422
+ const plan = deps.buildPlan({
1423
+ cwd,
1424
+ recipe: result.recipe,
1425
+ endpoint: base,
1426
+ apiKey: provisioned.apiKey,
1427
+ entryFile: result.entryFile,
1428
+ nextVersion: result.nextVersion,
1429
+ stack: result.otlpStack ?? void 0,
1430
+ options: { force: parsed.yes }
1431
+ });
1432
+ const install = await deps.installSdk({
1433
+ cwd,
1434
+ packageManager: result.packageManager,
1435
+ recipe: result.recipe,
1436
+ base,
1437
+ ui,
1438
+ fetchImpl: deps.fetchImpl
1439
+ });
1440
+ if (install.installed) {
1441
+ ui.out(`${color.green("\u2713")} Installed ${install.packages.join(", ")}.`);
1442
+ } else if (install.note) {
1443
+ ui.out(color.yellow(`! ${install.note}`));
1444
+ }
1445
+ const inject = await applyInjection(plan, parsed, deps);
1446
+ const notes = [];
1447
+ if (!install.installed && install.note) notes.push(install.note);
1448
+ notes.push(...inject.notes);
1449
+ let sessionUrl;
1450
+ if (parsed.skipVerify) {
1451
+ notes.push("Verification skipped (--skip-verify).");
1452
+ } else {
1453
+ try {
1454
+ await deps.syntheticCheck(base, provisioned.apiKey, deps.fetchImpl);
1455
+ ui.out(
1456
+ `${color.green("\u2713")} Synthetic ingest check passed (key + endpoint OK).`
1457
+ );
1458
+ } catch (err) {
1459
+ ui.err(color.red(`Synthetic ingest check failed: ${errMessage(err)}`));
1460
+ notes.push(
1461
+ "Synthetic ingest check failed \u2014 verify your network/endpoint."
1462
+ );
1463
+ }
1464
+ const poll = await pollWithSigint(
1465
+ base,
1466
+ token,
1467
+ provisioned.projectId,
1468
+ deps,
1469
+ wizardStart
1470
+ );
1471
+ if (poll.outcome === "found") {
1472
+ sessionUrl = poll.sessionId ? `${base}/sessions/${encodeURIComponent(poll.sessionId)}` : `${base}/bugs`;
1473
+ ui.out(`${color.green("\u2713")} First real event received!`);
1474
+ ui.out(` Watch it live: ${color.cyan(sessionUrl)}`);
1475
+ if (canUseBrowser(parsed.noBrowser, deps.env)) {
1476
+ const open = deps.openBrowserFn ?? openBrowser;
1477
+ if (await open(sessionUrl)) {
1478
+ ui.out(color.dim(" Opened your dashboard in the browser."));
1479
+ }
1480
+ }
1481
+ } else if (poll.outcome === "cancelled") {
1482
+ notes.push(
1483
+ "Stopped waiting for the first event \u2014 load your app any time."
1484
+ );
1485
+ } else {
1486
+ notes.push("No event yet \u2014 start your app and check the dashboard.");
1487
+ }
1488
+ }
1489
+ printSummary(ui, base, provisioned, inject.filesTouched, notes, sessionUrl);
1490
+ return 0;
1491
+ }
1492
+ function stackLabel(c) {
1493
+ if (c.recipe == null) return "\u2014";
1494
+ if (c.recipe === "otlp") return c.detected.otlpStack ?? "otlp";
1495
+ return c.recipe;
1496
+ }
1497
+ function candidateHint(c) {
1498
+ const stack = stackLabel(c);
1499
+ if (c.flags.includes("no-recipe")) return "no supported framework";
1500
+ if (c.flags.includes("already-wired")) return `${stack} \xB7 already wired`;
1501
+ if (c.flags.includes("otlp"))
1502
+ return `${stack} \xB7 OTLP guidance, no code changes`;
1503
+ if (c.flags.includes("likely-library"))
1504
+ return `${stack} \xB7 library? probably not an app`;
1505
+ if (c.flags.includes("ambiguous")) return `${stack} \xB7 entry file unclear`;
1506
+ return stack;
1507
+ }
1508
+ function toMultiSelectItems(candidates) {
1509
+ return candidates.map((c) => ({
1510
+ label: c.relDir,
1511
+ hint: candidateHint(c),
1512
+ checked: c.defaultChecked,
1513
+ selectable: c.selectable
1514
+ }));
1515
+ }
1516
+ function resolveSelection(parsed, candidates) {
1517
+ if (parsed.only && parsed.only.length > 0) {
1518
+ const indices = [];
1519
+ for (const want of parsed.only) {
1520
+ const needle = want.toLowerCase();
1521
+ const i = candidates.findIndex(
1522
+ (c) => c.name.toLowerCase() === needle || c.relDir.toLowerCase() === needle
1523
+ );
1524
+ if (i < 0) {
1525
+ return {
1526
+ error: `--only ${want}: no such service. Found: ${candidates.map((c) => c.relDir).join(", ")}`
1527
+ };
1528
+ }
1529
+ if (!candidates[i].selectable) {
1530
+ return {
1531
+ error: `--only ${want}: no supported framework detected there \u2014 it can't be wired.`
1532
+ };
1533
+ }
1534
+ if (!indices.includes(i)) indices.push(i);
1535
+ }
1536
+ return { indices };
1537
+ }
1538
+ if (parsed.all) {
1539
+ return {
1540
+ indices: candidates.map((c, i) => c.selectable ? i : -1).filter((i) => i >= 0)
1541
+ };
1542
+ }
1543
+ return null;
1544
+ }
1545
+ async function runBatchWizard(parsed, deps, ctx) {
1546
+ const { ui } = deps;
1547
+ const { base, wizardStart } = ctx;
1548
+ const root = deps.cwd;
1549
+ const candidates = deps.discoverServices(root, ctx.root);
1550
+ const selectableCount = candidates.filter((c) => c.selectable).length;
1551
+ ui.out(
1552
+ `${color.green("\u2713")} Monorepo \u2014 found ${color.bold(String(candidates.length))} package(s), ${color.bold(String(selectableCount))} wireable.`
1553
+ );
1554
+ if (selectableCount === 0) {
1555
+ ui.err("");
1556
+ ui.err(color.red("Nothing here can be wired."));
1557
+ for (const c of candidates) {
1558
+ ui.err(color.dim(` \xB7 ${c.relDir} \u2014 ${candidateHint(c)}`));
1559
+ }
1560
+ ui.err(
1561
+ "Supported: Next.js, SvelteKit, Nuxt, Remix, Astro, Angular, Vite SPA, NestJS, Express, Hono, Fastify, a Node server, or a non-JS backend that speaks OpenTelemetry (Django, Flask, FastAPI, Go, Rails, .NET)."
1562
+ );
1563
+ return 1;
1564
+ }
1565
+ const preset = resolveSelection(parsed, candidates);
1566
+ if (preset && "error" in preset) {
1567
+ ui.err(color.red(preset.error));
1568
+ return 1;
1569
+ }
1570
+ let indices;
1571
+ if (preset) {
1572
+ indices = preset.indices;
1573
+ } else if (!deps.isTTY) {
1574
+ ui.err("");
1575
+ ui.err(
1576
+ color.red(
1577
+ "Monorepo root, but there's no TTY to pick services. Pass --only <service> (repeatable) or --all."
1578
+ )
1579
+ );
1580
+ for (const c of candidates) {
1581
+ if (c.selectable) ui.err(color.dim(` \xB7 ${c.relDir}`));
1582
+ }
1583
+ return 1;
1584
+ } else {
1585
+ ui.out("");
1586
+ indices = await deps.prompter.multiSelect(
1587
+ "Which services should Crumbtrail wire?",
1588
+ toMultiSelectItems(candidates)
1589
+ );
1590
+ }
1591
+ const selected = indices.map((i) => candidates[i]);
1592
+ if (selected.length === 0) {
1593
+ ui.out(color.yellow("Nothing selected \u2014 no changes made."));
1594
+ return 0;
1595
+ }
1596
+ let token;
1597
+ try {
1598
+ token = await deps.ensureToken({
1599
+ base,
1600
+ ui,
1601
+ noBrowser: parsed.noBrowser,
1602
+ fetchImpl: deps.fetchImpl,
1603
+ env: deps.env
1604
+ });
1605
+ } catch (err) {
1606
+ ui.err(color.red(`Login failed: ${errMessage(err)}`));
1607
+ return 1;
1608
+ }
1609
+ const defaultProjectName = inferProjectName(
1610
+ readPkgName(root),
1611
+ path4.basename(root)
1612
+ );
1613
+ let project;
1614
+ try {
1615
+ project = await deps.resolveProject({
1616
+ base,
1617
+ token,
1618
+ ui,
1619
+ prompter: deps.prompter,
1620
+ assumeYes: parsed.yes,
1621
+ projectId: parsed.project,
1622
+ defaultProjectName,
1623
+ fetchImpl: deps.fetchImpl
1624
+ });
1625
+ } catch (err) {
1626
+ if (err instanceof UpgradeRequiredError) {
1627
+ ui.err("");
1628
+ ui.err(color.yellow(err.message));
1629
+ if (err.upgradeUrl) ui.err(`Upgrade: ${color.cyan(err.upgradeUrl)}`);
1630
+ return 1;
1631
+ }
1632
+ ui.err(color.red(`Provisioning failed: ${errMessage(err)}`));
1633
+ return 1;
1634
+ }
1635
+ const serviceNames = uniqueServiceNames(
1636
+ selected.map((c) => ({
1637
+ name: inferServiceName(c.recipe, c.name),
1638
+ relDir: c.relDir
1639
+ }))
1640
+ );
1641
+ const outcomes = [];
1642
+ for (const [i, c] of selected.entries()) {
1643
+ const recipe = c.recipe;
1644
+ const name = serviceNames[i];
1645
+ ui.out("");
1646
+ ui.out(
1647
+ color.bold(`[${i + 1}/${selected.length}] ${c.relDir}`) + color.dim(` \u2014 ${stackLabel(c)}`)
1648
+ );
1649
+ if (c.flags.includes("already-wired")) {
1650
+ ui.out(`${color.green("\u2713")} Already wired \u2014 leaving it untouched.`);
1651
+ outcomes.push({
1652
+ name,
1653
+ relDir: c.relDir,
1654
+ recipe,
1655
+ status: "skipped-already-wired",
1656
+ filesTouched: [],
1657
+ notes: []
1658
+ });
1659
+ continue;
1660
+ }
1661
+ try {
1662
+ const svc = await deps.provisionService({
1663
+ base,
1664
+ token,
1665
+ projectId: project.id,
1666
+ recipe,
1667
+ stack: c.detected.otlpStack,
1668
+ serviceName: name,
1669
+ ui,
1670
+ fetchImpl: deps.fetchImpl
1671
+ });
1672
+ const plan = deps.buildPlan({
1673
+ cwd: c.dir,
1674
+ recipe,
1675
+ endpoint: base,
1676
+ apiKey: svc.apiKey,
1677
+ entryFile: c.detected.entryFile,
1678
+ nextVersion: c.detected.nextVersion,
1679
+ stack: c.detected.otlpStack ?? void 0,
1680
+ options: { force: parsed.yes }
1681
+ });
1682
+ const install = await deps.installSdk({
1683
+ cwd: c.dir,
1684
+ packageManager: c.detected.packageManager ?? ctx.root.packageManager,
1685
+ recipe,
1686
+ base,
1687
+ ui,
1688
+ fetchImpl: deps.fetchImpl
1689
+ });
1690
+ if (install.installed) {
1691
+ ui.out(`${color.green("\u2713")} Installed ${install.packages.join(", ")}.`);
1692
+ } else if (install.note) {
1693
+ ui.out(color.yellow(`! ${install.note}`));
1694
+ }
1695
+ const applied = await applyBatchInjection(plan, c, svc.serviceName, {
1696
+ parsed,
1697
+ deps,
1698
+ base
1699
+ });
1700
+ outcomes.push({
1701
+ name: svc.serviceName,
1702
+ relDir: c.relDir,
1703
+ recipe,
1704
+ status: applied.status,
1705
+ serviceId: svc.serviceId,
1706
+ apiKey: svc.apiKey,
1707
+ filesTouched: applied.filesTouched,
1708
+ notes: [
1709
+ ...!install.installed && install.note ? [install.note] : [],
1710
+ ...applied.notes
1711
+ ]
1712
+ });
1713
+ } catch (err) {
1714
+ const message = errMessage(err);
1715
+ ui.err(color.red(`\u2717 ${c.relDir}: ${message}`));
1716
+ outcomes.push({
1717
+ name,
1718
+ relDir: c.relDir,
1719
+ recipe,
1720
+ status: "failed",
1721
+ filesTouched: [],
1722
+ notes: [],
1723
+ error: message
1724
+ });
1725
+ }
1726
+ }
1727
+ const reporting = outcomes.filter(
1728
+ (o) => o.status === "wired" || o.status === "guidance"
1729
+ );
1730
+ const batchNotes = [];
1731
+ if (parsed.skipVerify) {
1732
+ batchNotes.push("Verification skipped (--skip-verify).");
1733
+ } else if (reporting.length > 0) {
1734
+ for (const o of reporting) {
1735
+ if (!o.apiKey) continue;
1736
+ try {
1737
+ await deps.syntheticCheck(base, o.apiKey, deps.fetchImpl);
1738
+ ui.out(`${color.green("\u2713")} ${o.name}: key + endpoint OK.`);
1739
+ } catch (err) {
1740
+ ui.err(
1741
+ color.red(`\u2717 ${o.name}: ingest check failed: ${errMessage(err)}`)
1742
+ );
1743
+ o.notes.push("Synthetic ingest check failed \u2014 check network/endpoint.");
1744
+ }
1745
+ }
1746
+ const byServiceId = new Map(
1747
+ reporting.filter((o) => o.serviceId).map((o) => [o.serviceId, o])
1748
+ );
1749
+ const poll = await pollServicesWithSigint(
1750
+ {
1751
+ base,
1752
+ token,
1753
+ projectId: project.id,
1754
+ ui,
1755
+ wizardStart,
1756
+ serviceIds: [...byServiceId.keys()],
1757
+ onFound: (serviceId, sessionId) => {
1758
+ const o = byServiceId.get(serviceId);
1759
+ if (!o) return;
1760
+ o.sessionUrl = `${base}/sessions/${encodeURIComponent(sessionId)}`;
1761
+ ui.out(`${color.green("\u2713")} ${o.name}: first event received.`);
1762
+ },
1763
+ fetchImpl: deps.fetchImpl
1764
+ },
1765
+ deps
1766
+ );
1767
+ if (poll.outcome !== "found") {
1768
+ for (const o of byServiceId.values()) {
1769
+ if (!o.sessionUrl) o.notes.push("No event yet \u2014 start this service.");
1770
+ }
1771
+ }
1772
+ }
1773
+ printBatchSummary(ui, base, root, project.name, outcomes, batchNotes);
1774
+ const attempted = outcomes.filter(
1775
+ (o) => o.status !== "skipped-already-wired"
1776
+ );
1777
+ const anyGood = outcomes.some(
1778
+ (o) => o.status === "wired" || o.status === "guidance"
1779
+ );
1780
+ return attempted.length > 0 && !anyGood ? 1 : 0;
1781
+ }
1782
+ async function applyBatchInjection(plan, candidate, serviceName, ctx) {
1783
+ const { parsed, deps, base } = ctx;
1784
+ if (plan.kind === "otlp-guidance") {
1785
+ const body = renderOtlpGuide({
1786
+ stack: candidate.detected.otlpStack ?? RECIPE_REGISTRY.otlp.stack,
1787
+ serviceName,
1788
+ endpoint: base,
1789
+ snippet: plan.snippet ?? "",
1790
+ agentPrompt: plan.agentPrompt ?? ""
1791
+ });
1792
+ const res = deps.executePlan(otlpGuidePlan(candidate.dir, body));
1793
+ deps.ui.out(
1794
+ `${color.green("\u2713")} Speaks OpenTelemetry \u2014 no SDK needed. Wrote ${color.cyan(res.written.join(", "))}.`
1795
+ );
1796
+ return {
1797
+ status: "guidance",
1798
+ filesTouched: res.written,
1799
+ // The summary already prefixes each note with the service name.
1800
+ notes: ["add the OTLP exporter from the guide file to start reporting."]
1801
+ };
1802
+ }
1803
+ const applied = await applyInjection(plan, parsed, deps);
1804
+ const status = plan.kind === "fallback-ai" ? "guidance" : applied.filesTouched.length > 0 ? "wired" : "skipped-already-wired";
1805
+ return { status, filesTouched: applied.filesTouched, notes: applied.notes };
1806
+ }
1807
+ function printBatchSummary(ui, base, root, projectName, outcomes, batchNotes = []) {
1808
+ const mark = {
1809
+ wired: color.green("\u2713"),
1810
+ guidance: color.green("\u2713"),
1811
+ "skipped-already-wired": color.dim("\xB7"),
1812
+ failed: color.red("\u2717")
1813
+ };
1814
+ const width = Math.max(...outcomes.map((o) => o.name.length), 4);
1815
+ const rel = (p) => path4.relative(root, p) || p;
1816
+ ui.out("");
1817
+ ui.out(color.bold(`Setup complete \u2014 project "${projectName}"`));
1818
+ ui.out("");
1819
+ for (const o of outcomes) {
1820
+ const detail = o.status === "failed" ? color.red(`failed: ${o.error}`) : o.status === "skipped-already-wired" ? color.dim("already wired \u2014 skipped") : o.sessionUrl ? color.cyan(o.sessionUrl) : o.filesTouched.length > 0 ? color.dim(o.filesTouched.map(rel).join(", ")) : "";
1821
+ ui.out(
1822
+ ` ${mark[o.status]} ${o.name.padEnd(width)} ${color.dim(o.relDir.padEnd(24))} ${detail}`
1823
+ );
1824
+ }
1825
+ const count = (s) => outcomes.filter((o) => o.status === s).length;
1826
+ const parts = [
1827
+ `${count("wired")} wired`,
1828
+ ...count("guidance") > 0 ? [`${count("guidance")} guidance`] : [],
1829
+ ...count("failed") > 0 ? [`${count("failed")} failed`] : [],
1830
+ ...count("skipped-already-wired") > 0 ? [`${count("skipped-already-wired")} skipped`] : []
1831
+ ];
1832
+ ui.out("");
1833
+ ui.out(` ${parts.join(" \xB7 ")}`);
1834
+ ui.out(` Dashboard: ${color.cyan(`${base}/bugs`)}`);
1835
+ const notes = [
1836
+ ...outcomes.flatMap((o) => o.notes.map((n) => `${o.name}: ${n}`)),
1837
+ ...batchNotes
1838
+ ];
1839
+ if (count("failed") > 0) {
1840
+ notes.push("Re-run `crumbtrail` to retry \u2014 wired services are skipped.");
1841
+ }
1842
+ if (notes.length > 0) {
1843
+ ui.out("");
1844
+ for (const n of notes) ui.out(color.dim(` note: ${n}`));
1845
+ }
1846
+ }
1847
+ async function applyInjection(plan, parsed, deps) {
1848
+ const { ui } = deps;
1849
+ const filesTouched = [];
1850
+ const notes = [];
1851
+ for (const w of plan.warnings) ui.out(color.dim(` \xB7 ${w}`));
1852
+ if (plan.kind === "skip-already-wired") {
1853
+ ui.out(`${color.green("\u2713")} Already wired \u2014 leaving your code untouched.`);
1854
+ return { filesTouched, notes };
1855
+ }
1856
+ if (plan.kind === "fallback-ai") {
1857
+ ui.out(color.yellow("Couldn't safely edit your code automatically."));
1858
+ if (plan.snippet) {
1859
+ ui.out(color.dim("Paste this into your entry file:"));
1860
+ ui.out(plan.snippet);
1861
+ }
1862
+ if (plan.agentPrompt) {
1863
+ ui.out(color.dim("\nOr hand this to your coding agent:"));
1864
+ ui.out(plan.agentPrompt);
1865
+ }
1866
+ notes.push("Injection fell back to a manual snippet / AI prompt.");
1867
+ return { filesTouched, notes };
1868
+ }
1869
+ if (plan.kind === "otlp-guidance") {
1870
+ ui.out(
1871
+ `${color.green("\u2713")} Detected a non-JS backend that already speaks OpenTelemetry \u2014 no SDK needed.`
1872
+ );
1873
+ ui.out(color.dim("Point your existing OTLP exporter at Crumbtrail:"));
1874
+ if (plan.snippet) ui.out(plan.snippet);
1875
+ if (plan.agentPrompt) {
1876
+ ui.out(color.dim("\nOr hand this to your coding agent:"));
1877
+ ui.out(plan.agentPrompt);
1878
+ }
1879
+ notes.push(
1880
+ "OTLP backend \u2014 printed OpenTelemetry setup guidance; no files were changed."
1881
+ );
1882
+ return { filesTouched, notes };
1883
+ }
1884
+ if (plan.kind === "needs-confirm-dirty") {
1885
+ const ok = parsed.yes ? true : await deps.prompter.confirm(
1886
+ `${plan.targetPath} has uncommitted changes \u2014 prepend into it anyway?`,
1887
+ false
1888
+ );
1889
+ if (!ok) {
1890
+ ui.out(
1891
+ color.yellow("Left your file untouched. Add this to the top yourself:")
1892
+ );
1893
+ if (plan.content) ui.out(plan.content);
1894
+ notes.push(`Skipped editing ${plan.targetPath} (dirty). Apply manually.`);
1895
+ return { filesTouched, notes };
1896
+ }
1897
+ const res2 = deps.executePlan(plan, void 0, { confirmDirty: true });
1898
+ filesTouched.push(...res2.written);
1899
+ ui.out(`${color.green("\u2713")} ${describeWrites(res2)}`);
1900
+ return { filesTouched, notes };
1901
+ }
1902
+ if (plan.targetPath) {
1903
+ ui.out(
1904
+ `${plan.kind === "create" ? "Creating" : "Editing"} ${color.cyan(plan.targetPath)}\u2026`
1905
+ );
1906
+ }
1907
+ if (plan.envAction) {
1908
+ ui.out(
1909
+ `Appending CRUMBTRAIL_KEY to ${color.cyan(plan.envAction.targetPath)}\u2026`
1910
+ );
1911
+ if (plan.envAction.gitignoreWarning) {
1912
+ notes.push(plan.envAction.gitignoreWarning);
1913
+ }
1914
+ }
1915
+ const res = deps.executePlan(plan);
1916
+ filesTouched.push(...res.written);
1917
+ ui.out(`${color.green("\u2713")} ${describeWrites(res)}`);
1918
+ return { filesTouched, notes };
1919
+ }
1920
+ function describeWrites(res) {
1921
+ if (res.written.length === 0) return res.message;
1922
+ return `Crumbtrail wired in \u2014 wrote ${res.written.join(", ")}.`;
1923
+ }
1924
+ async function pollWithSigint(base, token, projectId, deps, wizardStart) {
1925
+ const controller = new AbortController();
1926
+ const onSigint = () => controller.abort();
1927
+ process.once("SIGINT", onSigint);
1928
+ try {
1929
+ return await deps.pollForRealEvent({
1930
+ base,
1931
+ token,
1932
+ projectId,
1933
+ ui: deps.ui,
1934
+ wizardStart,
1935
+ signal: controller.signal,
1936
+ fetchImpl: deps.fetchImpl
1937
+ });
1938
+ } finally {
1939
+ process.removeListener("SIGINT", onSigint);
1940
+ }
1941
+ }
1942
+ async function pollServicesWithSigint(opts, deps) {
1943
+ const controller = new AbortController();
1944
+ const onSigint = () => controller.abort();
1945
+ process.once("SIGINT", onSigint);
1946
+ try {
1947
+ return await deps.pollForServices({ ...opts, signal: controller.signal });
1948
+ } finally {
1949
+ process.removeListener("SIGINT", onSigint);
1950
+ }
1951
+ }
1952
+ function printSummary(ui, base, p, filesTouched, notes, sessionUrl) {
1953
+ ui.out("");
1954
+ ui.out(color.bold("Setup complete"));
1955
+ ui.out(` Project: ${p.projectName}`);
1956
+ ui.out(` Service: ${p.serviceName}`);
1957
+ ui.out(
1958
+ ` Key: ${maskKey(p.apiKey)} ${color.dim("(already wired into your code)")}`
1959
+ );
1960
+ if (filesTouched.length > 0) {
1961
+ ui.out(` Files: ${filesTouched.join("\n ")}`);
1962
+ }
1963
+ if (sessionUrl) {
1964
+ ui.out(` Session: ${color.cyan(sessionUrl)}`);
1965
+ }
1966
+ ui.out(` Dashboard: ${color.cyan(`${base}/bugs`)}`);
1967
+ if (notes.length > 0) {
1968
+ ui.out("");
1969
+ for (const n of notes) ui.out(color.dim(` note: ${n}`));
1970
+ }
1971
+ }
1972
+ function errMessage(err) {
1973
+ return err instanceof Error ? err.message : String(err);
1974
+ }
1975
+ async function runLogin(parsed, deps) {
1976
+ const base = resolveEndpoint(parsed.endpoint, deps.env);
1977
+ try {
1978
+ await deps.ensureToken({
1979
+ base,
1980
+ ui: deps.ui,
1981
+ noBrowser: parsed.noBrowser,
1982
+ fetchImpl: deps.fetchImpl,
1983
+ env: deps.env
1984
+ });
1985
+ return 0;
1986
+ } catch (err) {
1987
+ deps.ui.err(color.red(`Login failed: ${errMessage(err)}`));
1988
+ return 1;
1989
+ }
1990
+ }
1991
+ function runLogout(deps) {
1992
+ const cleared = clearAuth(deps.env);
1993
+ deps.ui.out(cleared ? "Logged out." : "No saved login to clear.");
1994
+ return 0;
1995
+ }
1996
+ async function runCli(argv, deps = defaultDeps()) {
1997
+ const parsed = parseArgs(argv);
1998
+ if (parsed.command === "version") {
1999
+ deps.ui.out(readVersion());
2000
+ return 0;
2001
+ }
2002
+ if (parsed.command === "help") {
2003
+ deps.ui.out(USAGE);
2004
+ return 0;
2005
+ }
2006
+ if (parsed.unknown) {
2007
+ deps.ui.err(`Unknown argument: ${parsed.unknown}
2008
+ `);
2009
+ deps.ui.err(USAGE);
2010
+ return 1;
2011
+ }
2012
+ if (parsed.command === "login") return runLogin(parsed, deps);
2013
+ if (parsed.command === "logout") return runLogout(deps);
2014
+ if (!deps.isTTY && !(parsed.yes && parsed.project)) {
2015
+ deps.ui.err(
2016
+ color.red(
2017
+ "Non-interactive shell detected. Pass --yes and --project <id> to run without prompts."
2018
+ )
2019
+ );
2020
+ deps.ui.err("");
2021
+ deps.ui.err(USAGE);
2022
+ return 1;
2023
+ }
2024
+ return runWizard(parsed, deps);
2025
+ }
2026
+ function isCliEntrypoint(argv1) {
2027
+ if (!argv1) return false;
2028
+ return ["cli.ts", "cli.js", "cli.cjs", "cli.mjs", "crumbtrail"].includes(
2029
+ path4.basename(argv1)
2030
+ );
2031
+ }
2032
+ if (isCliEntrypoint(process.argv[1])) {
2033
+ runCli(process.argv).then((code) => process.exit(code)).catch((err) => {
2034
+ process.stderr.write(`crumbtrail: ${errMessage(err)}
2035
+ `);
2036
+ process.exit(1);
2037
+ });
2038
+ }
2039
+ export {
2040
+ defaultDeps,
2041
+ installSdk,
2042
+ isCliEntrypoint,
2043
+ parseArgs,
2044
+ resolveSelection,
2045
+ runBatchWizard,
2046
+ runCli,
2047
+ runWizard
2048
+ };