@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/dist/launch.js ADDED
@@ -0,0 +1,329 @@
1
+ import { C as discoverRepoRoot, E as setCliName, S as RepoNotFoundError, T as cliName, _ as installFailureLog, d as hasYes, g as stripTierFlag, h as stripNoEnvFlag, m as setNoEnv, n as captureDevtoolsError, o as initDevtoolsTelemetry, p as isNonInteractive, s as lastSentryEventId } from "./telemetry-Bjoz29Hl.js";
2
+ import { c as setMenuEnvHook, d as formatCommand, f as nonEmpty, g as loadEnvSession, h as loadEnvLoad, n as bareGroupStartPath, t as catalog, v as helpPath } from "./catalog-BOBE4ee7.js";
3
+ import { c as setDryRun, o as isDryRun, s as resolveDryRun } from "./options-BTjOf5KP.js";
4
+ import { o as unwrap, r as errorMessage } from "./ui-CdKo8mLw.js";
5
+ import { confirm } from "@clack/prompts";
6
+ //#region ../cli-core/src/pipes.ts
7
+ /**
8
+ * Keeps a closed stdout/stderr from crashing devtools.
9
+ *
10
+ * When whatever reads devtools' output goes away first (`devtools … | head`,
11
+ * or a wrapper that stopped listening), the next write fails with EPIPE. The
12
+ * stream emits that as an `'error'` event nobody handles, so Node throws it as
13
+ * an uncaught exception and Sentry files it as a fatal. Nobody is left to read
14
+ * the output, so dropping it is the right outcome. Any other stream error is
15
+ * rethrown and crashes as it did before.
16
+ *
17
+ * Idempotent, because `launch`, `launchCi` and `ci.ts`'s `main` can all run in
18
+ * one process.
19
+ */
20
+ let installed = false;
21
+ function ignoreClosedPipes() {
22
+ if (installed) return;
23
+ installed = true;
24
+ for (const stream of [process.stdout, process.stderr]) stream.on("error", (err) => {
25
+ if (err.code !== "EPIPE") throw err;
26
+ });
27
+ }
28
+ //#endregion
29
+ //#region ../cli-core/src/safety-gate.ts
30
+ /**
31
+ * The production and staging safety gate.
32
+ *
33
+ * devtools can reach any tier, and it cannot tell which `supabase`
34
+ * subcommands write, so the question is asked before ANY command runs against
35
+ * a hosted tier, passthroughs and presets included. This replaces the
36
+ * per-command confirmations `db reset`, `db migrate`, `seed production` and
37
+ * `cf preview` each used to carry.
38
+ *
39
+ * * production: shows the tier and project ref (never the `DB_URL`, which
40
+ * holds the password) and asks once, defaulting to no.
41
+ * * staging: the same, with milder wording.
42
+ * * non-interactive runs need `--yes`; without it the gate refuses instead
43
+ * of hanging on a prompt nobody can answer. clack's `confirm()` never
44
+ * resolves without a TTY, so `--yes` is checked before anything that
45
+ * could wait on one.
46
+ * * development (local or remote): no prompt.
47
+ */
48
+ /** Set once the gate passed, so a devtools run spawned by devtools does not ask again. */
49
+ const GATE_PASSED_ENV = "DEVTOOLS_GATE_PASSED";
50
+ const HOSTED = /* @__PURE__ */ new Set(["staging", "production"]);
51
+ function isHostedTier(tier) {
52
+ return HOSTED.has(tier);
53
+ }
54
+ async function askClack(message) {
55
+ return unwrap(await confirm({
56
+ message,
57
+ initialValue: false
58
+ }));
59
+ }
60
+ /**
61
+ * Decides whether a command may run against `tier`. Writes its own refusal to
62
+ * stderr, so the caller only has to exit non-zero on `proceed: false`.
63
+ */
64
+ async function gateHostedTier(input) {
65
+ const env = input.env ?? process.env;
66
+ if (!isHostedTier(input.tier)) return { proceed: true };
67
+ if (env["DEVTOOLS_GATE_PASSED"] === input.tier) return { proceed: true };
68
+ const production = input.tier === "production";
69
+ const target = `${production ? "PRODUCTION" : "staging"} (project ${input.projectRef ?? "unknown"})`;
70
+ const cli = cliName();
71
+ const command = formatCommand(cli, input.argv);
72
+ if (input.yes) {
73
+ if (input.nonInteractive) process.stderr.write(`${cli}: running \`${command}\` against ${target}.\n`);
74
+ return { proceed: true };
75
+ }
76
+ if (input.nonInteractive) {
77
+ process.stderr.write(`${cli}: refusing to run \`${command}\` against ${target} without --yes. Nobody is here to confirm it.
78
+ `);
79
+ return {
80
+ proceed: false,
81
+ reason: "refused"
82
+ };
83
+ }
84
+ if (!await (input.ask ?? askClack)(production ? `This runs \`${command}\` against PRODUCTION, project ${input.projectRef ?? "unknown"}. It is live data. Continue?` : `This runs \`${command}\` against staging, project ${input.projectRef ?? "unknown"}. Continue?`)) {
85
+ process.stderr.write(`${cli}: left ${input.tier} alone.\n`);
86
+ return {
87
+ proceed: false,
88
+ reason: "declined"
89
+ };
90
+ }
91
+ return { proceed: true };
92
+ }
93
+ //#endregion
94
+ //#region src/launch-core.ts
95
+ /**
96
+ * What `backstage` does BEFORE `cli.ts` (and so every command it dispatches)
97
+ * is imported: settle the session's tier, enter it, hand off. `launch.ts` is
98
+ * the entry point the `bin/backstage.mjs` bootstrap runs.
99
+ *
100
+ * ## It starts anywhere
101
+ *
102
+ * `pnpm dlx @devdogsuga/backstage` works from any directory, a DevDogsUGA
103
+ * checkout or not. Nothing here needs one until a command does: help, version,
104
+ * completions and anything run with `--no-env` never look for a repository,
105
+ * and never import the optional-peer `@devdogsuga/*` libraries, which are only
106
+ * installed next to a checkout. A command that reads a checkout (`env`,
107
+ * `deploy <app>`, `planner`…) says "run this from inside a DevDogsUGA clone"
108
+ * and exits 1, instead of a stack trace.
109
+ *
110
+ * ## The session's tier
111
+ *
112
+ * Every command here always needs production secrets, but they name their
113
+ * own targets: `env` takes `--target`, `planner` works on the production
114
+ * database. So unlike devtools, nothing is ever asked at launch:
115
+ *
116
+ * * `--tier <t>` (anywhere in argv, stripped here), else `DEPLOY_ENV`, names
117
+ * the session's tier and so the env file loaded (`.env.<tier>`);
118
+ * * with neither, the session is plain `development` and `.env` is loaded,
119
+ * which is where `BWS_ACCESS_TOKEN` lives;
120
+ * * the `deploy` group refuses to run without one of the two. Applying
121
+ * migrations to whatever `DB_URL` the development `.env` happens to hold is
122
+ * not a default anybody wants.
123
+ *
124
+ * ## `--no-env`
125
+ *
126
+ * Skips tier resolution and env-file loading: the caller's environment is the
127
+ * environment. For `deploy write-env`, which CREATES the file tier resolution
128
+ * would otherwise insist on reading, and for the CI steps that hold one narrow
129
+ * credential in the job's own `env:` block (`preflight`, `plan`, `migrate`,
130
+ * `smoke`, `reconcile`, `planner status`).
131
+ *
132
+ * ## Staging and production ask first
133
+ *
134
+ * A command that is not read-only asks once before it runs against a hosted
135
+ * tier, `--yes` answers, and with no terminal (or `CI=true`) it refuses
136
+ * without `--yes` (`@devdogsuga/cli-core/safety-gate`).
137
+ */
138
+ /** The tier words a `--tier` or `DEPLOY_ENV` may carry. */
139
+ const SESSION_TIERS = [
140
+ "development",
141
+ "development:local",
142
+ "development:remote",
143
+ "staging",
144
+ "production"
145
+ ];
146
+ /** `--tier`, else `DEPLOY_ENV`, else plain development. Exported for the tests. */
147
+ function resolveSession(explicit, deployEnv = process.env.DEPLOY_ENV) {
148
+ const selector = explicit ?? nonEmpty(deployEnv);
149
+ if (selector === void 0) return {
150
+ ok: true,
151
+ session: {
152
+ tier: "development",
153
+ named: false
154
+ }
155
+ };
156
+ if (!SESSION_TIERS.includes(selector)) return {
157
+ ok: false,
158
+ reason: `unknown tier "${selector}". Expected: ${SESSION_TIERS.join(", ")}.`
159
+ };
160
+ const [tier, qualifier] = selector.split(":");
161
+ return {
162
+ ok: true,
163
+ session: qualifier === void 0 ? {
164
+ tier,
165
+ named: true
166
+ } : {
167
+ tier,
168
+ devDatabase: qualifier,
169
+ named: true
170
+ }
171
+ };
172
+ }
173
+ /**
174
+ * Whether the command `argv` names is declared `envFree`: it needs no env
175
+ * file and no tier (`graphics`, `qr`, `github`, `newsletter`), so the session
176
+ * behaves as if `--no-env` was typed. They run in a directory with no
177
+ * checkout and no `.env`, which is the point of `pnpm dlx`.
178
+ */
179
+ function isEnvFree(argv) {
180
+ const path = helpPath(argv);
181
+ for (let end = path.length; end > 0; end -= 1) {
182
+ const node = catalog.findCommand(path.slice(0, end));
183
+ if (node) return node.envFree === true;
184
+ }
185
+ return false;
186
+ }
187
+ function fail(label, message) {
188
+ process.stderr.write(`${label}: ${message}\n`);
189
+ process.exit(1);
190
+ }
191
+ /**
192
+ * Loads the session's env files onto `process.env`, or just names the tier
193
+ * under `--no-env`. Exits on a refusal: there is no command dispatched yet for
194
+ * a caller to fall back to.
195
+ */
196
+ async function enterSession(session, noEnv, label) {
197
+ if (noEnv) {
198
+ process.env.DEPLOY_ENV = session.tier;
199
+ if (session.devDatabase !== void 0) process.env.DEV_DB = session.devDatabase;
200
+ return;
201
+ }
202
+ if (discoverRepoRoot() === null) fail(label, new RepoNotFoundError().message);
203
+ const envLoad = await loadEnvLoad();
204
+ const envSession = await loadEnvSession();
205
+ process.env[envLoad.SHELL_KEYS_ENV] ??= Object.keys(process.env).join(",");
206
+ try {
207
+ const entered = await envSession.enterEnvironment(session.tier, {
208
+ override: false,
209
+ devDatabase: session.devDatabase
210
+ });
211
+ for (const warning of entered.warnings) process.stderr.write(`${label}: ${warning}\n`);
212
+ process.stderr.write(`${label}: loaded ${entered.files.length > 0 ? entered.files.join(", ") : "no env files"} (${session.tier})\n`);
213
+ } catch (err) {
214
+ if (!(err instanceof envLoad.MissingEnvFileError)) throw err;
215
+ if (session.tier !== "development") fail(label, err.message);
216
+ process.stderr.write(`${label}: ${err.message}\n`);
217
+ }
218
+ }
219
+ /**
220
+ * Runs `proceed` only if the hosted-tier gate lets the command through. A
221
+ * command the gate stops exits non-zero and returns `blocked`.
222
+ */
223
+ async function gated(session, commandArgv, proceed, blocked, enabled) {
224
+ if (!enabled || isDryRun() || catalog.findCommand(helpPath(commandArgv))?.dryRun === "read-only") return proceed();
225
+ if (!(await gateHostedTier({
226
+ tier: session.tier,
227
+ projectRef: nonEmpty(process.env.PROJECT_REF),
228
+ argv: commandArgv,
229
+ yes: hasYes(commandArgv),
230
+ nonInteractive: isNonInteractive()
231
+ })).proceed) {
232
+ process.exitCode = 1;
233
+ return blocked;
234
+ }
235
+ process.env[GATE_PASSED_ENV] = session.tier;
236
+ return proceed();
237
+ }
238
+ /**
239
+ * The refusal for a `deploy` command run with no tier named, if it applies.
240
+ * Under `--no-env` the caller supplied the environment, credentials and all,
241
+ * so there is no env file for the tier to choose and nothing to default.
242
+ */
243
+ function missingTier(argv, session, noEnv) {
244
+ if (argv[0] !== "deploy" || session.named || noEnv) return null;
245
+ return "no tier named. A deploy command must be told which environment it is for: pass --tier <staging|production> or set DEPLOY_ENV.";
246
+ }
247
+ /**
248
+ * Resolves the session's tier, enters it, and hands off to `options.dispatch`.
249
+ */
250
+ async function launchWith(argv, options) {
251
+ setCliName("backstage");
252
+ const label = "backstage";
253
+ const gate = options.gate ?? true;
254
+ const { noEnv: typedNoEnv, rest: withoutNoEnv } = stripNoEnvFlag(argv);
255
+ const { explicit, rest: withoutTier } = stripTierFlag(withoutNoEnv);
256
+ const { dryRun, rest } = resolveDryRun(withoutTier);
257
+ const noEnv = typedNoEnv || isEnvFree(rest);
258
+ setNoEnv(noEnv);
259
+ setDryRun(dryRun);
260
+ installFailureLog({
261
+ argv,
262
+ eventId: lastSentryEventId
263
+ });
264
+ ignoreClosedPipes();
265
+ initDevtoolsTelemetry(rest.slice(0, 2).join(" ") || "menu");
266
+ const dispatch = async (args) => {
267
+ try {
268
+ await options.dispatch(args);
269
+ } catch (err) {
270
+ process.stderr.write(`${label}: ${errorMessage(err)}\n`);
271
+ await captureDevtoolsError(err);
272
+ process.exit(1);
273
+ }
274
+ };
275
+ if (rest.includes("--help") || rest.includes("-h") || rest[0] === "version" || rest[0] === "completions") {
276
+ await dispatch(rest);
277
+ return;
278
+ }
279
+ const name = helpPath(rest)[0];
280
+ if (name !== void 0 && catalog.findCommand([name]) === null) {
281
+ await dispatch(rest);
282
+ return;
283
+ }
284
+ const resolved = resolveSession(explicit);
285
+ if (!resolved.ok) fail(label, resolved.reason);
286
+ const { session } = resolved;
287
+ if (rest.length === 0 || process.stdin.isTTY === true && bareGroupStartPath(catalog, rest) !== null) {
288
+ setMenuEnvHook(async (commandArgv, dispatchCommand) => {
289
+ const chosen = resolveSession(stripTierFlag(commandArgv).explicit);
290
+ if (!chosen.ok) {
291
+ process.stderr.write(`${label}: ${chosen.reason}\n`);
292
+ process.exitCode = 1;
293
+ return null;
294
+ }
295
+ const skipEnv = noEnv || isEnvFree(commandArgv);
296
+ setNoEnv(skipEnv);
297
+ const refusal = missingTier(commandArgv, chosen.session, skipEnv);
298
+ if (refusal) {
299
+ process.stderr.write(`${label}: ${refusal}\n`);
300
+ process.exitCode = 1;
301
+ return null;
302
+ }
303
+ await enterSession(chosen.session, skipEnv, label);
304
+ return gated(chosen.session, commandArgv, dispatchCommand, null, gate);
305
+ });
306
+ await dispatch(rest);
307
+ return;
308
+ }
309
+ const refusal = missingTier(rest, session, noEnv);
310
+ if (refusal) fail(label, refusal);
311
+ await enterSession(session, noEnv, label);
312
+ await gated(session, rest, () => dispatch(rest), void 0, gate);
313
+ }
314
+ //#endregion
315
+ //#region src/launch.ts
316
+ /**
317
+ * `backstage`'s entry point, run by the `bin/backstage.mjs` bootstrap. All of
318
+ * the launching is in `launch-core.ts`; this file only says which dispatcher
319
+ * runs the command.
320
+ */
321
+ async function launch(argv) {
322
+ await launchWith(argv, { dispatch: async (args) => {
323
+ const { main } = await import("./cli-YLCe3Zrx.js");
324
+ await main(args);
325
+ } });
326
+ }
327
+ if (import.meta.url === `file://${process.argv[1]}`) await launch(process.argv.slice(2));
328
+ //#endregion
329
+ export { launch };
@@ -0,0 +1,183 @@
1
+ import { T as cliName } from "./telemetry-Bjoz29Hl.js";
2
+ //#region ../cli-core/src/dry-run.ts
3
+ const DRY_RUN_FLAG = "--dry-run";
4
+ /** Commands that forward everything after their name to another tool. */
5
+ const FORWARDING = /* @__PURE__ */ new Set([
6
+ "supabase",
7
+ "wrangler",
8
+ "drizzle-kit",
9
+ "psql",
10
+ "bw",
11
+ "run"
12
+ ]);
13
+ let active = false;
14
+ function setDryRun(on) {
15
+ active = on;
16
+ }
17
+ function isDryRun() {
18
+ return active;
19
+ }
20
+ /**
21
+ * Decides whether `argv` asks for a dry run and returns the argv to dispatch.
22
+ *
23
+ * For a forwarding command a leading `--dry-run` is consumed (the tool never
24
+ * sees it) and a later one is the tool's. For any other command the argv is
25
+ * returned as it came, because several commands read `--dry-run` themselves.
26
+ */
27
+ function resolveDryRun(argv) {
28
+ const firstPositional = argv.findIndex((arg) => !arg.startsWith("-"));
29
+ const lead = firstPositional === -1 ? argv.length : firstPositional;
30
+ const command = argv[firstPositional];
31
+ if (command !== void 0 && FORWARDING.has(command)) {
32
+ const rest = argv.filter((arg, i) => i >= lead || arg !== "--dry-run");
33
+ return {
34
+ dryRun: rest.length !== argv.length,
35
+ rest
36
+ };
37
+ }
38
+ return {
39
+ dryRun: argv.includes(DRY_RUN_FLAG),
40
+ rest: [...argv]
41
+ };
42
+ }
43
+ /**
44
+ * How a command treats `--dry-run`, from the nearest node on its path that
45
+ * says. `undefined` means it did not say, and the dispatcher stops it.
46
+ */
47
+ function dryRunKind(catalog, path) {
48
+ for (let end = path.length; end > 0; end -= 1) {
49
+ const kind = catalog.findCommand(path.slice(0, end))?.dryRun;
50
+ if (kind !== void 0) return kind;
51
+ }
52
+ }
53
+ /** The command line a stopped command would have run. */
54
+ function wouldRunLine(argv) {
55
+ return `Would run: ${cliName()} ${argv.join(" ")}`;
56
+ }
57
+ const QR_FLAGS = {
58
+ text: {
59
+ name: "text",
60
+ value: "<text>",
61
+ summary: "What to encode: a URL or any text. May be given as the argument."
62
+ },
63
+ theme: {
64
+ name: "theme",
65
+ value: `<${[
66
+ "devdogs-light",
67
+ "devdogs-dark",
68
+ "acm-light",
69
+ "acm-dark"
70
+ ].join("|")}>`,
71
+ summary: "Logo, shape and ink colour in one go, as the console's theme buttons."
72
+ },
73
+ size: {
74
+ name: "size",
75
+ value: "<px>",
76
+ summary: "Width and height of the image in pixels. Defaults to 999."
77
+ },
78
+ margin: {
79
+ name: "margin",
80
+ value: "<modules>",
81
+ summary: "Quiet zone around the code, in modules. Defaults to 2."
82
+ },
83
+ color: {
84
+ name: "color",
85
+ value: "<css colour>",
86
+ summary: "Ink colour. Defaults to the theme's."
87
+ },
88
+ background: {
89
+ name: "background",
90
+ value: "<css colour|transparent>",
91
+ summary: "Background colour. Transparent by default."
92
+ },
93
+ gradient: {
94
+ name: "gradient",
95
+ value: "<from,to>",
96
+ summary: "Two-colour diagonal gradient for the ink, instead of --color."
97
+ },
98
+ shape: {
99
+ name: "shape",
100
+ value: "<rounded|square>",
101
+ summary: "Module shape. Defaults to the theme's."
102
+ },
103
+ logo: {
104
+ name: "logo",
105
+ value: "<preset|none|file>",
106
+ summary: `Centre logo: a preset (${[
107
+ "devdogs",
108
+ "acm",
109
+ "discord-white",
110
+ "discord-black",
111
+ "discord-blurple"
112
+ ].join(", ")}), none, or an image file path.`
113
+ },
114
+ logoSize: {
115
+ name: "logo-size",
116
+ value: "<modules>",
117
+ summary: "Logo width in modules. Defaults to 9."
118
+ },
119
+ logoPadding: {
120
+ name: "logo-padding",
121
+ value: "<modules>",
122
+ summary: "Clear space around the logo, in modules. Defaults to 1."
123
+ },
124
+ errorLevel: {
125
+ name: "error-level",
126
+ value: `<${[
127
+ "L",
128
+ "M",
129
+ "Q",
130
+ "H"
131
+ ].join("|")}>`,
132
+ summary: "Error-correction level. Defaults to H."
133
+ },
134
+ version: {
135
+ name: "qr-version",
136
+ value: "<1-40>",
137
+ summary: "Force a QR version (grid size). Defaults to the smallest that fits."
138
+ },
139
+ formats: {
140
+ name: "format",
141
+ value: `<${[
142
+ "svg",
143
+ "png",
144
+ "jpg",
145
+ "webp",
146
+ "avif",
147
+ "tiff"
148
+ ].join(",")}>`,
149
+ summary: "Output formats, comma-separated, several at once. Defaults to svg,png."
150
+ }
151
+ };
152
+ /** CLI-only flags: the crop of a logo file, and where the files go. */
153
+ const QR_EXTRA_FLAGS = {
154
+ logoCrop: {
155
+ name: "logo-crop",
156
+ value: "<x,y,width,height>",
157
+ summary: "Crop a logo file to this region (source pixels) before it goes in the code."
158
+ },
159
+ out: {
160
+ name: "out",
161
+ value: "<dir>",
162
+ summary: "Directory for the files. Defaults to the current directory."
163
+ },
164
+ name: {
165
+ name: "name",
166
+ value: "<stem>",
167
+ summary: "File name without extension. Defaults to qr."
168
+ }
169
+ };
170
+ /** The flags as the command catalog declares them (help, completions, menu). */
171
+ function qrCatalogOptions() {
172
+ return [...Object.values(QR_FLAGS), ...Object.values(QR_EXTRA_FLAGS)].map((flag) => ({
173
+ flag: `--${flag.name}`,
174
+ value: flag.value,
175
+ summary: flag.summary
176
+ }));
177
+ }
178
+ /** Every flag name `parseArgs` should accept, all string-valued. */
179
+ function qrParseOptions() {
180
+ return Object.fromEntries([...Object.values(QR_FLAGS), ...Object.values(QR_EXTRA_FLAGS)].map((flag) => [flag.name, { type: "string" }]));
181
+ }
182
+ //#endregion
183
+ export { dryRunKind as a, setDryRun as c, qrParseOptions as i, wouldRunLine as l, QR_FLAGS as n, isDryRun as o, qrCatalogOptions as r, resolveDryRun as s, QR_EXTRA_FLAGS as t };
@@ -0,0 +1,15 @@
1
+ //#region ../cli-core/src/repo/peer-redirect-hooks.ts
2
+ let redirects = [];
3
+ const initialize = (data) => {
4
+ redirects = data;
5
+ };
6
+ const resolve = (specifier, context, nextResolve) => {
7
+ const redirect = redirects.find((r) => r.specifier === specifier && r.parentURL === context.parentURL);
8
+ if (redirect) return {
9
+ url: redirect.url,
10
+ shortCircuit: true
11
+ };
12
+ return nextResolve(specifier, context);
13
+ };
14
+ //#endregion
15
+ export { initialize, resolve };