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/chunk-UFJ2BODB.js +7401 -0
- package/dist/cli.cjs +9412 -0
- package/dist/cli.d.cts +358 -0
- package/dist/cli.d.ts +358 -0
- package/dist/cli.js +2048 -0
- package/dist/executor-DKDrkhxM.d.cts +279 -0
- package/dist/executor-DKDrkhxM.d.ts +279 -0
- package/dist/index.cjs +7436 -0
- package/dist/index.d.cts +78 -0
- package/dist/index.d.ts +78 -0
- package/dist/index.js +64 -0
- package/package.json +55 -0
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
|
+
};
|