crumbtrail 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.d.cts CHANGED
@@ -1,6 +1,6 @@
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';
3
- import { Stack } from 'crumbtrail-design-system';
2
+ import { R as Recipe, D as DetectResult, P as PackageManager, b as buildPlan, e as executePlan } from './executor-CW6Q5mhH.cjs';
3
+ import { Stack } from 'crumbtrail-core';
4
4
 
5
5
  /** Output sink — swappable in tests to capture lines instead of writing stdout. */
6
6
  interface Ui {
@@ -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,6 +1,6 @@
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';
3
- import { Stack } from 'crumbtrail-design-system';
2
+ import { R as Recipe, D as DetectResult, P as PackageManager, b as buildPlan, e as executePlan } from './executor-CW6Q5mhH.js';
3
+ import { Stack } from 'crumbtrail-core';
4
4
 
5
5
  /** Output sink — swappable in tests to capture lines instead of writing stdout. */
6
6
  interface Ui {
@@ -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-MZDASWQ7.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
@@ -1,4 +1,4 @@
1
- import { Stack } from 'crumbtrail-design-system';
1
+ import { Stack } from 'crumbtrail-core';
2
2
 
3
3
  /** Injection recipes, ordered most-specific-first during detection. */
4
4
  type Recipe = "tauri" | "next" | "sveltekit" | "nuxt" | "remix" | "astro" | "angular" | "vite-spa" | "nestjs" | "express" | "hono" | "fastify" | "react-native" | "node" | "otlp";
@@ -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. */
@@ -1,4 +1,4 @@
1
- import { Stack } from 'crumbtrail-design-system';
1
+ import { Stack } from 'crumbtrail-core';
2
2
 
3
3
  /** Injection recipes, ordered most-specific-first during detection. */
4
4
  type Recipe = "tauri" | "next" | "sveltekit" | "nuxt" | "remix" | "astro" | "angular" | "vite-spa" | "nestjs" | "express" | "hono" | "fastify" | "react-native" | "node" | "otlp";
@@ -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. */