crumbtrail 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2333,6 +2333,9 @@ function resolveReactNativeEntry(cwd) {
2333
2333
  path.join("app", "_layout.tsx"),
2334
2334
  path.join("app", "_layout.jsx"),
2335
2335
  path.join("app", "_layout.js"),
2336
+ path.join("src", "app", "_layout.tsx"),
2337
+ path.join("src", "app", "_layout.jsx"),
2338
+ path.join("src", "app", "_layout.js"),
2336
2339
  "App.tsx",
2337
2340
  "App.jsx",
2338
2341
  "App.ts",
@@ -2812,6 +2815,20 @@ function nodeInitSnippet(endpoint) {
2812
2815
  " .catch(() => {});"
2813
2816
  ].join("\n");
2814
2817
  }
2818
+ function singleQuoted(value) {
2819
+ return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
2820
+ }
2821
+ function nestInitSnippet(endpoint) {
2822
+ return [
2823
+ "// Crumbtrail \u2014 auto-captures uncaught exceptions, unhandled rejections, and",
2824
+ "// console.error. Key is read from process.env.CRUMBTRAIL_KEY, which autoCapture",
2825
+ "// loads from your .env (written by the CLI). Express apps can also add",
2826
+ "// createCrumbtrailExpressMiddleware for per-request capture.",
2827
+ "import('crumbtrail-node')",
2828
+ ` .then(({ autoCapture }) => autoCapture({ endpoint: ${singleQuoted(endpoint)} }))`,
2829
+ " .catch(() => {});"
2830
+ ].join("\n");
2831
+ }
2815
2832
  function reactNativeInitSnippet(endpoint, apiKey) {
2816
2833
  return [
2817
2834
  'import { createReactNativeCrumbtrail } from "crumbtrail-react-native";',
@@ -2843,10 +2860,14 @@ import path2 from "path";
2843
2860
  function realGitStatus(cwd, target) {
2844
2861
  let out;
2845
2862
  try {
2863
+ const env = Object.fromEntries(
2864
+ Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))
2865
+ );
2846
2866
  out = execFileSync("git", ["status", "--porcelain", "--", target], {
2847
2867
  cwd,
2848
2868
  encoding: "utf8",
2849
- stdio: ["ignore", "pipe", "ignore"]
2869
+ stdio: ["ignore", "pipe", "ignore"],
2870
+ env
2850
2871
  });
2851
2872
  } catch {
2852
2873
  return { isRepo: false, tracked: false, dirty: false };
@@ -7027,7 +7048,7 @@ function planVite(input, io) {
7027
7048
  return prependWithPreflight(input, io, input.entryFile, block);
7028
7049
  }
7029
7050
  function planNode(input, io) {
7030
- const block = nodeInitSnippet(input.endpoint);
7051
+ const block = input.recipe === "nestjs" ? nestInitSnippet(input.endpoint) : nodeInitSnippet(input.endpoint);
7031
7052
  const { action, warning } = buildEnvAction(input.cwd, input.apiKey, io);
7032
7053
  const envWarnings = warning ? [warning] : [];
7033
7054
  if (!input.entryFile) {
@@ -7320,6 +7341,7 @@ export {
7320
7341
  clientInitSnippet,
7321
7342
  nuxtPluginSnippet,
7322
7343
  nodeInitSnippet,
7344
+ nestInitSnippet,
7323
7345
  reactNativeInitSnippet,
7324
7346
  tauriInitSnippet,
7325
7347
  envKeyLine,
package/dist/cli.cjs CHANGED
@@ -2182,6 +2182,7 @@ __export(cli_exports, {
2182
2182
  isCliEntrypoint: () => isCliEntrypoint,
2183
2183
  parseArgs: () => parseArgs,
2184
2184
  resolveSelection: () => resolveSelection,
2185
+ resolveWorkspaceDir: () => resolveWorkspaceDir,
2185
2186
  runBatchWizard: () => runBatchWizard,
2186
2187
  runCli: () => runCli,
2187
2188
  runWizard: () => runWizard
@@ -2357,6 +2358,9 @@ function resolveReactNativeEntry(cwd) {
2357
2358
  import_node_path.default.join("app", "_layout.tsx"),
2358
2359
  import_node_path.default.join("app", "_layout.jsx"),
2359
2360
  import_node_path.default.join("app", "_layout.js"),
2361
+ import_node_path.default.join("src", "app", "_layout.tsx"),
2362
+ import_node_path.default.join("src", "app", "_layout.jsx"),
2363
+ import_node_path.default.join("src", "app", "_layout.js"),
2360
2364
  "App.tsx",
2361
2365
  "App.jsx",
2362
2366
  "App.ts",
@@ -2836,6 +2840,20 @@ function nodeInitSnippet(endpoint) {
2836
2840
  " .catch(() => {});"
2837
2841
  ].join("\n");
2838
2842
  }
2843
+ function singleQuoted(value) {
2844
+ return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
2845
+ }
2846
+ function nestInitSnippet(endpoint) {
2847
+ return [
2848
+ "// Crumbtrail \u2014 auto-captures uncaught exceptions, unhandled rejections, and",
2849
+ "// console.error. Key is read from process.env.CRUMBTRAIL_KEY, which autoCapture",
2850
+ "// loads from your .env (written by the CLI). Express apps can also add",
2851
+ "// createCrumbtrailExpressMiddleware for per-request capture.",
2852
+ "import('crumbtrail-node')",
2853
+ ` .then(({ autoCapture }) => autoCapture({ endpoint: ${singleQuoted(endpoint)} }))`,
2854
+ " .catch(() => {});"
2855
+ ].join("\n");
2856
+ }
2839
2857
  function reactNativeInitSnippet(endpoint, apiKey) {
2840
2858
  return [
2841
2859
  'import { createReactNativeCrumbtrail } from "crumbtrail-react-native";',
@@ -2867,10 +2885,14 @@ var import_node_path2 = __toESM(require("path"), 1);
2867
2885
  function realGitStatus(cwd, target) {
2868
2886
  let out;
2869
2887
  try {
2888
+ const env = Object.fromEntries(
2889
+ Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))
2890
+ );
2870
2891
  out = (0, import_node_child_process.execFileSync)("git", ["status", "--porcelain", "--", target], {
2871
2892
  cwd,
2872
2893
  encoding: "utf8",
2873
- stdio: ["ignore", "pipe", "ignore"]
2894
+ stdio: ["ignore", "pipe", "ignore"],
2895
+ env
2874
2896
  });
2875
2897
  } catch {
2876
2898
  return { isRepo: false, tracked: false, dirty: false };
@@ -7051,7 +7073,7 @@ function planVite(input, io) {
7051
7073
  return prependWithPreflight(input, io, input.entryFile, block);
7052
7074
  }
7053
7075
  function planNode(input, io) {
7054
- const block = nodeInitSnippet(input.endpoint);
7076
+ const block = input.recipe === "nestjs" ? nestInitSnippet(input.endpoint) : nodeInitSnippet(input.endpoint);
7055
7077
  const { action, warning } = buildEnvAction(input.cwd, input.apiKey, io);
7056
7078
  const envWarnings = warning ? [warning] : [];
7057
7079
  if (!input.entryFile) {
@@ -7325,7 +7347,7 @@ var import_node_os = __toESM(require("os"), 1);
7325
7347
  var import_node_path5 = __toESM(require("path"), 1);
7326
7348
 
7327
7349
  // src/net.ts
7328
- var DEFAULT_ENDPOINT = "https://crumbtrail-cloud-production.up.railway.app";
7350
+ var DEFAULT_ENDPOINT = "https://app.crumbtrail.dev";
7329
7351
  function normalizeBase(base2) {
7330
7352
  return base2.replace(/\/+$/, "");
7331
7353
  }
@@ -7780,6 +7802,7 @@ async function validateToken(base2, token, fetchImpl) {
7780
7802
  throw err;
7781
7803
  }
7782
7804
  }
7805
+ var TOKEN_ENV_VAR = "CRUMBTRAIL_TOKEN";
7783
7806
  var sleep = (ms) => new Promise((r2) => setTimeout(r2, ms));
7784
7807
  async function loginBrowser(opts) {
7785
7808
  const { verifier, challenge } = pkcePair();
@@ -7876,6 +7899,17 @@ async function login(opts) {
7876
7899
  }
7877
7900
  async function ensureToken(opts) {
7878
7901
  const env = opts.env ?? process.env;
7902
+ const envToken = env[TOKEN_ENV_VAR]?.trim();
7903
+ if (envToken) {
7904
+ const state = await validateToken(opts.base, envToken, opts.fetchImpl);
7905
+ if (state === "valid") {
7906
+ opts.ui.out(color.dim(`Using ${TOKEN_ENV_VAR} from the environment.`));
7907
+ return envToken;
7908
+ }
7909
+ throw new Error(
7910
+ `${TOKEN_ENV_VAR} was set but ${opts.base} rejected it (401). Check the token value (create one in the dashboard), or point at the right deployment with --endpoint <url>.`
7911
+ );
7912
+ }
7879
7913
  const stored = loadAuth(env);
7880
7914
  if (stored && stored.token && stored.endpoint === opts.base) {
7881
7915
  const state = await validateToken(opts.base, stored.token, opts.fetchImpl);
@@ -7886,6 +7920,11 @@ async function ensureToken(opts) {
7886
7920
  clearAuth(env);
7887
7921
  opts.ui.out(color.dim("Saved login expired \u2014 signing in again."));
7888
7922
  }
7923
+ if (opts.allowInteractiveLogin === false) {
7924
+ throw new Error(
7925
+ `No Crumbtrail login available and this shell isn't interactive. Set ${TOKEN_ENV_VAR}=<your CLI token> (create one in the dashboard) to run in CI, or run the wizard in an interactive terminal. Add --endpoint <url> if you point at a self-hosted Crumbtrail.`
7926
+ );
7927
+ }
7889
7928
  const minted = await login(opts);
7890
7929
  saveAuth(
7891
7930
  { token: minted.token, expiresAt: minted.expiresAt, endpoint: opts.base },
@@ -8097,13 +8136,17 @@ var CLI_CHECK_PREFIX = "cli-check-";
8097
8136
  function syntheticSessionId() {
8098
8137
  return `${CLI_CHECK_PREFIX}${(0, import_node_crypto2.randomBytes)(8).toString("hex")}`;
8099
8138
  }
8100
- function firstRealSession(sessions, wizardStart) {
8101
- return sessions.find((s) => {
8102
- if (s.id.startsWith(CLI_CHECK_PREFIX)) return false;
8103
- if (wizardStart == null) return true;
8104
- const started = s.startedAt ? Date.parse(s.startedAt) : NaN;
8105
- return Number.isFinite(started) && started >= wizardStart;
8106
- });
8139
+ var POLL_SKEW_TOLERANCE_MS = 2 * 60 * 1e3;
8140
+ function isRealNewSession(s, wizardStart, guard) {
8141
+ if (s.id.startsWith(CLI_CHECK_PREFIX)) return false;
8142
+ if (guard?.baselineIds) return !guard.baselineIds.has(s.id);
8143
+ if (wizardStart == null) return true;
8144
+ const started = s.startedAt ? Date.parse(s.startedAt) : NaN;
8145
+ const tolerance = Math.max(0, guard?.skewToleranceMs ?? 0);
8146
+ return Number.isFinite(started) && started >= wizardStart - tolerance;
8147
+ }
8148
+ function firstRealSession(sessions, wizardStart, guard) {
8149
+ return sessions.find((s) => isRealNewSession(s, wizardStart, guard));
8107
8150
  }
8108
8151
  async function syntheticCheck(base2, apiKey, fetchImpl) {
8109
8152
  const sessionId = syntheticSessionId();
@@ -8165,6 +8208,26 @@ async function pollForRealEvent(opts) {
8165
8208
  color.dim("Waiting for the first real event\u2026 (Ctrl-C to skip)")
8166
8209
  );
8167
8210
  }
8211
+ if (opts.signal?.aborted) {
8212
+ opts.ui.status?.();
8213
+ return { outcome: "cancelled" };
8214
+ }
8215
+ let baselineIds;
8216
+ try {
8217
+ const existing = await fetchSessions(
8218
+ opts.base,
8219
+ opts.token,
8220
+ opts.projectId,
8221
+ opts.fetchImpl
8222
+ );
8223
+ baselineIds = new Set(existing.map((s) => s.id));
8224
+ } catch {
8225
+ baselineIds = void 0;
8226
+ }
8227
+ const guard = {
8228
+ baselineIds,
8229
+ skewToleranceMs: POLL_SKEW_TOLERANCE_MS
8230
+ };
8168
8231
  let state = initialIngestPollState();
8169
8232
  let sessionId;
8170
8233
  while (state.status === "waiting") {
@@ -8187,7 +8250,7 @@ async function pollForRealEvent(opts) {
8187
8250
  opts.projectId,
8188
8251
  opts.fetchImpl
8189
8252
  );
8190
- const real = firstRealSession(sessions, opts.wizardStart);
8253
+ const real = firstRealSession(sessions, opts.wizardStart, guard);
8191
8254
  found = real !== void 0;
8192
8255
  if (real) sessionId = real.id;
8193
8256
  } catch {
@@ -8198,15 +8261,11 @@ async function pollForRealEvent(opts) {
8198
8261
  opts.ui.status?.();
8199
8262
  return state.status === "found" ? { outcome: "found", sessionId } : { outcome: "timedout" };
8200
8263
  }
8201
- function realSessionsByService(sessions, wizardStart) {
8264
+ function realSessionsByService(sessions, wizardStart, guard) {
8202
8265
  const found = /* @__PURE__ */ new Map();
8203
8266
  for (const s of [...sessions].reverse()) {
8204
8267
  if (!s.serviceId || found.has(s.serviceId)) continue;
8205
- if (s.id.startsWith(CLI_CHECK_PREFIX)) continue;
8206
- if (wizardStart != null) {
8207
- const started = s.startedAt ? Date.parse(s.startedAt) : NaN;
8208
- if (!Number.isFinite(started) || started < wizardStart) continue;
8209
- }
8268
+ if (!isRealNewSession(s, wizardStart, guard)) continue;
8210
8269
  found.set(s.serviceId, s.id);
8211
8270
  }
8212
8271
  return found;
@@ -8482,6 +8541,9 @@ function parseArgs(argv) {
8482
8541
  case "--only":
8483
8542
  (parsed.only ??= []).push(args[++i]);
8484
8543
  break;
8544
+ case "--workspace":
8545
+ parsed.workspace = args[++i];
8546
+ break;
8485
8547
  default:
8486
8548
  if (a.startsWith("--project=")) {
8487
8549
  parsed.project = a.slice("--project=".length);
@@ -8489,6 +8551,8 @@ function parseArgs(argv) {
8489
8551
  parsed.endpoint = a.slice("--endpoint=".length);
8490
8552
  } else if (a.startsWith("--only=")) {
8491
8553
  (parsed.only ??= []).push(a.slice("--only=".length));
8554
+ } else if (a.startsWith("--workspace=")) {
8555
+ parsed.workspace = a.slice("--workspace=".length);
8492
8556
  } else if (!commandSet && (a === "login" || a === "logout")) {
8493
8557
  parsed.command = a;
8494
8558
  commandSet = true;
@@ -8516,6 +8580,8 @@ Options:
8516
8580
  --project <id> Attach to an existing project (skip creation)
8517
8581
  --only <name> Monorepo: wire only this service (repeatable)
8518
8582
  --all Monorepo: wire every service it can, no prompt
8583
+ --workspace <dir> Target one package dir (relative to cwd) instead of
8584
+ the repo root \u2014 wires just that package
8519
8585
  --no-browser Use the device-code login flow
8520
8586
  --skip-verify Don't wait for the first event
8521
8587
  --endpoint <url> Cloud endpoint (else $CRUMBTRAIL_BASE_URL, else default)
@@ -8639,6 +8705,34 @@ function readPkgName(dir) {
8639
8705
  return null;
8640
8706
  }
8641
8707
  }
8708
+ var defaultWorkspaceIO = {
8709
+ isDir: (p) => {
8710
+ try {
8711
+ return (0, import_node_fs6.statSync)(p).isDirectory();
8712
+ } catch {
8713
+ return false;
8714
+ }
8715
+ },
8716
+ isFile: (p) => {
8717
+ try {
8718
+ return (0, import_node_fs6.statSync)(p).isFile();
8719
+ } catch {
8720
+ return false;
8721
+ }
8722
+ }
8723
+ };
8724
+ function resolveWorkspaceDir(baseCwd, workspace, io = defaultWorkspaceIO) {
8725
+ const dir = import_node_path8.default.resolve(baseCwd, workspace);
8726
+ if (!io.isDir(dir)) {
8727
+ return { error: `--workspace ${workspace}: no such directory (${dir}).` };
8728
+ }
8729
+ if (!io.isFile(import_node_path8.default.join(dir, "package.json"))) {
8730
+ return {
8731
+ error: `--workspace ${workspace}: ${dir} has no package.json \u2014 point it at a package directory.`
8732
+ };
8733
+ }
8734
+ return { dir };
8735
+ }
8642
8736
  async function runWizard(parsed, deps) {
8643
8737
  const { ui } = deps;
8644
8738
  const base2 = resolveEndpoint(parsed.endpoint, deps.env);
@@ -8646,7 +8740,18 @@ async function runWizard(parsed, deps) {
8646
8740
  ui.out(color.bold("\nCrumbtrail setup"));
8647
8741
  ui.out(color.dim(`Endpoint: ${base2}
8648
8742
  `));
8649
- const cwd = deps.cwd;
8743
+ let cwd = deps.cwd;
8744
+ if (parsed.workspace) {
8745
+ const resolved = resolveWorkspaceDir(deps.cwd, parsed.workspace);
8746
+ if ("error" in resolved) {
8747
+ ui.err(color.red(resolved.error));
8748
+ return 1;
8749
+ }
8750
+ cwd = resolved.dir;
8751
+ ui.out(
8752
+ color.dim(`Targeting workspace: ${import_node_path8.default.relative(deps.cwd, cwd) || cwd}`)
8753
+ );
8754
+ }
8650
8755
  const result = deps.detect(cwd);
8651
8756
  if (result.isMonorepo) {
8652
8757
  return runBatchWizard(parsed, deps, { base: base2, wizardStart, root: result });
@@ -8680,7 +8785,8 @@ async function runWizard(parsed, deps) {
8680
8785
  ui,
8681
8786
  noBrowser: parsed.noBrowser,
8682
8787
  fetchImpl: deps.fetchImpl,
8683
- env: deps.env
8788
+ env: deps.env,
8789
+ allowInteractiveLogin: deps.isTTY
8684
8790
  });
8685
8791
  } catch (err) {
8686
8792
  ui.err(color.red(`Login failed: ${errMessage(err)}`));
@@ -8737,7 +8843,10 @@ async function runWizard(parsed, deps) {
8737
8843
  } else if (install.note) {
8738
8844
  ui.out(color.yellow(`! ${install.note}`));
8739
8845
  }
8740
- const inject = await applyInjection(plan, parsed, deps);
8846
+ const inject = await applyInjection(plan, parsed, deps, {
8847
+ installed: install.installed,
8848
+ packages: install.packages
8849
+ });
8741
8850
  const notes = [];
8742
8851
  if (!install.installed && install.note) notes.push(install.note);
8743
8852
  notes.push(...inject.notes);
@@ -8780,10 +8889,27 @@ async function runWizard(parsed, deps) {
8780
8889
  } else {
8781
8890
  notes.push("No event yet \u2014 start your app and check the dashboard.");
8782
8891
  }
8892
+ printEvidenceSourcesPointer(ui, base2);
8783
8893
  }
8784
8894
  printSummary(ui, base2, provisioned, inject.filesTouched, notes, sessionUrl);
8785
8895
  return 0;
8786
8896
  }
8897
+ function printEvidenceSourcesPointer(ui, base2) {
8898
+ ui.out("");
8899
+ ui.out(color.bold("Next: make each ticket's evidence more complete."));
8900
+ ui.out(
8901
+ color.dim(
8902
+ "Crumbtrail's SDK stands alone, but it can also fold in evidence from tools"
8903
+ )
8904
+ );
8905
+ ui.out(
8906
+ color.dim(
8907
+ "you already run \u2014 Sentry, CloudWatch, Splunk, Datadog, PostHog, Cloudflare \u2014"
8908
+ )
8909
+ );
8910
+ ui.out(color.dim("queried at incident time and added to each bug's bundle."));
8911
+ ui.out(` Evidence sources: ${color.cyan(`${base2}/settings`)}`);
8912
+ }
8787
8913
  function stackLabel2(c) {
8788
8914
  if (c.recipe == null) return "\u2014";
8789
8915
  if (c.recipe === "otlp") return c.detected.otlpStack ?? "otlp";
@@ -8895,7 +9021,8 @@ async function runBatchWizard(parsed, deps, ctx) {
8895
9021
  ui,
8896
9022
  noBrowser: parsed.noBrowser,
8897
9023
  fetchImpl: deps.fetchImpl,
8898
- env: deps.env
9024
+ env: deps.env,
9025
+ allowInteractiveLogin: deps.isTTY
8899
9026
  });
8900
9027
  } catch (err) {
8901
9028
  ui.err(color.red(`Login failed: ${errMessage(err)}`));
@@ -8990,7 +9117,11 @@ async function runBatchWizard(parsed, deps, ctx) {
8990
9117
  const applied = await applyBatchInjection(plan, c, svc.serviceName, {
8991
9118
  parsed,
8992
9119
  deps,
8993
- base: base2
9120
+ base: base2,
9121
+ sdkInstall: {
9122
+ installed: install.installed,
9123
+ packages: install.packages
9124
+ }
8994
9125
  });
8995
9126
  outcomes.push({
8996
9127
  name: svc.serviceName,
@@ -9065,6 +9196,9 @@ async function runBatchWizard(parsed, deps, ctx) {
9065
9196
  }
9066
9197
  }
9067
9198
  }
9199
+ if (!parsed.skipVerify && reporting.length > 0) {
9200
+ printEvidenceSourcesPointer(ui, base2);
9201
+ }
9068
9202
  printBatchSummary(ui, base2, root, project.name, outcomes, batchNotes);
9069
9203
  const attempted = outcomes.filter(
9070
9204
  (o) => o.status !== "skipped-already-wired"
@@ -9095,7 +9229,7 @@ async function applyBatchInjection(plan, candidate, serviceName, ctx) {
9095
9229
  notes: ["add the OTLP exporter from the guide file to start reporting."]
9096
9230
  };
9097
9231
  }
9098
- const applied = await applyInjection(plan, parsed, deps);
9232
+ const applied = await applyInjection(plan, parsed, deps, ctx.sdkInstall);
9099
9233
  const status = plan.kind === "fallback-ai" ? "guidance" : applied.filesTouched.length > 0 ? "wired" : "skipped-already-wired";
9100
9234
  return { status, filesTouched: applied.filesTouched, notes: applied.notes };
9101
9235
  }
@@ -9139,7 +9273,7 @@ function printBatchSummary(ui, base2, root, projectName, outcomes, batchNotes =
9139
9273
  for (const n of notes) ui.out(color.dim(` note: ${n}`));
9140
9274
  }
9141
9275
  }
9142
- async function applyInjection(plan, parsed, deps) {
9276
+ async function applyInjection(plan, parsed, deps, sdkInstall) {
9143
9277
  const { ui } = deps;
9144
9278
  const filesTouched = [];
9145
9279
  const notes = [];
@@ -9186,7 +9320,16 @@ async function applyInjection(plan, parsed, deps) {
9186
9320
  color.yellow("Left your file untouched. Add this to the top yourself:")
9187
9321
  );
9188
9322
  if (plan.content) ui.out(plan.content);
9189
- notes.push(`Skipped editing ${plan.targetPath} (dirty). Apply manually.`);
9323
+ notes.push(
9324
+ `Skipped editing ${plan.targetPath} (uncommitted changes) \u2014 paste the snippet above into it manually.`
9325
+ );
9326
+ if (sdkInstall?.installed && sdkInstall.packages.length > 0) {
9327
+ const pkgs = sdkInstall.packages.join(", ");
9328
+ const were = sdkInstall.packages.length > 1 ? "were" : "was";
9329
+ notes.push(
9330
+ `${pkgs} ${were} already installed, so your package.json is already updated \u2014 only the code import above is still manual.`
9331
+ );
9332
+ }
9190
9333
  return { filesTouched, notes };
9191
9334
  }
9192
9335
  const res2 = deps.executePlan(plan, void 0, { confirmDirty: true });
@@ -9275,7 +9418,8 @@ async function runLogin(parsed, deps) {
9275
9418
  ui: deps.ui,
9276
9419
  noBrowser: parsed.noBrowser,
9277
9420
  fetchImpl: deps.fetchImpl,
9278
- env: deps.env
9421
+ env: deps.env,
9422
+ allowInteractiveLogin: deps.isTTY
9279
9423
  });
9280
9424
  return 0;
9281
9425
  } catch (err) {
@@ -9338,6 +9482,7 @@ if (isCliEntrypoint(process.argv[1])) {
9338
9482
  isCliEntrypoint,
9339
9483
  parseArgs,
9340
9484
  resolveSelection,
9485
+ resolveWorkspaceDir,
9341
9486
  runBatchWizard,
9342
9487
  runCli,
9343
9488
  runWizard
package/dist/cli.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { R as Recipe, D as DetectResult, P as PackageManager, b as buildPlan, e as executePlan } from './executor-DKDrkhxM.cjs';
2
+ import { R as Recipe, D as DetectResult, P as PackageManager, b as buildPlan, e as executePlan } from './executor-BKiOvIvD.cjs';
3
3
  import { Stack } from 'crumbtrail-design-system';
4
4
 
5
5
  /** Output sink — swappable in tests to capture lines instead of writing stdout. */
@@ -56,6 +56,14 @@ interface LoginOptions {
56
56
  pollIntervalMs?: number;
57
57
  /** Browser hand-off deadline in ms (default 5 min); overridable in tests. */
58
58
  browserDeadlineMs?: number;
59
+ /**
60
+ * False in a non-TTY shell: refuse to START an interactive login (browser
61
+ * hand-off / device code) that would block on input nobody can give, and throw
62
+ * an actionable error instead of hanging. Undefined/true keeps the interactive
63
+ * flow (default) so a normal terminal is unaffected. A valid CRUMBTRAIL_TOKEN or
64
+ * a cached token is honored regardless of this flag.
65
+ */
66
+ allowInteractiveLogin?: boolean;
59
67
  }
60
68
  /**
61
69
  * Resolve a usable CLI token for `base`: reuse a stored token (validated by a
@@ -260,6 +268,12 @@ interface ParsedArgs {
260
268
  only?: string[];
261
269
  /** Monorepo root only: select every wireable service, no prompt. */
262
270
  all: boolean;
271
+ /**
272
+ * Target a specific package directory instead of the detected repo root. In a
273
+ * monorepo this bypasses the batch scan and wires exactly this one package
274
+ * (resolved relative to cwd; must exist and hold a package.json).
275
+ */
276
+ workspace?: string;
263
277
  /** Non-flag/subcommand leftover — an unknown token triggers usage help. */
264
278
  unknown?: string;
265
279
  }
@@ -315,6 +329,24 @@ interface WizardDeps {
315
329
  fetchImpl?: typeof fetch;
316
330
  }
317
331
  declare function defaultDeps(): WizardDeps;
332
+ /** Filesystem probes for --workspace validation; injectable so the resolver is
333
+ * unit-testable without touching real directories. */
334
+ interface WorkspaceIO {
335
+ isDir: (p: string) => boolean;
336
+ isFile: (p: string) => boolean;
337
+ }
338
+ /**
339
+ * Resolve `--workspace <dir>` to an absolute package directory. The dir is taken
340
+ * relative to the wizard's cwd, must exist, and must hold a package.json (the
341
+ * workspace-package manifest) — otherwise detect() would run against nothing
342
+ * useful, so we refuse with a concrete message rather than proceed. Pure aside
343
+ * from the injected probes.
344
+ */
345
+ declare function resolveWorkspaceDir(baseCwd: string, workspace: string, io?: WorkspaceIO): {
346
+ dir: string;
347
+ } | {
348
+ error: string;
349
+ };
318
350
  declare function runWizard(parsed: ParsedArgs, deps: WizardDeps): Promise<number>;
319
351
  type ServiceStatus = "wired" | "guidance" | "skipped-already-wired" | "failed";
320
352
  interface ServiceOutcome {
@@ -355,4 +387,4 @@ declare function runBatchWizard(parsed: ParsedArgs, deps: WizardDeps, ctx: Batch
355
387
  declare function runCli(argv: string[], deps?: WizardDeps): Promise<number>;
356
388
  declare function isCliEntrypoint(argv1: string | undefined): boolean;
357
389
 
358
- export { type Command, type InstallSdkInput, type InstallSdkResult, type ParsedArgs, type ServiceOutcome, type ServiceStatus, type WizardDeps, defaultDeps, installSdk, isCliEntrypoint, parseArgs, resolveSelection, runBatchWizard, runCli, runWizard };
390
+ export { type Command, type InstallSdkInput, type InstallSdkResult, type ParsedArgs, type ServiceOutcome, type ServiceStatus, type WizardDeps, type WorkspaceIO, defaultDeps, installSdk, isCliEntrypoint, parseArgs, resolveSelection, resolveWorkspaceDir, runBatchWizard, runCli, runWizard };
package/dist/cli.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { R as Recipe, D as DetectResult, P as PackageManager, b as buildPlan, e as executePlan } from './executor-DKDrkhxM.js';
2
+ import { R as Recipe, D as DetectResult, P as PackageManager, b as buildPlan, e as executePlan } from './executor-BKiOvIvD.js';
3
3
  import { Stack } from 'crumbtrail-design-system';
4
4
 
5
5
  /** Output sink — swappable in tests to capture lines instead of writing stdout. */
@@ -56,6 +56,14 @@ interface LoginOptions {
56
56
  pollIntervalMs?: number;
57
57
  /** Browser hand-off deadline in ms (default 5 min); overridable in tests. */
58
58
  browserDeadlineMs?: number;
59
+ /**
60
+ * False in a non-TTY shell: refuse to START an interactive login (browser
61
+ * hand-off / device code) that would block on input nobody can give, and throw
62
+ * an actionable error instead of hanging. Undefined/true keeps the interactive
63
+ * flow (default) so a normal terminal is unaffected. A valid CRUMBTRAIL_TOKEN or
64
+ * a cached token is honored regardless of this flag.
65
+ */
66
+ allowInteractiveLogin?: boolean;
59
67
  }
60
68
  /**
61
69
  * Resolve a usable CLI token for `base`: reuse a stored token (validated by a
@@ -260,6 +268,12 @@ interface ParsedArgs {
260
268
  only?: string[];
261
269
  /** Monorepo root only: select every wireable service, no prompt. */
262
270
  all: boolean;
271
+ /**
272
+ * Target a specific package directory instead of the detected repo root. In a
273
+ * monorepo this bypasses the batch scan and wires exactly this one package
274
+ * (resolved relative to cwd; must exist and hold a package.json).
275
+ */
276
+ workspace?: string;
263
277
  /** Non-flag/subcommand leftover — an unknown token triggers usage help. */
264
278
  unknown?: string;
265
279
  }
@@ -315,6 +329,24 @@ interface WizardDeps {
315
329
  fetchImpl?: typeof fetch;
316
330
  }
317
331
  declare function defaultDeps(): WizardDeps;
332
+ /** Filesystem probes for --workspace validation; injectable so the resolver is
333
+ * unit-testable without touching real directories. */
334
+ interface WorkspaceIO {
335
+ isDir: (p: string) => boolean;
336
+ isFile: (p: string) => boolean;
337
+ }
338
+ /**
339
+ * Resolve `--workspace <dir>` to an absolute package directory. The dir is taken
340
+ * relative to the wizard's cwd, must exist, and must hold a package.json (the
341
+ * workspace-package manifest) — otherwise detect() would run against nothing
342
+ * useful, so we refuse with a concrete message rather than proceed. Pure aside
343
+ * from the injected probes.
344
+ */
345
+ declare function resolveWorkspaceDir(baseCwd: string, workspace: string, io?: WorkspaceIO): {
346
+ dir: string;
347
+ } | {
348
+ error: string;
349
+ };
318
350
  declare function runWizard(parsed: ParsedArgs, deps: WizardDeps): Promise<number>;
319
351
  type ServiceStatus = "wired" | "guidance" | "skipped-already-wired" | "failed";
320
352
  interface ServiceOutcome {
@@ -355,4 +387,4 @@ declare function runBatchWizard(parsed: ParsedArgs, deps: WizardDeps, ctx: Batch
355
387
  declare function runCli(argv: string[], deps?: WizardDeps): Promise<number>;
356
388
  declare function isCliEntrypoint(argv1: string | undefined): boolean;
357
389
 
358
- export { type Command, type InstallSdkInput, type InstallSdkResult, type ParsedArgs, type ServiceOutcome, type ServiceStatus, type WizardDeps, defaultDeps, installSdk, isCliEntrypoint, parseArgs, resolveSelection, runBatchWizard, runCli, runWizard };
390
+ export { type Command, type InstallSdkInput, type InstallSdkResult, type ParsedArgs, type ServiceOutcome, type ServiceStatus, type WizardDeps, type WorkspaceIO, defaultDeps, installSdk, isCliEntrypoint, parseArgs, resolveSelection, resolveWorkspaceDir, runBatchWizard, runCli, runWizard };
package/dist/cli.js CHANGED
@@ -7,10 +7,10 @@ import {
7
7
  detect,
8
8
  executePlan,
9
9
  projectAlreadyWired
10
- } from "./chunk-UFJ2BODB.js";
10
+ } from "./chunk-HAP4DPWC.js";
11
11
 
12
12
  // src/cli.ts
13
- import { readFileSync as readFileSync2 } from "fs";
13
+ import { readFileSync as readFileSync2, statSync } from "fs";
14
14
  import path4 from "path";
15
15
  import { spawnSync } from "child_process";
16
16
 
@@ -30,7 +30,7 @@ import os from "os";
30
30
  import path from "path";
31
31
 
32
32
  // src/net.ts
33
- var DEFAULT_ENDPOINT = "https://crumbtrail-cloud-production.up.railway.app";
33
+ var DEFAULT_ENDPOINT = "https://app.crumbtrail.dev";
34
34
  function normalizeBase(base) {
35
35
  return base.replace(/\/+$/, "");
36
36
  }
@@ -485,6 +485,7 @@ async function validateToken(base, token, fetchImpl) {
485
485
  throw err;
486
486
  }
487
487
  }
488
+ var TOKEN_ENV_VAR = "CRUMBTRAIL_TOKEN";
488
489
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
489
490
  async function loginBrowser(opts) {
490
491
  const { verifier, challenge } = pkcePair();
@@ -581,6 +582,17 @@ async function login(opts) {
581
582
  }
582
583
  async function ensureToken(opts) {
583
584
  const env = opts.env ?? process.env;
585
+ const envToken = env[TOKEN_ENV_VAR]?.trim();
586
+ if (envToken) {
587
+ const state = await validateToken(opts.base, envToken, opts.fetchImpl);
588
+ if (state === "valid") {
589
+ opts.ui.out(color.dim(`Using ${TOKEN_ENV_VAR} from the environment.`));
590
+ return envToken;
591
+ }
592
+ throw new Error(
593
+ `${TOKEN_ENV_VAR} was set but ${opts.base} rejected it (401). Check the token value (create one in the dashboard), or point at the right deployment with --endpoint <url>.`
594
+ );
595
+ }
584
596
  const stored = loadAuth(env);
585
597
  if (stored && stored.token && stored.endpoint === opts.base) {
586
598
  const state = await validateToken(opts.base, stored.token, opts.fetchImpl);
@@ -591,6 +603,11 @@ async function ensureToken(opts) {
591
603
  clearAuth(env);
592
604
  opts.ui.out(color.dim("Saved login expired \u2014 signing in again."));
593
605
  }
606
+ if (opts.allowInteractiveLogin === false) {
607
+ throw new Error(
608
+ `No Crumbtrail login available and this shell isn't interactive. Set ${TOKEN_ENV_VAR}=<your CLI token> (create one in the dashboard) to run in CI, or run the wizard in an interactive terminal. Add --endpoint <url> if you point at a self-hosted Crumbtrail.`
609
+ );
610
+ }
594
611
  const minted = await login(opts);
595
612
  saveAuth(
596
613
  { token: minted.token, expiresAt: minted.expiresAt, endpoint: opts.base },
@@ -802,13 +819,17 @@ var CLI_CHECK_PREFIX = "cli-check-";
802
819
  function syntheticSessionId() {
803
820
  return `${CLI_CHECK_PREFIX}${randomBytes2(8).toString("hex")}`;
804
821
  }
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
- });
822
+ var POLL_SKEW_TOLERANCE_MS = 2 * 60 * 1e3;
823
+ function isRealNewSession(s, wizardStart, guard) {
824
+ if (s.id.startsWith(CLI_CHECK_PREFIX)) return false;
825
+ if (guard?.baselineIds) return !guard.baselineIds.has(s.id);
826
+ if (wizardStart == null) return true;
827
+ const started = s.startedAt ? Date.parse(s.startedAt) : NaN;
828
+ const tolerance = Math.max(0, guard?.skewToleranceMs ?? 0);
829
+ return Number.isFinite(started) && started >= wizardStart - tolerance;
830
+ }
831
+ function firstRealSession(sessions, wizardStart, guard) {
832
+ return sessions.find((s) => isRealNewSession(s, wizardStart, guard));
812
833
  }
813
834
  async function syntheticCheck(base, apiKey, fetchImpl) {
814
835
  const sessionId = syntheticSessionId();
@@ -870,6 +891,26 @@ async function pollForRealEvent(opts) {
870
891
  color.dim("Waiting for the first real event\u2026 (Ctrl-C to skip)")
871
892
  );
872
893
  }
894
+ if (opts.signal?.aborted) {
895
+ opts.ui.status?.();
896
+ return { outcome: "cancelled" };
897
+ }
898
+ let baselineIds;
899
+ try {
900
+ const existing = await fetchSessions(
901
+ opts.base,
902
+ opts.token,
903
+ opts.projectId,
904
+ opts.fetchImpl
905
+ );
906
+ baselineIds = new Set(existing.map((s) => s.id));
907
+ } catch {
908
+ baselineIds = void 0;
909
+ }
910
+ const guard = {
911
+ baselineIds,
912
+ skewToleranceMs: POLL_SKEW_TOLERANCE_MS
913
+ };
873
914
  let state = initialIngestPollState();
874
915
  let sessionId;
875
916
  while (state.status === "waiting") {
@@ -892,7 +933,7 @@ async function pollForRealEvent(opts) {
892
933
  opts.projectId,
893
934
  opts.fetchImpl
894
935
  );
895
- const real = firstRealSession(sessions, opts.wizardStart);
936
+ const real = firstRealSession(sessions, opts.wizardStart, guard);
896
937
  found = real !== void 0;
897
938
  if (real) sessionId = real.id;
898
939
  } catch {
@@ -903,15 +944,11 @@ async function pollForRealEvent(opts) {
903
944
  opts.ui.status?.();
904
945
  return state.status === "found" ? { outcome: "found", sessionId } : { outcome: "timedout" };
905
946
  }
906
- function realSessionsByService(sessions, wizardStart) {
947
+ function realSessionsByService(sessions, wizardStart, guard) {
907
948
  const found = /* @__PURE__ */ new Map();
908
949
  for (const s of [...sessions].reverse()) {
909
950
  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
- }
951
+ if (!isRealNewSession(s, wizardStart, guard)) continue;
915
952
  found.set(s.serviceId, s.id);
916
953
  }
917
954
  return found;
@@ -1187,6 +1224,9 @@ function parseArgs(argv) {
1187
1224
  case "--only":
1188
1225
  (parsed.only ??= []).push(args[++i]);
1189
1226
  break;
1227
+ case "--workspace":
1228
+ parsed.workspace = args[++i];
1229
+ break;
1190
1230
  default:
1191
1231
  if (a.startsWith("--project=")) {
1192
1232
  parsed.project = a.slice("--project=".length);
@@ -1194,6 +1234,8 @@ function parseArgs(argv) {
1194
1234
  parsed.endpoint = a.slice("--endpoint=".length);
1195
1235
  } else if (a.startsWith("--only=")) {
1196
1236
  (parsed.only ??= []).push(a.slice("--only=".length));
1237
+ } else if (a.startsWith("--workspace=")) {
1238
+ parsed.workspace = a.slice("--workspace=".length);
1197
1239
  } else if (!commandSet && (a === "login" || a === "logout")) {
1198
1240
  parsed.command = a;
1199
1241
  commandSet = true;
@@ -1221,6 +1263,8 @@ Options:
1221
1263
  --project <id> Attach to an existing project (skip creation)
1222
1264
  --only <name> Monorepo: wire only this service (repeatable)
1223
1265
  --all Monorepo: wire every service it can, no prompt
1266
+ --workspace <dir> Target one package dir (relative to cwd) instead of
1267
+ the repo root \u2014 wires just that package
1224
1268
  --no-browser Use the device-code login flow
1225
1269
  --skip-verify Don't wait for the first event
1226
1270
  --endpoint <url> Cloud endpoint (else $CRUMBTRAIL_BASE_URL, else default)
@@ -1344,6 +1388,34 @@ function readPkgName(dir) {
1344
1388
  return null;
1345
1389
  }
1346
1390
  }
1391
+ var defaultWorkspaceIO = {
1392
+ isDir: (p) => {
1393
+ try {
1394
+ return statSync(p).isDirectory();
1395
+ } catch {
1396
+ return false;
1397
+ }
1398
+ },
1399
+ isFile: (p) => {
1400
+ try {
1401
+ return statSync(p).isFile();
1402
+ } catch {
1403
+ return false;
1404
+ }
1405
+ }
1406
+ };
1407
+ function resolveWorkspaceDir(baseCwd, workspace, io = defaultWorkspaceIO) {
1408
+ const dir = path4.resolve(baseCwd, workspace);
1409
+ if (!io.isDir(dir)) {
1410
+ return { error: `--workspace ${workspace}: no such directory (${dir}).` };
1411
+ }
1412
+ if (!io.isFile(path4.join(dir, "package.json"))) {
1413
+ return {
1414
+ error: `--workspace ${workspace}: ${dir} has no package.json \u2014 point it at a package directory.`
1415
+ };
1416
+ }
1417
+ return { dir };
1418
+ }
1347
1419
  async function runWizard(parsed, deps) {
1348
1420
  const { ui } = deps;
1349
1421
  const base = resolveEndpoint(parsed.endpoint, deps.env);
@@ -1351,7 +1423,18 @@ async function runWizard(parsed, deps) {
1351
1423
  ui.out(color.bold("\nCrumbtrail setup"));
1352
1424
  ui.out(color.dim(`Endpoint: ${base}
1353
1425
  `));
1354
- const cwd = deps.cwd;
1426
+ let cwd = deps.cwd;
1427
+ if (parsed.workspace) {
1428
+ const resolved = resolveWorkspaceDir(deps.cwd, parsed.workspace);
1429
+ if ("error" in resolved) {
1430
+ ui.err(color.red(resolved.error));
1431
+ return 1;
1432
+ }
1433
+ cwd = resolved.dir;
1434
+ ui.out(
1435
+ color.dim(`Targeting workspace: ${path4.relative(deps.cwd, cwd) || cwd}`)
1436
+ );
1437
+ }
1355
1438
  const result = deps.detect(cwd);
1356
1439
  if (result.isMonorepo) {
1357
1440
  return runBatchWizard(parsed, deps, { base, wizardStart, root: result });
@@ -1385,7 +1468,8 @@ async function runWizard(parsed, deps) {
1385
1468
  ui,
1386
1469
  noBrowser: parsed.noBrowser,
1387
1470
  fetchImpl: deps.fetchImpl,
1388
- env: deps.env
1471
+ env: deps.env,
1472
+ allowInteractiveLogin: deps.isTTY
1389
1473
  });
1390
1474
  } catch (err) {
1391
1475
  ui.err(color.red(`Login failed: ${errMessage(err)}`));
@@ -1442,7 +1526,10 @@ async function runWizard(parsed, deps) {
1442
1526
  } else if (install.note) {
1443
1527
  ui.out(color.yellow(`! ${install.note}`));
1444
1528
  }
1445
- const inject = await applyInjection(plan, parsed, deps);
1529
+ const inject = await applyInjection(plan, parsed, deps, {
1530
+ installed: install.installed,
1531
+ packages: install.packages
1532
+ });
1446
1533
  const notes = [];
1447
1534
  if (!install.installed && install.note) notes.push(install.note);
1448
1535
  notes.push(...inject.notes);
@@ -1485,10 +1572,27 @@ async function runWizard(parsed, deps) {
1485
1572
  } else {
1486
1573
  notes.push("No event yet \u2014 start your app and check the dashboard.");
1487
1574
  }
1575
+ printEvidenceSourcesPointer(ui, base);
1488
1576
  }
1489
1577
  printSummary(ui, base, provisioned, inject.filesTouched, notes, sessionUrl);
1490
1578
  return 0;
1491
1579
  }
1580
+ function printEvidenceSourcesPointer(ui, base) {
1581
+ ui.out("");
1582
+ ui.out(color.bold("Next: make each ticket's evidence more complete."));
1583
+ ui.out(
1584
+ color.dim(
1585
+ "Crumbtrail's SDK stands alone, but it can also fold in evidence from tools"
1586
+ )
1587
+ );
1588
+ ui.out(
1589
+ color.dim(
1590
+ "you already run \u2014 Sentry, CloudWatch, Splunk, Datadog, PostHog, Cloudflare \u2014"
1591
+ )
1592
+ );
1593
+ ui.out(color.dim("queried at incident time and added to each bug's bundle."));
1594
+ ui.out(` Evidence sources: ${color.cyan(`${base}/settings`)}`);
1595
+ }
1492
1596
  function stackLabel(c) {
1493
1597
  if (c.recipe == null) return "\u2014";
1494
1598
  if (c.recipe === "otlp") return c.detected.otlpStack ?? "otlp";
@@ -1600,7 +1704,8 @@ async function runBatchWizard(parsed, deps, ctx) {
1600
1704
  ui,
1601
1705
  noBrowser: parsed.noBrowser,
1602
1706
  fetchImpl: deps.fetchImpl,
1603
- env: deps.env
1707
+ env: deps.env,
1708
+ allowInteractiveLogin: deps.isTTY
1604
1709
  });
1605
1710
  } catch (err) {
1606
1711
  ui.err(color.red(`Login failed: ${errMessage(err)}`));
@@ -1695,7 +1800,11 @@ async function runBatchWizard(parsed, deps, ctx) {
1695
1800
  const applied = await applyBatchInjection(plan, c, svc.serviceName, {
1696
1801
  parsed,
1697
1802
  deps,
1698
- base
1803
+ base,
1804
+ sdkInstall: {
1805
+ installed: install.installed,
1806
+ packages: install.packages
1807
+ }
1699
1808
  });
1700
1809
  outcomes.push({
1701
1810
  name: svc.serviceName,
@@ -1770,6 +1879,9 @@ async function runBatchWizard(parsed, deps, ctx) {
1770
1879
  }
1771
1880
  }
1772
1881
  }
1882
+ if (!parsed.skipVerify && reporting.length > 0) {
1883
+ printEvidenceSourcesPointer(ui, base);
1884
+ }
1773
1885
  printBatchSummary(ui, base, root, project.name, outcomes, batchNotes);
1774
1886
  const attempted = outcomes.filter(
1775
1887
  (o) => o.status !== "skipped-already-wired"
@@ -1800,7 +1912,7 @@ async function applyBatchInjection(plan, candidate, serviceName, ctx) {
1800
1912
  notes: ["add the OTLP exporter from the guide file to start reporting."]
1801
1913
  };
1802
1914
  }
1803
- const applied = await applyInjection(plan, parsed, deps);
1915
+ const applied = await applyInjection(plan, parsed, deps, ctx.sdkInstall);
1804
1916
  const status = plan.kind === "fallback-ai" ? "guidance" : applied.filesTouched.length > 0 ? "wired" : "skipped-already-wired";
1805
1917
  return { status, filesTouched: applied.filesTouched, notes: applied.notes };
1806
1918
  }
@@ -1844,7 +1956,7 @@ function printBatchSummary(ui, base, root, projectName, outcomes, batchNotes = [
1844
1956
  for (const n of notes) ui.out(color.dim(` note: ${n}`));
1845
1957
  }
1846
1958
  }
1847
- async function applyInjection(plan, parsed, deps) {
1959
+ async function applyInjection(plan, parsed, deps, sdkInstall) {
1848
1960
  const { ui } = deps;
1849
1961
  const filesTouched = [];
1850
1962
  const notes = [];
@@ -1891,7 +2003,16 @@ async function applyInjection(plan, parsed, deps) {
1891
2003
  color.yellow("Left your file untouched. Add this to the top yourself:")
1892
2004
  );
1893
2005
  if (plan.content) ui.out(plan.content);
1894
- notes.push(`Skipped editing ${plan.targetPath} (dirty). Apply manually.`);
2006
+ notes.push(
2007
+ `Skipped editing ${plan.targetPath} (uncommitted changes) \u2014 paste the snippet above into it manually.`
2008
+ );
2009
+ if (sdkInstall?.installed && sdkInstall.packages.length > 0) {
2010
+ const pkgs = sdkInstall.packages.join(", ");
2011
+ const were = sdkInstall.packages.length > 1 ? "were" : "was";
2012
+ notes.push(
2013
+ `${pkgs} ${were} already installed, so your package.json is already updated \u2014 only the code import above is still manual.`
2014
+ );
2015
+ }
1895
2016
  return { filesTouched, notes };
1896
2017
  }
1897
2018
  const res2 = deps.executePlan(plan, void 0, { confirmDirty: true });
@@ -1980,7 +2101,8 @@ async function runLogin(parsed, deps) {
1980
2101
  ui: deps.ui,
1981
2102
  noBrowser: parsed.noBrowser,
1982
2103
  fetchImpl: deps.fetchImpl,
1983
- env: deps.env
2104
+ env: deps.env,
2105
+ allowInteractiveLogin: deps.isTTY
1984
2106
  });
1985
2107
  return 0;
1986
2108
  } catch (err) {
@@ -2042,6 +2164,7 @@ export {
2042
2164
  isCliEntrypoint,
2043
2165
  parseArgs,
2044
2166
  resolveSelection,
2167
+ resolveWorkspaceDir,
2045
2168
  runBatchWizard,
2046
2169
  runCli,
2047
2170
  runWizard
@@ -69,10 +69,15 @@ declare function resolveViteEntry(cwd: string): string | null;
69
69
  /**
70
70
  * Resolve the React Native / Expo injection entry. Prefers, in order:
71
71
  * 1. `app/_layout.{tsx,jsx,js}` — the expo-router root layout,
72
- * 2. `App.{tsx,jsx,ts,js}` — the classic Expo / bare-RN root component,
73
- * 3. `index.{js,ts}` the bare-RN `AppRegistry` entry.
74
- * First existing file wins. Returns null when none exist (→ ambiguous), so the
75
- * resolver never points at an `expo/AppEntry` path inside node_modules.
72
+ * 2. `src/app/_layout.{tsx,jsx,js}` — the same root layout under a `src/` dir,
73
+ * which is where create-expo-app's current DEFAULT template puts it,
74
+ * 3. `App.{tsx,jsx,ts,js}` the classic Expo / bare-RN root component,
75
+ * 4. `index.{js,ts}` the bare-RN `AppRegistry` entry.
76
+ * First existing file wins. The root `app/` layout keeps precedence over the
77
+ * `src/app/` one so existing projects resolve exactly as before; `src/app/` is a
78
+ * new fallback checked ahead of `App.*`. Returns null when none exist
79
+ * (→ ambiguous), so the resolver never points at an `expo/AppEntry` path inside
80
+ * node_modules.
76
81
  */
77
82
  declare function resolveReactNativeEntry(cwd: string): string | null;
78
83
  /** Pull a node script/module path out of a `start`-style command. */
@@ -69,10 +69,15 @@ declare function resolveViteEntry(cwd: string): string | null;
69
69
  /**
70
70
  * Resolve the React Native / Expo injection entry. Prefers, in order:
71
71
  * 1. `app/_layout.{tsx,jsx,js}` — the expo-router root layout,
72
- * 2. `App.{tsx,jsx,ts,js}` — the classic Expo / bare-RN root component,
73
- * 3. `index.{js,ts}` the bare-RN `AppRegistry` entry.
74
- * First existing file wins. Returns null when none exist (→ ambiguous), so the
75
- * resolver never points at an `expo/AppEntry` path inside node_modules.
72
+ * 2. `src/app/_layout.{tsx,jsx,js}` — the same root layout under a `src/` dir,
73
+ * which is where create-expo-app's current DEFAULT template puts it,
74
+ * 3. `App.{tsx,jsx,ts,js}` the classic Expo / bare-RN root component,
75
+ * 4. `index.{js,ts}` the bare-RN `AppRegistry` entry.
76
+ * First existing file wins. The root `app/` layout keeps precedence over the
77
+ * `src/app/` one so existing projects resolve exactly as before; `src/app/` is a
78
+ * new fallback checked ahead of `App.*`. Returns null when none exist
79
+ * (→ ambiguous), so the resolver never points at an `expo/AppEntry` path inside
80
+ * node_modules.
76
81
  */
77
82
  declare function resolveReactNativeEntry(cwd: string): string | null;
78
83
  /** Pull a node script/module path out of a `start`-style command. */
package/dist/index.cjs CHANGED
@@ -2188,6 +2188,7 @@ __export(index_exports, {
2188
2188
  envKeyLine: () => envKeyLine,
2189
2189
  executePlan: () => executePlan,
2190
2190
  matchRecipe: () => matchRecipe,
2191
+ nestInitSnippet: () => nestInitSnippet,
2191
2192
  nodeInitSnippet: () => nodeInitSnippet,
2192
2193
  nuxtPluginSnippet: () => nuxtPluginSnippet,
2193
2194
  parseNodeInvocation: () => parseNodeInvocation,
@@ -2375,6 +2376,9 @@ function resolveReactNativeEntry(cwd) {
2375
2376
  import_node_path.default.join("app", "_layout.tsx"),
2376
2377
  import_node_path.default.join("app", "_layout.jsx"),
2377
2378
  import_node_path.default.join("app", "_layout.js"),
2379
+ import_node_path.default.join("src", "app", "_layout.tsx"),
2380
+ import_node_path.default.join("src", "app", "_layout.jsx"),
2381
+ import_node_path.default.join("src", "app", "_layout.js"),
2378
2382
  "App.tsx",
2379
2383
  "App.jsx",
2380
2384
  "App.ts",
@@ -2854,6 +2858,20 @@ function nodeInitSnippet(endpoint) {
2854
2858
  " .catch(() => {});"
2855
2859
  ].join("\n");
2856
2860
  }
2861
+ function singleQuoted(value) {
2862
+ return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
2863
+ }
2864
+ function nestInitSnippet(endpoint) {
2865
+ return [
2866
+ "// Crumbtrail \u2014 auto-captures uncaught exceptions, unhandled rejections, and",
2867
+ "// console.error. Key is read from process.env.CRUMBTRAIL_KEY, which autoCapture",
2868
+ "// loads from your .env (written by the CLI). Express apps can also add",
2869
+ "// createCrumbtrailExpressMiddleware for per-request capture.",
2870
+ "import('crumbtrail-node')",
2871
+ ` .then(({ autoCapture }) => autoCapture({ endpoint: ${singleQuoted(endpoint)} }))`,
2872
+ " .catch(() => {});"
2873
+ ].join("\n");
2874
+ }
2857
2875
  function reactNativeInitSnippet(endpoint, apiKey) {
2858
2876
  return [
2859
2877
  'import { createReactNativeCrumbtrail } from "crumbtrail-react-native";',
@@ -2885,10 +2903,14 @@ var import_node_path2 = __toESM(require("path"), 1);
2885
2903
  function realGitStatus(cwd, target) {
2886
2904
  let out;
2887
2905
  try {
2906
+ const env = Object.fromEntries(
2907
+ Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))
2908
+ );
2888
2909
  out = (0, import_node_child_process.execFileSync)("git", ["status", "--porcelain", "--", target], {
2889
2910
  cwd,
2890
2911
  encoding: "utf8",
2891
- stdio: ["ignore", "pipe", "ignore"]
2912
+ stdio: ["ignore", "pipe", "ignore"],
2913
+ env
2892
2914
  });
2893
2915
  } catch {
2894
2916
  return { isRepo: false, tracked: false, dirty: false };
@@ -7069,7 +7091,7 @@ function planVite(input, io) {
7069
7091
  return prependWithPreflight(input, io, input.entryFile, block);
7070
7092
  }
7071
7093
  function planNode(input, io) {
7072
- const block = nodeInitSnippet(input.endpoint);
7094
+ const block = input.recipe === "nestjs" ? nestInitSnippet(input.endpoint) : nodeInitSnippet(input.endpoint);
7073
7095
  const { action, warning } = buildEnvAction(input.cwd, input.apiKey, io);
7074
7096
  const envWarnings = warning ? [warning] : [];
7075
7097
  if (!input.entryFile) {
@@ -7347,6 +7369,7 @@ function executePlan(plan, io = defaultExecutorIO, options = {}) {
7347
7369
  envKeyLine,
7348
7370
  executePlan,
7349
7371
  matchRecipe,
7372
+ nestInitSnippet,
7350
7373
  nodeInitSnippet,
7351
7374
  nuxtPluginSnippet,
7352
7375
  parseNodeInvocation,
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- export { B as BuildPlanInput, a as BuildPlanOptions, c as DENO_UNSUPPORTED_REASON, d as DOCKER_COMING_SOON_NOTE, D as DetectResult, E as EnvAction, f as ExecuteOptions, g as ExecuteResult, h as ExecutorIO, G as GitTargetStatus, I as InjectIO, P as PackageManager, i as Plan, j as PlanKind, R as Recipe, W as WorkspacePackage, b as buildPlan, k as defaultExecutorIO, l as defaultInjectIO, m as detect, n as detectPackageManager, e as executePlan, o as matchRecipe, p as parseNodeInvocation, q as parsePnpmWorkspace, r as projectAlreadyWired, s as resolveAngularEntry, t as resolveNestEntry, u as resolveOtlpStack, v as resolveReactNativeEntry, w as resolveRemixEntry, x as resolveViteEntry, y as supportsInstrumentationClient } from './executor-DKDrkhxM.cjs';
1
+ export { B as BuildPlanInput, a as BuildPlanOptions, c as DENO_UNSUPPORTED_REASON, d as DOCKER_COMING_SOON_NOTE, D as DetectResult, E as EnvAction, f as ExecuteOptions, g as ExecuteResult, h as ExecutorIO, G as GitTargetStatus, I as InjectIO, P as PackageManager, i as Plan, j as PlanKind, R as Recipe, W as WorkspacePackage, b as buildPlan, k as defaultExecutorIO, l as defaultInjectIO, m as detect, n as detectPackageManager, e as executePlan, o as matchRecipe, p as parseNodeInvocation, q as parsePnpmWorkspace, r as projectAlreadyWired, s as resolveAngularEntry, t as resolveNestEntry, u as resolveOtlpStack, v as resolveReactNativeEntry, w as resolveRemixEntry, x as resolveViteEntry, y as supportsInstrumentationClient } from './executor-BKiOvIvD.cjs';
2
2
  import 'crumbtrail-design-system';
3
3
 
4
4
  interface SourceShape {
@@ -55,6 +55,17 @@ declare function nuxtPluginSnippet(endpoint: string, apiKey: string): string;
55
55
  * crumbtrail-node's README).
56
56
  */
57
57
  declare function nodeInitSnippet(endpoint: string): string;
58
+ /**
59
+ * NestJS server init. Byte-for-byte the same wiring as `nodeInitSnippet` — a
60
+ * dynamically-imported `autoCapture` prepended into `src/main.ts` — but emitted
61
+ * with SINGLE quotes to match Nest scaffolds' Prettier default
62
+ * (`singleQuote: true`). Nest is the only backend-JS recipe that gets its own
63
+ * snippet: its generator ships a `.prettierrc` with single quotes, so the
64
+ * double-quoted `nodeInitSnippet` produces cosmetic diff/lint noise on the very
65
+ * first commit. Every other backend-JS recipe (express/hono/fastify/node) keeps
66
+ * the double-quoted snippet, which matches Prettier's own default.
67
+ */
68
+ declare function nestInitSnippet(endpoint: string): string;
58
69
  /**
59
70
  * React Native / Expo init block. Imperative + prepend-safe: it calls
60
71
  * `createReactNativeCrumbtrail` (which runs `Crumbtrail.init` and installs the
@@ -75,4 +86,4 @@ declare function tauriInitSnippet(): string;
75
86
  /** The single line the CLI writes into `.env` for the Node recipe. */
76
87
  declare function envKeyLine(apiKey: string): string;
77
88
 
78
- export { type SourceShape, analyzeSource, clientInitSnippet, envKeyLine, nodeInitSnippet, nuxtPluginSnippet, prependIntoSource, prologueEnd, reactNativeInitSnippet, referencesCrumbtrail, tauriInitSnippet, withTrailingNewline };
89
+ export { type SourceShape, analyzeSource, clientInitSnippet, envKeyLine, nestInitSnippet, nodeInitSnippet, nuxtPluginSnippet, prependIntoSource, prologueEnd, reactNativeInitSnippet, referencesCrumbtrail, tauriInitSnippet, withTrailingNewline };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { B as BuildPlanInput, a as BuildPlanOptions, c as DENO_UNSUPPORTED_REASON, d as DOCKER_COMING_SOON_NOTE, D as DetectResult, E as EnvAction, f as ExecuteOptions, g as ExecuteResult, h as ExecutorIO, G as GitTargetStatus, I as InjectIO, P as PackageManager, i as Plan, j as PlanKind, R as Recipe, W as WorkspacePackage, b as buildPlan, k as defaultExecutorIO, l as defaultInjectIO, m as detect, n as detectPackageManager, e as executePlan, o as matchRecipe, p as parseNodeInvocation, q as parsePnpmWorkspace, r as projectAlreadyWired, s as resolveAngularEntry, t as resolveNestEntry, u as resolveOtlpStack, v as resolveReactNativeEntry, w as resolveRemixEntry, x as resolveViteEntry, y as supportsInstrumentationClient } from './executor-DKDrkhxM.js';
1
+ export { B as BuildPlanInput, a as BuildPlanOptions, c as DENO_UNSUPPORTED_REASON, d as DOCKER_COMING_SOON_NOTE, D as DetectResult, E as EnvAction, f as ExecuteOptions, g as ExecuteResult, h as ExecutorIO, G as GitTargetStatus, I as InjectIO, P as PackageManager, i as Plan, j as PlanKind, R as Recipe, W as WorkspacePackage, b as buildPlan, k as defaultExecutorIO, l as defaultInjectIO, m as detect, n as detectPackageManager, e as executePlan, o as matchRecipe, p as parseNodeInvocation, q as parsePnpmWorkspace, r as projectAlreadyWired, s as resolveAngularEntry, t as resolveNestEntry, u as resolveOtlpStack, v as resolveReactNativeEntry, w as resolveRemixEntry, x as resolveViteEntry, y as supportsInstrumentationClient } from './executor-BKiOvIvD.js';
2
2
  import 'crumbtrail-design-system';
3
3
 
4
4
  interface SourceShape {
@@ -55,6 +55,17 @@ declare function nuxtPluginSnippet(endpoint: string, apiKey: string): string;
55
55
  * crumbtrail-node's README).
56
56
  */
57
57
  declare function nodeInitSnippet(endpoint: string): string;
58
+ /**
59
+ * NestJS server init. Byte-for-byte the same wiring as `nodeInitSnippet` — a
60
+ * dynamically-imported `autoCapture` prepended into `src/main.ts` — but emitted
61
+ * with SINGLE quotes to match Nest scaffolds' Prettier default
62
+ * (`singleQuote: true`). Nest is the only backend-JS recipe that gets its own
63
+ * snippet: its generator ships a `.prettierrc` with single quotes, so the
64
+ * double-quoted `nodeInitSnippet` produces cosmetic diff/lint noise on the very
65
+ * first commit. Every other backend-JS recipe (express/hono/fastify/node) keeps
66
+ * the double-quoted snippet, which matches Prettier's own default.
67
+ */
68
+ declare function nestInitSnippet(endpoint: string): string;
58
69
  /**
59
70
  * React Native / Expo init block. Imperative + prepend-safe: it calls
60
71
  * `createReactNativeCrumbtrail` (which runs `Crumbtrail.init` and installs the
@@ -75,4 +86,4 @@ declare function tauriInitSnippet(): string;
75
86
  /** The single line the CLI writes into `.env` for the Node recipe. */
76
87
  declare function envKeyLine(apiKey: string): string;
77
88
 
78
- export { type SourceShape, analyzeSource, clientInitSnippet, envKeyLine, nodeInitSnippet, nuxtPluginSnippet, prependIntoSource, prologueEnd, reactNativeInitSnippet, referencesCrumbtrail, tauriInitSnippet, withTrailingNewline };
89
+ export { type SourceShape, analyzeSource, clientInitSnippet, envKeyLine, nestInitSnippet, nodeInitSnippet, nuxtPluginSnippet, prependIntoSource, prologueEnd, reactNativeInitSnippet, referencesCrumbtrail, tauriInitSnippet, withTrailingNewline };
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  envKeyLine,
12
12
  executePlan,
13
13
  matchRecipe,
14
+ nestInitSnippet,
14
15
  nodeInitSnippet,
15
16
  nuxtPluginSnippet,
16
17
  parseNodeInvocation,
@@ -29,7 +30,7 @@ import {
29
30
  supportsInstrumentationClient,
30
31
  tauriInitSnippet,
31
32
  withTrailingNewline
32
- } from "./chunk-UFJ2BODB.js";
33
+ } from "./chunk-HAP4DPWC.js";
33
34
  export {
34
35
  DENO_UNSUPPORTED_REASON,
35
36
  DOCKER_COMING_SOON_NOTE,
@@ -43,6 +44,7 @@ export {
43
44
  envKeyLine,
44
45
  executePlan,
45
46
  matchRecipe,
47
+ nestInitSnippet,
46
48
  nodeInitSnippet,
47
49
  nuxtPluginSnippet,
48
50
  parseNodeInvocation,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crumbtrail",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Crumbtrail setup CLI — detect your framework and wire the SDK into your app in one step.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -39,6 +39,11 @@
39
39
  "publishConfig": {
40
40
  "access": "public"
41
41
  },
42
+ "devDependencies": {
43
+ "@types/node": "^25.5.0",
44
+ "crumbtrail-design-system": "0.0.0",
45
+ "crumbtrail-install-shared": "0.1.0"
46
+ },
42
47
  "scripts": {
43
48
  "build": "tsup",
44
49
  "test": "vitest run",
@@ -46,10 +51,5 @@
46
51
  "test:watch": "vitest",
47
52
  "typecheck": "tsc --noEmit -p tsconfig.json",
48
53
  "clean": "rm -rf dist"
49
- },
50
- "devDependencies": {
51
- "@types/node": "^25.5.0",
52
- "crumbtrail-design-system": "workspace:*",
53
- "crumbtrail-install-shared": "workspace:*"
54
54
  }
55
- }
55
+ }