@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.
- package/README.md +147 -0
- package/bin/backstage.mjs +28 -0
- package/dist/args-Cjr_Iqts.js +66 -0
- package/dist/build-info.json +3 -0
- package/dist/catalog-BOBE4ee7.js +1570 -0
- package/dist/cli-YLCe3Zrx.js +6300 -0
- package/dist/commands-Bk7IbhPD.js +220 -0
- package/dist/commands-D345F-Od.js +800 -0
- package/dist/commands-Dv5TXjvb.js +545 -0
- package/dist/dispatch-D048O65I.js +13 -0
- package/dist/launch.js +329 -0
- package/dist/options-BTjOf5KP.js +183 -0
- package/dist/peer-redirect-hooks.js +15 -0
- package/dist/telemetry-Bjoz29Hl.js +609 -0
- package/dist/telemetry.js +2 -0
- package/dist/ui-CdKo8mLw.js +66 -0
- package/env.ts +163 -0
- package/package.json +82 -0
|
@@ -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 };
|