@devdogsuga/backstage 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.
@@ -0,0 +1,609 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { homedir, platform, release } from "node:os";
5
+ import * as Sentry from "@sentry/node";
6
+ import { buildSentryOptions } from "@devdogsuga/telemetry";
7
+ //#region ../cli-core/src/catalog.ts
8
+ /**
9
+ * How each scope reads, in the two places that draw it.
10
+ *
11
+ * `menu` sits inline in a hint, so it is one word. `help` heads a block of
12
+ * commands, so it can be a phrase. Both live here rather than in the renderers
13
+ * because they are labels, the same kind of data as a group title.
14
+ */
15
+ const SCOPES = {
16
+ machine: {
17
+ menu: "This machine",
18
+ help: "Supabase on this machine"
19
+ },
20
+ repo: {
21
+ menu: "Repo",
22
+ help: "files in the repo"
23
+ },
24
+ endpoint: {
25
+ menu: "Database",
26
+ help: "the session's database (--tier)"
27
+ },
28
+ infra: {
29
+ menu: "Hosted",
30
+ help: "hosted infrastructure, each naming its own connection"
31
+ }
32
+ };
33
+ /** Print what would change; write nothing; exit 0. On every command that writes. */
34
+ const DRY_RUN = {
35
+ flag: "--dry-run",
36
+ summary: "Print what would change, write nothing, exit 0."
37
+ };
38
+ /**
39
+ * Machine-readable output to stdout.
40
+ *
41
+ * Promptless by design: the wizard is already interactive; a flag that switches
42
+ * its output format has no meaning there. `commands.test.ts` pins the promptless
43
+ * set with a "scripting-only" category for exactly this kind of flag.
44
+ */
45
+ const JSON_FLAG = {
46
+ flag: "--json",
47
+ summary: "Print machine-readable JSON to stdout."
48
+ };
49
+ /** Skip every interactive confirmation prompt. On every destructive command. */
50
+ const YES = {
51
+ flag: "--yes",
52
+ summary: "Skip the confirmations."
53
+ };
54
+ function walk(roots, path) {
55
+ let nodes = roots;
56
+ let found = null;
57
+ for (const name of path) {
58
+ const next = nodes.find((node) => node.name === name);
59
+ if (!next) return null;
60
+ found = next;
61
+ nodes = next.subcommands ?? [];
62
+ }
63
+ return found;
64
+ }
65
+ function createCatalog(trees) {
66
+ const groups = trees.groups;
67
+ const ciGroups = trees.ciGroups ?? [];
68
+ const topLevel = groups.flatMap((group) => group.commands);
69
+ const ciTopLevel = ciGroups.flatMap((group) => group.commands);
70
+ const subcommandNames = (path) => (walk(topLevel, path)?.subcommands ?? []).map((node) => node.name);
71
+ return {
72
+ usage: trees.usage,
73
+ commonTasks: trees.commonTasks ?? [],
74
+ groups,
75
+ ciGroups,
76
+ topLevel,
77
+ ciTopLevel,
78
+ findCommand: (path) => walk(topLevel, path),
79
+ findCiCommand: (path) => walk(ciTopLevel, path),
80
+ groupOf: (name) => groups.find((group) => group.commands.some((command) => command.name === name)),
81
+ subcommandNames,
82
+ subcommandCiNames: (path) => (walk(ciTopLevel, path)?.subcommands ?? []).map((node) => node.name),
83
+ subcommandList: (path) => {
84
+ const names = subcommandNames(path);
85
+ if (names.length <= 1) return names.join("");
86
+ return `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}`;
87
+ },
88
+ allPaths: () => {
89
+ const paths = [];
90
+ const visit = (nodes, prefix) => {
91
+ for (const node of nodes) {
92
+ const path = [...prefix, node.name];
93
+ paths.push(path);
94
+ visit(node.subcommands ?? [], path);
95
+ }
96
+ };
97
+ visit(topLevel, []);
98
+ return paths;
99
+ }
100
+ };
101
+ }
102
+ //#endregion
103
+ //#region ../cli-core/src/cli-name.ts
104
+ let current = "devtools";
105
+ function setCliName(name) {
106
+ current = name;
107
+ }
108
+ function cliName() {
109
+ return current;
110
+ }
111
+ //#endregion
112
+ //#region ../cli-core/src/repo/root.ts
113
+ /**
114
+ * Where devtools finds the DevDogsUGA checkout it is running against.
115
+ *
116
+ * devtools used to live inside `packages/devtools` of that repo, so its own
117
+ * `import.meta.url` WAS a path into the repo (`PROJECT_ROOT` /
118
+ * `REPO_ROOT` walked up a fixed number of `..` segments from this file).
119
+ * Published as its own package and run through `pnpm dlx`, that assumption
120
+ * breaks: this file's `import.meta.url` points into a dlx cache directory
121
+ * or an isolated `node_modules` tree that has nothing to do with the repo
122
+ * the contributor is standing in.
123
+ *
124
+ * So repo discovery walks up from `process.cwd()` instead, looking for the
125
+ * marker validated in the `devtools-dlx` prototype
126
+ * (`/home/sloan/scratchpad/devdogs/prototypes/devtools-dlx/FINDINGS.md`,
127
+ * experiment 1): a directory containing `pnpm-workspace.yaml` AND whose
128
+ * `package.json` has `"name": "devdogs-monorepo"`. The name check is not
129
+ * redundant — a bare `pnpm-workspace.yaml` also matches a Backstage
130
+ * checkout (a separate pnpm workspace), which devtools should refuse
131
+ * rather than silently "find".
132
+ */
133
+ /** The `package.json` name every real DevDogsUGA checkout carries at its root. */
134
+ const REPO_MARKER_NAME = "devdogs-monorepo";
135
+ var RepoNotFoundError = class extends Error {
136
+ constructor() {
137
+ super("run this from inside a DevDogsUGA clone");
138
+ this.name = "RepoNotFoundError";
139
+ }
140
+ };
141
+ function looksLikeRepoRoot(dir) {
142
+ if (!existsSync(join(dir, "pnpm-workspace.yaml"))) return false;
143
+ try {
144
+ return JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).name === REPO_MARKER_NAME;
145
+ } catch {
146
+ return false;
147
+ }
148
+ }
149
+ /**
150
+ * Walks up from `startDir` (default `process.cwd()`) looking for the repo
151
+ * marker. Returns `null` rather than throwing — `findRepoRoot()` below is
152
+ * the throwing convenience most callers want; this is exported for callers
153
+ * (like `setup`) that need to know without failing.
154
+ */
155
+ function discoverRepoRoot(startDir = process.cwd()) {
156
+ let dir = startDir;
157
+ for (;;) {
158
+ if (looksLikeRepoRoot(dir)) return dir;
159
+ const parent = dirname(dir);
160
+ if (parent === dir) return null;
161
+ dir = parent;
162
+ }
163
+ }
164
+ let cachedRoot;
165
+ /**
166
+ * The memoized, throwing repo-root lookup. Lazy on purpose: importing this
167
+ * module must never fail just because nothing has called it yet (`setup`
168
+ * and `completions` in `launch.ts` never need a repo at all, and must stay
169
+ * that way — see that file's header comment on why).
170
+ *
171
+ * Resolved once per process: `process.cwd()` does not change mid-invocation
172
+ * for this CLI, and re-walking the filesystem on every call would be pure
173
+ * waste.
174
+ */
175
+ function findRepoRoot() {
176
+ if (cachedRoot === void 0) cachedRoot = process.env.DEVTOOLS_TEST_REPO_ROOT ?? discoverRepoRoot();
177
+ if (cachedRoot === null) throw new RepoNotFoundError();
178
+ return cachedRoot;
179
+ }
180
+ //#endregion
181
+ //#region ../cli-core/src/version.ts
182
+ let cachedOwnDir;
183
+ /** The published package's own root: the nearest directory above this
184
+ * module's file that holds a `package.json`. A bundled CLI runs from a flat
185
+ * `dist/`, so that is the CLI's root (`dist/launch.js` -> up one); the core
186
+ * is inlined into it, so its own source location never matters at run time.
187
+ * Memoized since it never changes within a process. */
188
+ function ownPackageDir() {
189
+ if (cachedOwnDir !== void 0) return cachedOwnDir;
190
+ let dir = dirname(fileURLToPath(import.meta.url));
191
+ while (!existsSync(join(dir, "package.json")) && dirname(dir) !== dir) dir = dirname(dir);
192
+ cachedOwnDir = dir;
193
+ return dir;
194
+ }
195
+ /** Reads this build's own `package.json` version — used by `telemetry.ts`
196
+ * (release tagging) and the CLI's `version` command. */
197
+ function ownVersion() {
198
+ const pkg = JSON.parse(readFileSync(join(ownPackageDir(), "package.json"), "utf8"));
199
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
200
+ }
201
+ //#endregion
202
+ //#region ../cli-core/src/failure-log.ts
203
+ /**
204
+ * A local log for a failed run, so a contributor has something to attach to
205
+ * #tech-support.
206
+ *
207
+ * When the process is about to exit non-zero, one file is written holding what
208
+ * helps someone else diagnose it: the command line, the version and platform,
209
+ * the tool commands that ran with their exit codes, devtools' own output, the
210
+ * error, and the Sentry event id when telemetry sent one. The path is printed
211
+ * last, on stderr. Output a tool wrote straight to the terminal is not
212
+ * captured (tools keep the terminal so colours and prompts work), and the log
213
+ * says so.
214
+ *
215
+ * Secrets are redacted before anything is stored: credentials inside URLs,
216
+ * and the value of any environment variable named like a secret.
217
+ *
218
+ * Not a failure, so no log: a clean exit, Ctrl-C (130) or SIGTERM (143), the
219
+ * drift code (2) that `--check` uses on purpose, and a cancelled prompt.
220
+ */
221
+ function nonEmpty(value) {
222
+ if (value === void 0 || value === "") return void 0;
223
+ return value;
224
+ }
225
+ function describe(error) {
226
+ return error instanceof Error ? error.message : String(error);
227
+ }
228
+ function versionOrUnknown() {
229
+ try {
230
+ return ownVersion();
231
+ } catch {
232
+ return "unknown";
233
+ }
234
+ }
235
+ const SECRET_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|KEY|DSN|DB_URL|CREDENTIAL)/i;
236
+ const URL_CREDENTIALS = /\/\/[^/@\s:]+(:[^/@\s]*)?@/g;
237
+ let state = null;
238
+ /** Replaces credentials in `text` with a placeholder. */
239
+ function redact(text, env = process.env) {
240
+ let out = text.replace(URL_CREDENTIALS, "//***@");
241
+ for (const [name, value] of Object.entries(env)) if (value && value.length >= 8 && SECRET_NAME.test(name)) out = out.split(value).join("<redacted>");
242
+ return out;
243
+ }
244
+ function failureLogDir(env = process.env) {
245
+ return nonEmpty(env.DEVTOOLS_LOG_DIR) ?? join(nonEmpty(env.XDG_STATE_HOME) ?? join(homedir(), ".local", "state"), "devdogs", "logs");
246
+ }
247
+ /** Whether an exit code means the run failed. */
248
+ function isFailureCode(code) {
249
+ return code !== 0 && code !== 2 && code !== 130 && code !== 143;
250
+ }
251
+ /** The file's text. Pure, so it is testable without a process to fail. */
252
+ function renderFailureLog(input) {
253
+ const env = input.env ?? process.env;
254
+ const lines = [
255
+ "devtools failure log",
256
+ "Attach this file to your #tech-support message.",
257
+ "",
258
+ `time: ${input.now.toISOString()}`,
259
+ `version: ${versionOrUnknown()}`,
260
+ `command: devtools ${input.argv.join(" ")}`,
261
+ `exit code: ${input.code}`,
262
+ `tier: ${nonEmpty(env.DEPLOY_ENV) ?? "development"}`,
263
+ `node: ${process.version}`,
264
+ `platform: ${platform()} ${release()}`,
265
+ `cwd: ${process.cwd()}`,
266
+ `sentry: ${input.eventId ?? "none (telemetry sent nothing)"}`,
267
+ "",
268
+ "== Tool commands that ran ==",
269
+ ...input.ran.length > 0 ? input.ran : ["(none)"],
270
+ "",
271
+ "== Error =="
272
+ ];
273
+ if (input.error === void 0) lines.push("(none thrown)");
274
+ else lines.push(input.error instanceof Error ? input.error.stack ?? input.error.message : describe(input.error));
275
+ lines.push("", "== devtools output (tool output that went straight to the terminal is not here) ==", input.output.length > 0 ? input.output : "(none)");
276
+ return redact(`${lines.join("\n")}\n`, env);
277
+ }
278
+ /** Records a command a tool ran, for the log. */
279
+ function noteRan(line) {
280
+ state?.ran.push(line);
281
+ }
282
+ /** Records the error a failure came from, for the log. */
283
+ function noteError(error) {
284
+ if (state) state.error ??= error;
285
+ }
286
+ /** A cancelled prompt is the user's choice, not a failure. */
287
+ function suppressFailureLog() {
288
+ if (state) state.quiet = true;
289
+ }
290
+ function keepNewest(dir) {
291
+ try {
292
+ const old = readdirSync(dir).filter((name) => name.startsWith("devtools-") && name.endsWith(".log")).sort().slice(0, -10);
293
+ for (const name of old) rmSync(join(dir, name), { force: true });
294
+ } catch {}
295
+ }
296
+ /** Writes the log and returns its path, or `null` if it could not be written. */
297
+ function writeLog(input) {
298
+ try {
299
+ const dir = failureLogDir(input.env);
300
+ mkdirSync(dir, { recursive: true });
301
+ const stamp = input.now.toISOString().replace(/[:.]/g, "-");
302
+ const path = join(dir, `devtools-${stamp}.log`);
303
+ writeFileSync(path, renderFailureLog(input), { mode: 384 });
304
+ keepNewest(dir);
305
+ return path;
306
+ } catch {
307
+ return null;
308
+ }
309
+ }
310
+ /**
311
+ * Starts recording and writes the log if the process exits with a failure.
312
+ * Call once, early. A second call is ignored.
313
+ */
314
+ function installFailureLog(options) {
315
+ if (state) return;
316
+ state = {
317
+ argv: [...options.argv],
318
+ eventId: options.eventId,
319
+ ran: [],
320
+ output: "",
321
+ error: void 0,
322
+ quiet: false
323
+ };
324
+ const capture = (stream) => {
325
+ const original = stream.write.bind(stream);
326
+ stream.write = (chunk, ...rest) => {
327
+ if (state && typeof chunk === "string") state.output = (state.output + chunk).slice(-65536);
328
+ return original(chunk, ...rest);
329
+ };
330
+ };
331
+ capture(process.stdout);
332
+ capture(process.stderr);
333
+ process.once("exit", (exitCode) => {
334
+ const current = state;
335
+ if (!current || current.quiet) return;
336
+ const code = process.exitCode === void 0 ? exitCode : Number(process.exitCode);
337
+ if (!isFailureCode(code)) return;
338
+ const path = writeLog({
339
+ argv: current.argv,
340
+ code,
341
+ ran: current.ran,
342
+ output: current.output,
343
+ error: current.error,
344
+ eventId: current.eventId(),
345
+ now: /* @__PURE__ */ new Date()
346
+ });
347
+ const eventId = current.eventId();
348
+ process.stderr.write([
349
+ path ? `Log for #tech-support: ${path}` : "Could not write a failure log.",
350
+ ...eventId ? [`Sentry event: ${eventId}`] : [],
351
+ ""
352
+ ].join("\n"));
353
+ });
354
+ }
355
+ //#endregion
356
+ //#region ../cli-core/src/mode.ts
357
+ /**
358
+ * Non-interactive mode: the one definition both published CLIs share.
359
+ *
360
+ * A run is non-interactive when nobody can answer a prompt: no TTY on stdin,
361
+ * or `CI=true`. In that mode there is no wizard and no banner, output is plain
362
+ * lines (errors on stderr), every confirmation needs `--yes`, the tier must be
363
+ * named (`--tier` or `DEPLOY_ENV`), and Sentry reports the environment `ci`.
364
+ * `--no-env` is the companion switch: it skips loading env files, for a job
365
+ * that supplies its own environment.
366
+ */
367
+ /** Skip every confirmation. Also the only way past the hosted-tier gate with no terminal. */
368
+ const YES_FLAG = "--yes";
369
+ /** Do not load env files; the caller's environment is the environment. */
370
+ const NO_ENV_FLAG = "--no-env";
371
+ /** `CI=true` (or `1`), the value every CI runner sets. */
372
+ function isCiEnv(env = process.env) {
373
+ return env.CI === "true" || env.CI === "1";
374
+ }
375
+ /**
376
+ * Whether this run has nobody to ask.
377
+ *
378
+ * Both inputs are injectable for tests; the defaults read the live process.
379
+ */
380
+ function isNonInteractive(env = process.env, isTTY = process.stdin.isTTY === true) {
381
+ return !isTTY || isCiEnv(env);
382
+ }
383
+ /** Whether `--yes` was typed anywhere in `argv`. */
384
+ function hasYes(argv) {
385
+ return argv.includes(YES_FLAG);
386
+ }
387
+ /**
388
+ * Pulls `--no-env` out of `argv`, wherever it sits, leaving everything else in
389
+ * order. Like `--tier`, it is a global flag the launcher strips before any
390
+ * command sees the rest.
391
+ */
392
+ function stripNoEnvFlag(argv) {
393
+ const rest = argv.filter((arg) => arg !== NO_ENV_FLAG);
394
+ return {
395
+ noEnv: rest.length !== argv.length,
396
+ rest
397
+ };
398
+ }
399
+ /**
400
+ * Pulls a global `--tier <t>` out of `argv`, wherever it sits, leaving every
401
+ * other argument untouched and in its original order.
402
+ *
403
+ * A trailing `--tier` with nothing after it removes just the flag; the
404
+ * missing value then reaches tier resolution as `explicit: undefined`, which
405
+ * falls through to `DEPLOY_ENV`/the sole tier/the prompt exactly as if
406
+ * `--tier` had never been typed, rather than this function guessing. A
407
+ * following token that is itself a flag is treated the same way, NOT consumed
408
+ * as the value (the guard every other flag-value reader keeps): without it,
409
+ * `--tier --help` would swallow the flag as a bogus tier and refuse with
410
+ * "unknown tier" instead of reaching the help bypass.
411
+ */
412
+ function stripTierFlag(argv) {
413
+ const rest = [...argv];
414
+ const index = rest.indexOf("--tier");
415
+ if (index === -1) return {
416
+ explicit: void 0,
417
+ rest
418
+ };
419
+ const value = rest[index + 1];
420
+ const missing = value === void 0 || value.startsWith("-");
421
+ rest.splice(index, missing ? 1 : 2);
422
+ return {
423
+ explicit: missing ? void 0 : value,
424
+ rest
425
+ };
426
+ }
427
+ let noEnv = false;
428
+ /** Records that `--no-env` was typed, for handlers that behave differently
429
+ * when the caller supplies the environment (backstage's `planner status`). */
430
+ function setNoEnv(value) {
431
+ noEnv = value;
432
+ }
433
+ /** Whether this run was started with `--no-env`. */
434
+ function isNoEnv() {
435
+ return noEnv;
436
+ }
437
+ //#endregion
438
+ //#region ../cli-core/src/telemetry.ts
439
+ /**
440
+ * devtools' own Sentry wiring — the CLI is a consumer of
441
+ * `@devdogsuga/telemetry` like `apps/platform`, `apps/schedule-builder`, and
442
+ * `apps/sandbox`, but on `@sentry/node` rather than a framework SDK: a CLI
443
+ * process starts, runs one command, and exits, with none of a server's
444
+ * request lifecycle for a framework integration to hook into.
445
+ *
446
+ * ## Reporting is on by default
447
+ *
448
+ * Every other `*_SENTRY_DSN` in this org is optional-and-empty until
449
+ * configured (see `@devdogsuga/telemetry`'s no-op-without-DSN contract), and
450
+ * so is this one — empty means `buildSentryOptions` returns `undefined` and
451
+ * `Sentry.init` never runs — but the DEFAULT here is reporting ON once a DSN
452
+ * exists, unlike a feature a contributor opts into. `DEVTOOLS_TELEMETRY=0` is
453
+ * the one escape hatch, checked before `Sentry.init` and before every capture
454
+ * call, so it also works as a kill switch after init already ran (a
455
+ * long-lived `pnpm devtools` menu session, for instance).
456
+ *
457
+ * ## Where the DSN comes from
458
+ *
459
+ * Baked in at build time, not read from the consumer's environment: devtools
460
+ * runs on contributors' machines, where no `.env` could be relied on to carry
461
+ * it. `scripts/write-build-info.mjs` writes `dist/build-info.json` from the
462
+ * build's `DEVTOOLS_SENTRY_DSN`, which Backstage's `publish.yaml` sets from
463
+ * the repo's Actions variable of the same name. Every other build bakes ""
464
+ * and reports nothing. `DEVTOOLS_SENTRY_DSN` in the run-time environment still
465
+ * wins, for pointing a local build at a test project.
466
+ */
467
+ /** The DSN baked into this build, or "" if there is none (a source run under
468
+ * tsx, or a build made without `DEVTOOLS_SENTRY_DSN`). */
469
+ function bakedSentryDsn() {
470
+ try {
471
+ const info = JSON.parse(readFileSync(new URL("./build-info.json", import.meta.url), "utf8"));
472
+ return typeof info.sentryDsn === "string" ? info.sentryDsn : "";
473
+ } catch {
474
+ return "";
475
+ }
476
+ }
477
+ /** The run-time `DEVTOOLS_SENTRY_DSN` if set, else the baked one. */
478
+ function resolveDevtoolsDsn(env, baked) {
479
+ return (env.DEVTOOLS_SENTRY_DSN ?? "") || baked;
480
+ }
481
+ let initialized = false;
482
+ /** The id of the last event this process sent to Sentry, if any. */
483
+ let lastEventId;
484
+ /**
485
+ * The Sentry event id of the last error or failure this run reported, or
486
+ * `undefined` when telemetry is off or sent nothing. Printed with the failure
487
+ * log so a maintainer can find the event.
488
+ */
489
+ function lastSentryEventId() {
490
+ return lastEventId;
491
+ }
492
+ /** Records the id only when a client exists: without one nothing was sent. */
493
+ function sent(id) {
494
+ if (Sentry.getClient()) lastEventId = id;
495
+ }
496
+ /**
497
+ * `'ci'` whenever the run is non-interactive (no TTY, or `CI=true`, which the
498
+ * runner sets itself before any workflow-authored env), `'local'` on a
499
+ * contributor's terminal.
500
+ * One of `@devdogsuga/telemetry`'s `ENVIRONMENTS`.
501
+ */
502
+ function devtoolsEnvironment() {
503
+ return isNonInteractive() ? "ci" : "local";
504
+ }
505
+ /**
506
+ * `DEVTOOLS_TELEMETRY=0` (any other value, including unset, leaves reporting
507
+ * on) is the only opt-out. Checked by both `initDevtoolsTelemetry` (skips
508
+ * `Sentry.init` outright) and `captureDevtoolsError` (so setting it mid-run,
509
+ * or between two invocations in the same process, still works).
510
+ */
511
+ function devtoolsTelemetryEnabled() {
512
+ return process.env.DEVTOOLS_TELEMETRY !== "0";
513
+ }
514
+ /**
515
+ * Whether anyone is actually using devtools in this process. Package-registry
516
+ * scanners install every published version within minutes and run it in a
517
+ * throwaway sandbox (random `DESKTOP-xxxxxx` hosts, Firecracker VMs, a fuzzer
518
+ * that turns `process.exit` into a throw). Left unfiltered, each release filed
519
+ * the same two issues from them. They run with no DevDogsUGA checkout and no
520
+ * terminal. A person has at least one of the two (`setup` runs outside a
521
+ * checkout, but in a terminal), and CI always has a checkout.
522
+ */
523
+ function hasRealCaller(inRepo, isTTY) {
524
+ return inRepo || isTTY;
525
+ }
526
+ /**
527
+ * Initializes `@sentry/node` for this CLI process. Safe to call more than
528
+ * once (idempotent) and safe to call with no DSN configured (no-ops via
529
+ * `buildSentryOptions`, see its header).
530
+ *
531
+ * `command` becomes a `command` tag on every event this process reports, so
532
+ * an issue in Sentry names the subcommand it came from (`db reset`,
533
+ * `deploy platform`, …) without anyone opening the job log first.
534
+ */
535
+ function initDevtoolsTelemetry(command) {
536
+ if (initialized) return;
537
+ initialized = true;
538
+ if (!devtoolsTelemetryEnabled()) return;
539
+ if (!hasRealCaller(discoverRepoRoot() !== null, process.stdin.isTTY === true)) return;
540
+ const options = buildSentryOptions({
541
+ service: "devtools",
542
+ environment: devtoolsEnvironment(),
543
+ dsn: resolveDevtoolsDsn(process.env, bakedSentryDsn()),
544
+ release: `${cliName()}@${ownVersion()}`
545
+ });
546
+ if (!options) return;
547
+ Sentry.init(options);
548
+ Sentry.getCurrentScope().setTag("command", command);
549
+ }
550
+ /**
551
+ * Reports an error nothing below the entry points caught — `launch.ts`'s
552
+ * `dispatch`, `launch-ci.ts`, and the bins — then flushes before the process
553
+ * exits. Node's event loop dies with `process.exit`, taking any in-flight
554
+ * request to Sentry's ingest endpoint with it, so the flush must be awaited
555
+ * BEFORE that call — never after.
556
+ *
557
+ * A short timeout: a CLI exiting on error should not hang perceptibly longer
558
+ * because Sentry's ingest is slow or unreachable.
559
+ */
560
+ async function captureDevtoolsError(err) {
561
+ noteError(err);
562
+ if (!devtoolsTelemetryEnabled()) return;
563
+ sent(Sentry.captureException(err));
564
+ await Sentry.flush(2e3);
565
+ }
566
+ /**
567
+ * Reports an error a command caught and explained to the reader itself
568
+ * (`explainError` in `ui.ts`, `backstage deploy`'s catch), then carried on
569
+ * to a normal exit. Not flushed: the SDK's in-flight request keeps the event
570
+ * loop alive until it lands, so a natural exit waits for it on its own. A
571
+ * path that ends in `process.exit` instead must use `captureDevtoolsError`.
572
+ */
573
+ function reportDevtoolsError(err) {
574
+ noteError(err);
575
+ if (!devtoolsTelemetryEnabled()) return;
576
+ sent(Sentry.captureException(err));
577
+ }
578
+ /**
579
+ * Reports a failure that has no error object — a subprocess or deploy step
580
+ * that exited non-zero. Same no-flush contract as `reportDevtoolsError`.
581
+ */
582
+ function reportDevtoolsFailure(message, extra = {}) {
583
+ noteError(new Error(message));
584
+ if (!devtoolsTelemetryEnabled()) return;
585
+ sent(Sentry.captureMessage(message, {
586
+ level: "error",
587
+ extra
588
+ }));
589
+ }
590
+ /**
591
+ * Reports a warning-level message under a FIXED fingerprint, then flushes.
592
+ *
593
+ * For the deprecated aliases: every use groups into one Sentry issue per
594
+ * fingerprint, so the issue's last-seen time says when the alias stopped
595
+ * being used and is safe to remove. Flushed because the callers go on to
596
+ * `process.exit`, which would otherwise drop the request in flight. The
597
+ * timeout is short: the command being run is what the person came for.
598
+ */
599
+ async function captureDevtoolsDeprecation(message, fingerprint, tags) {
600
+ if (!devtoolsTelemetryEnabled()) return;
601
+ Sentry.captureMessage(message, {
602
+ level: "warning",
603
+ fingerprint: [fingerprint],
604
+ tags
605
+ });
606
+ await Sentry.flush(1e3);
607
+ }
608
+ //#endregion
609
+ export { YES as A, discoverRepoRoot as C, DRY_RUN as D, setCliName as E, JSON_FLAG as O, RepoNotFoundError as S, cliName as T, installFailureLog as _, hasRealCaller as a, ownPackageDir as b, reportDevtoolsError as c, hasYes as d, isNoEnv as f, stripTierFlag as g, stripNoEnvFlag as h, devtoolsTelemetryEnabled as i, createCatalog as j, SCOPES as k, reportDevtoolsFailure as l, setNoEnv as m, captureDevtoolsError as n, initDevtoolsTelemetry as o, isNonInteractive as p, devtoolsEnvironment as r, lastSentryEventId as s, captureDevtoolsDeprecation as t, resolveDevtoolsDsn as u, noteRan as v, findRepoRoot as w, ownVersion as x, suppressFailureLog as y };
@@ -0,0 +1,2 @@
1
+ import { a as hasRealCaller, c as reportDevtoolsError, i as devtoolsTelemetryEnabled, l as reportDevtoolsFailure, n as captureDevtoolsError, o as initDevtoolsTelemetry, r as devtoolsEnvironment, s as lastSentryEventId, t as captureDevtoolsDeprecation, u as resolveDevtoolsDsn } from "./telemetry-Bjoz29Hl.js";
2
+ export { captureDevtoolsDeprecation, captureDevtoolsError, devtoolsEnvironment, devtoolsTelemetryEnabled, hasRealCaller, initDevtoolsTelemetry, lastSentryEventId, reportDevtoolsError, reportDevtoolsFailure, resolveDevtoolsDsn };
@@ -0,0 +1,66 @@
1
+ import { c as reportDevtoolsError, p as isNonInteractive, y as suppressFailureLog } from "./telemetry-Bjoz29Hl.js";
2
+ import { cancel, isCancel, log, note } from "@clack/prompts";
3
+ //#region ../cli-core/src/ui.ts
4
+ /**
5
+ * Shared prompt plumbing.
6
+ *
7
+ * The audience here is a contributor who may not be comfortable in a terminal
8
+ * at all, so two rules apply throughout:
9
+ *
10
+ * * Nothing requires knowing a command. Running `pnpm devtools` with no
11
+ * arguments opens a menu, and every prompt has a sensible default.
12
+ * * A failure says what to do next. `explain()` exists so no error path
13
+ * ends at a stack trace.
14
+ */
15
+ function bail(message = "Cancelled.") {
16
+ if (message === "Cancelled.") suppressFailureLog();
17
+ if (isNonInteractive()) process.stderr.write(`${message}\n`);
18
+ else cancel(message);
19
+ process.exit(1);
20
+ }
21
+ /** Exits cleanly on Ctrl-C rather than letting a cancel symbol leak onward. */
22
+ function unwrap(value) {
23
+ if (isCancel(value)) bail();
24
+ return value;
25
+ }
26
+ /**
27
+ * Reports a failure with the next thing to try.
28
+ *
29
+ * `hints` is not decoration: for most of these failures the fix is one command,
30
+ * and printing it is the difference between a contributor continuing and a
31
+ * contributor asking in Discord.
32
+ */
33
+ function explain(summary, detail, hints = []) {
34
+ if (isNonInteractive()) {
35
+ process.stderr.write(`${summary}\n`);
36
+ if (detail) process.stderr.write(`${detail}\n`);
37
+ for (const hint of hints) process.stderr.write(` try: ${hint}\n`);
38
+ return;
39
+ }
40
+ log.error(summary);
41
+ if (detail) log.message(detail);
42
+ if (hints.length > 0) note(hints.join("\n"), "Try this");
43
+ }
44
+ /**
45
+ * A refusal the reader can fix themselves — a name that matches nothing, a
46
+ * flag missing where there is no terminal to ask. Thrown from deep enough
47
+ * that the command's catch can't tell it from a real failure any other way;
48
+ * `explainError` prints it like any other error but keeps it out of Sentry.
49
+ */
50
+ var UsageError = class extends Error {
51
+ name = "UsageError";
52
+ };
53
+ /**
54
+ * `explain()` for a caught error rather than a refusal: prints the same way,
55
+ * with `err`'s message as the detail, and reports `err` to Sentry unless it
56
+ * is a {@link UsageError}. Only failures nobody anticipated belong there.
57
+ */
58
+ function explainError(summary, err, hints = []) {
59
+ if (!(err instanceof UsageError)) reportDevtoolsError(err);
60
+ explain(summary, errorMessage(err), hints);
61
+ }
62
+ function errorMessage(err) {
63
+ return err instanceof Error ? err.message : String(err);
64
+ }
65
+ //#endregion
66
+ export { explainError as a, explain as i, bail as n, unwrap as o, errorMessage as r, UsageError as t };