@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,545 @@
1
+ import { p as isNonInteractive } from "./telemetry-Bjoz29Hl.js";
2
+ import { a as explainError, o as unwrap, r as errorMessage, t as UsageError } from "./ui-CdKo8mLw.js";
3
+ import { n as positionals } from "./args-Cjr_Iqts.js";
4
+ import { t as DONE } from "./dispatch-D048O65I.js";
5
+ import { dirname, relative, resolve } from "node:path";
6
+ import { autocompleteMultiselect, log, multiselect, note } from "@clack/prompts";
7
+ import { mkdir, writeFile } from "node:fs/promises";
8
+ import { homedir } from "node:os";
9
+ import { APPS, AppIcon, Banner, EmailSignature, EventCard, FORMATS, OPAQUE_FORMATS, PageCard, THEME } from "@devdogsuga/brand";
10
+ import { EVENT_SEGMENT_VISUALS, meetingCardDetail, meetingLocation } from "@devdogsuga/brand/event";
11
+ import { getClubConfig } from "@devdogsuga/events";
12
+ import { render as render$1 } from "@devdogsuga/brand/render";
13
+ //#region src/graphics/events.ts
14
+ /**
15
+ * Builds the reader from `@devdogsuga/events`'s committed data file.
16
+ *
17
+ * `Meeting.id` is the card's `slug` and `Meeting.title` is its authored name
18
+ * (`nameOverride` on the platform's meetings table).
19
+ */
20
+ function configEvents() {
21
+ return { async meetings() {
22
+ return [...getClubConfig().meetings].sort((a, b) => Date.parse(b.startsAt) - Date.parse(a.startsAt)).map((row) => {
23
+ const meeting = {
24
+ slug: row.id,
25
+ startsAt: new Date(row.startsAt),
26
+ endsAt: new Date(row.endsAt),
27
+ building: row.building,
28
+ location: row.location,
29
+ kind: row.kind,
30
+ cancelledAt: row.cancelledAt ? new Date(row.cancelledAt) : null,
31
+ cancellationReason: row.cancellationReason
32
+ };
33
+ const items = row.agenda.map((workshop) => ({
34
+ label: workshopLabel(workshop),
35
+ segment: "workshop"
36
+ }));
37
+ const detail = meetingCardDetail({
38
+ meeting,
39
+ title: meetingTitle(row.title, row.kind, meeting.startsAt, items.map((item) => item.label)),
40
+ agenda: items.map((item) => `${EVENT_SEGMENT_VISUALS[item.segment].label}: ${item.label}`),
41
+ location: meetingLocation(row.building, row.location)
42
+ });
43
+ return {
44
+ slug: row.id,
45
+ hint: describeMeeting(meeting),
46
+ detail,
47
+ items: items.map((item) => {
48
+ const visual = EVENT_SEGMENT_VISUALS[item.segment];
49
+ return {
50
+ stem: slugPart(item.label),
51
+ detail: {
52
+ ...detail,
53
+ title: item.label,
54
+ agenda: void 0,
55
+ badge: {
56
+ label: visual.label,
57
+ accent: visual.accent
58
+ }
59
+ }
60
+ };
61
+ })
62
+ };
63
+ });
64
+ } };
65
+ }
66
+ /** A readable path component for a workshop card. */
67
+ function slugPart(label) {
68
+ return label.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "agenda-item";
69
+ }
70
+ /** `Workshop.title` is non-nullable in the config schema (`workshopSchema`). */
71
+ function workshopLabel(row) {
72
+ return row.title;
73
+ }
74
+ /**
75
+ * The meeting's name, in descending order of how much somebody meant it.
76
+ *
77
+ * Mirrors `apps/platform/src/lib/meetingTitle.ts`: an authored name, then the
78
+ * kind, then the workshops it teaches, then the date. Two named workshops read
79
+ * as a heading; three do not, and the date carries it while the agenda below
80
+ * shows the detail.
81
+ *
82
+ * Duplicated rather than imported because it lives in the Next app, which this
83
+ * package cannot depend on. `graphics.test.ts` pins the chain so a change to one
84
+ * shows up as a failure rather than as a differently-titled poster.
85
+ */
86
+ function meetingTitle(nameOverride, kind, startsAt, agenda) {
87
+ if (nameOverride !== null) return nameOverride;
88
+ if (kind !== null) return kind;
89
+ if (agenda.length === 1) return `Workshop: ${agenda[0]}`;
90
+ if (agenda.length === 2) return `Workshop: ${agenda[0]} & ${agenda[1]}`;
91
+ return startsAt.toLocaleDateString("en-US", {
92
+ timeZone: "America/New_York",
93
+ dateStyle: "long"
94
+ });
95
+ }
96
+ /** One line for the picker: when it is, and whether it is still on. */
97
+ function describeMeeting(meeting) {
98
+ const when = meeting.startsAt.toLocaleDateString("en-US", {
99
+ timeZone: "America/New_York",
100
+ dateStyle: "medium"
101
+ });
102
+ return meeting.cancelledAt === null ? when : `${when}, cancelled`;
103
+ }
104
+ //#endregion
105
+ //#region src/graphics/select.ts
106
+ /**
107
+ * Resolves graphic patterns.
108
+ *
109
+ * Every graphic is `group/name`, and three shapes of pattern read against
110
+ * that: a lone star (or a star over a star) is everything, `group/` plus a star
111
+ * is a whole group, and anything else is an exact name. Deliberately not a
112
+ * general glob — `event/2026-` plus a star looks like it should work and would
113
+ * be one more thing to specify, and the picker covers the case where somebody
114
+ * does not know the exact name.
115
+ *
116
+ * A bare group is accepted as shorthand for `group/*`, because `graphics app`
117
+ * is what people type and refusing it teaches nothing.
118
+ */
119
+ function matchGraphics(patterns, graphics) {
120
+ const matched = /* @__PURE__ */ new Map();
121
+ const unmatched = [];
122
+ const groups = new Set(graphics.map((graphic) => graphic.group));
123
+ for (const pattern of patterns) {
124
+ const hits = graphics.filter((graphic) => {
125
+ if (pattern === "*" || pattern === "*/*") return true;
126
+ if (pattern.endsWith("/*")) return graphic.group === pattern.slice(0, -2);
127
+ if (groups.has(pattern)) return graphic.group === pattern;
128
+ return graphic.name === pattern;
129
+ });
130
+ if (hits.length === 0) {
131
+ unmatched.push(pattern);
132
+ continue;
133
+ }
134
+ for (const hit of hits) matched.set(hit.name, hit);
135
+ }
136
+ return {
137
+ matched: [...matched.values()],
138
+ unmatched
139
+ };
140
+ }
141
+ /**
142
+ * Pairs each graphic with each requested format it actually supports.
143
+ *
144
+ * The matrix is sparse, and silently dropping a pair is the wrong answer in one
145
+ * direction only: asking for `*` at every format should quietly skip the
146
+ * combinations that do not exist, while asking for one graphic at one format it
147
+ * cannot do should say so. The caller decides which case it is; this reports
148
+ * both halves.
149
+ */
150
+ function pair(graphics, formats) {
151
+ const selections = [];
152
+ const unsupported = [];
153
+ for (const graphic of graphics) for (const name of formats) {
154
+ const format = FORMATS[name];
155
+ if (!format) continue;
156
+ if (graphic.formats.includes(name)) selections.push({
157
+ graphic,
158
+ format
159
+ });
160
+ else unsupported.push({
161
+ graphic: graphic.name,
162
+ format: name
163
+ });
164
+ }
165
+ return {
166
+ selections,
167
+ unsupported
168
+ };
169
+ }
170
+ /** Every format any of these graphics can be drawn at, in registry order. */
171
+ function formatsFor(graphics) {
172
+ const names = new Set(graphics.flatMap((graphic) => graphic.formats));
173
+ return Object.values(FORMATS).filter((format) => names.has(format.name));
174
+ }
175
+ /**
176
+ * Splits `--flag=value` into two tokens.
177
+ *
178
+ * The rest of this CLI reads flags as `--flag value`, and `positionals()`
179
+ * depends on that to tell a flag's value from a subcommand. `--format=a,b`
180
+ * arrives as one token, so it would be read as a positional — that is, as a
181
+ * graphic name — and `graphics --format=og` would try to render a graphic called
182
+ * `--format=og` and report nothing matched. Normalising here lets both spellings
183
+ * work without teaching the shared parser about either.
184
+ */
185
+ function normalizeArgv(argv) {
186
+ return argv.flatMap((token) => {
187
+ if (!token.startsWith("--")) return [token];
188
+ const eq = token.indexOf("=");
189
+ return eq === -1 ? [token] : [token.slice(0, eq), token.slice(eq + 1)];
190
+ });
191
+ }
192
+ /** A comma-separated flag value, trimmed and emptied of blanks. */
193
+ function commaList(value) {
194
+ return (value ?? "").split(",").map((part) => part.trim()).filter(Boolean);
195
+ }
196
+ //#endregion
197
+ //#region src/graphics/prompts.ts
198
+ /**
199
+ * What the command asks for when the command line did not say.
200
+ *
201
+ * Two questions, and each one is skipped entirely when a flag answered it, so
202
+ * a scripted invocation never blocks. That is the whole contract: this command
203
+ * has to be usable both as "make me the September 8 posters" typed by an
204
+ * officer who does not remember the format names, and as a line in a build.
205
+ *
206
+ * {@link requireInteractive} is what keeps the second half true. Without it a
207
+ * missing flag in CI would hang on a prompt nobody can answer until the job
208
+ * times out, which is the least debuggable failure available.
209
+ */
210
+ /** Refuses, naming the flag, rather than prompting where nobody can answer. */
211
+ function requireInteractive(what, flag) {
212
+ if (!isNonInteractive()) return;
213
+ throw new UsageError(`No terminal to ask which ${what} to use. Pass ${flag}: this is not an interactive shell.`);
214
+ }
215
+ /**
216
+ * Which graphics.
217
+ *
218
+ * Autocomplete rather than a plain list: there are dozens once a semester of
219
+ * meetings is loaded, and the useful interaction is typing "sep" or "dogdays"
220
+ * rather than scrolling. Grouped by prefix in the label so the list reads as
221
+ * the `group/name` vocabulary the flags use.
222
+ */
223
+ async function pickGraphics(graphics) {
224
+ requireInteractive("graphics", "a graphic name, or `*` for all of them");
225
+ const chosen = unwrap(await autocompleteMultiselect({
226
+ message: "Which images?",
227
+ options: graphics.map((graphic) => ({
228
+ value: graphic.name,
229
+ label: graphic.name,
230
+ hint: graphic.why
231
+ }))
232
+ }));
233
+ return graphics.filter((graphic) => chosen.includes(graphic.name));
234
+ }
235
+ /**
236
+ * Which formats.
237
+ *
238
+ * Offered from the union of what the chosen graphics support, so the list never
239
+ * contains a size that would be skipped — asking somebody to pick `savvycal`
240
+ * for an event and then quietly not rendering it is worse than not offering it.
241
+ */
242
+ async function pickFormats(graphics) {
243
+ requireInteractive("formats", "--format or --all-formats");
244
+ const available = formatsFor(graphics);
245
+ return unwrap(await multiselect({
246
+ message: "Which formats?",
247
+ options: available.map((format) => ({
248
+ value: format.name,
249
+ label: format.name,
250
+ hint: format.why
251
+ })),
252
+ initialValues: available.some((format) => format.name === "og") ? ["og"] : []
253
+ }));
254
+ }
255
+ //#endregion
256
+ //#region src/graphics/registry.ts
257
+ /** The two renditions anything may need, because anything may reach the GDG platform. */
258
+ const GDG = ["gdgc-wide", "gdgc-square"];
259
+ const EVENT_FORMATS = [
260
+ "og",
261
+ ...GDG,
262
+ "involvement-network"
263
+ ];
264
+ const EMAIL = [
265
+ "email-1x",
266
+ "email-2x",
267
+ "email-3x"
268
+ ];
269
+ const ICON_SIZES = [
270
+ 16,
271
+ 32,
272
+ 48,
273
+ 64,
274
+ 96,
275
+ 128,
276
+ 180,
277
+ 192,
278
+ 256,
279
+ 384,
280
+ 512,
281
+ 1024
282
+ ];
283
+ /**
284
+ * The club lockup, on each of the two grounds it is ever set on.
285
+ *
286
+ * One subject, two grounds, and the rendition decides which template draws it:
287
+ * the email densities want the signature's tight horizontal strip, everything
288
+ * else wants the banner. That is why "the email signature" is not a graphic of
289
+ * its own: it is this lockup at `email-2x`, and its GDG export is the same
290
+ * lockup at `gdgc-square` rather than a second drawing that has to be kept in
291
+ * step with the first.
292
+ */
293
+ function brandGraphics() {
294
+ return ["dark", "light"].map((ground) => ({
295
+ name: ground === "dark" ? "brand/club" : "brand/club-light",
296
+ group: "brand",
297
+ stem: ground === "dark" ? "club" : "club-light",
298
+ formats: [
299
+ "og",
300
+ ...GDG,
301
+ ...ground === "dark" ? ["savvycal"] : [],
302
+ ...EMAIL
303
+ ],
304
+ why: `The DevDogs lockup with the GDG chapter cobrand, on a ${ground} ground.`,
305
+ render: (format) => format.family === "email" ? EmailSignature({
306
+ width: format.width,
307
+ ground
308
+ }) : Banner({
309
+ width: format.width,
310
+ height: format.height,
311
+ ground
312
+ })
313
+ }));
314
+ }
315
+ function appGraphics() {
316
+ return Object.keys(APPS).map((app) => {
317
+ const brand = APPS[app];
318
+ return {
319
+ name: `app/${app}`,
320
+ group: "app",
321
+ stem: app,
322
+ formats: [
323
+ ...ICON_SIZES.map((size) => `icon-${size}`),
324
+ "icon-favicon",
325
+ "icon-apple",
326
+ "og",
327
+ ...GDG
328
+ ],
329
+ why: `${brand.name}: its mark as an icon, and its card as a link.`,
330
+ render: (format) => format.family === "icon" ? AppIcon({
331
+ app,
332
+ size: format.width,
333
+ background: OPAQUE_FORMATS.has(format.name) ? THEME.background : void 0
334
+ }) : PageCard({
335
+ width: format.width,
336
+ height: format.height,
337
+ title: brand.name,
338
+ description: brand.blurb,
339
+ eyebrow: brand.tagline,
340
+ accent: brand.ground,
341
+ footer: brand.host,
342
+ cobrand: true
343
+ })
344
+ };
345
+ });
346
+ }
347
+ /**
348
+ * One graphic per meeting, and one per agenda item when a night has several.
349
+ *
350
+ * These are the reason the two axes were split at all: "this meeting" x "the
351
+ * GDG platform's banner" is a pairing nobody could have enumerated in advance.
352
+ */
353
+ function eventGraphics(events) {
354
+ return events.flatMap((event) => {
355
+ const meeting = {
356
+ name: `event/${event.slug}/meeting`,
357
+ group: "event",
358
+ stem: event.slug,
359
+ formats: EVENT_FORMATS,
360
+ why: `The ${event.slug} meeting: ${event.hint}.`,
361
+ render: (format) => EventCard({
362
+ width: format.width,
363
+ height: format.height,
364
+ cobrand: true,
365
+ ...event.detail
366
+ })
367
+ };
368
+ if ((event.items?.length ?? 0) < 2) return [meeting];
369
+ const occurrences = /* @__PURE__ */ new Map();
370
+ return [meeting, ...event.items.map((item) => {
371
+ const occurrence = (occurrences.get(item.stem) ?? 0) + 1;
372
+ occurrences.set(item.stem, occurrence);
373
+ const path = occurrence === 1 ? item.stem : `${item.stem}-${occurrence}`;
374
+ return {
375
+ name: `event/${event.slug}/${path}`,
376
+ group: "event",
377
+ stem: `${event.slug}-${path}`,
378
+ formats: EVENT_FORMATS,
379
+ why: `${item.detail.badge?.label ?? "Agenda item"}: ${item.detail.title} (${event.hint}).`,
380
+ render: (format) => EventCard({
381
+ width: format.width,
382
+ height: format.height,
383
+ cobrand: true,
384
+ ...item.detail
385
+ })
386
+ };
387
+ })];
388
+ });
389
+ }
390
+ /** Everything that does not depend on the schedule. */
391
+ function staticGraphics() {
392
+ return [...brandGraphics(), ...appGraphics()];
393
+ }
394
+ /**
395
+ * Every leaf is unique, so a flat output directory cannot silently overwrite
396
+ * one graphic's file with another's.
397
+ */
398
+ function assertUniqueStems(graphics) {
399
+ const seen = /* @__PURE__ */ new Map();
400
+ for (const graphic of graphics) {
401
+ const clash = seen.get(graphic.stem);
402
+ if (clash) throw new UsageError(`${graphic.name} and ${clash} share the leaf "${graphic.stem}", so a flat output directory would overwrite one with the other.`);
403
+ seen.set(graphic.stem, graphic.name);
404
+ }
405
+ }
406
+ //#endregion
407
+ //#region src/graphics/commands.ts
408
+ /**
409
+ * `pnpm backstage graphics [graphic…] [--format …] [--out …]`
410
+ *
411
+ * Renders the club's pictures from the templates in `@devdogsuga/brand`. Two
412
+ * axes, asked for separately: WHICH picture (`event/2026-09-08`, `app/dogdays`,
413
+ * `brand/club`) and at WHAT SIZE (`gdgc-square`, `og`, `icon-512`). That split
414
+ * is the point of the command: an event poster for the GDG on Campus platform
415
+ * is exactly the pairing "this meeting" x "that platform's banner".
416
+ *
417
+ * Anything the command line leaves out, it asks for; anything it names, it does
418
+ * not. So an officer can type `pnpm backstage graphics` and be walked through
419
+ * it, and a script can pass every flag and never see a prompt.
420
+ *
421
+ * It needs no checkout and no credentials: the templates are in
422
+ * `@devdogsuga/brand`, the meetings in `@devdogsuga/events`, and the files go
423
+ * to `--out` or the current directory, flat, as `<stem>-<format>.png`.
424
+ */
425
+ function flagValue(argv, flag) {
426
+ const index = argv.indexOf(flag);
427
+ if (index === -1) return void 0;
428
+ const value = argv[index + 1];
429
+ return value && !value.startsWith("--") ? value : void 0;
430
+ }
431
+ function parseGraphicsArgs(argv) {
432
+ const normalized = normalizeArgv(argv);
433
+ return {
434
+ patterns: positionals(normalized),
435
+ formats: commaList(flagValue(normalized, "--format")),
436
+ allFormats: normalized.includes("--all-formats"),
437
+ out: flagValue(normalized, "--out"),
438
+ noOutput: normalized.includes("--dry-run") || normalized.includes("--no-output")
439
+ };
440
+ }
441
+ /** `~/images` when the shell did not expand it, which quoting prevents. */
442
+ function expandHome(path) {
443
+ return path === "~" || path.startsWith("~/") ? resolve(homedir(), path.slice(2)) : path;
444
+ }
445
+ /** Whether anything asked for could be an event, so whether to read the schedule. */
446
+ function wantsEvents(patterns) {
447
+ if (patterns.length === 0) return true;
448
+ return patterns.some((pattern) => pattern === "*" || pattern === "*/*" || pattern === "event" || pattern.startsWith("event/"));
449
+ }
450
+ /** Whether a pattern names events specifically, rather than sweeping them up. */
451
+ function namesEvents(patterns) {
452
+ return patterns.some((pattern) => pattern === "event" || pattern.startsWith("event/"));
453
+ }
454
+ /**
455
+ * Meetings, or a clear account of why there are none.
456
+ *
457
+ * A wildcard means "everything", and everything else is renderable, so an
458
+ * unreadable schedule is reported and skipped; `event/*` asked for exactly the
459
+ * thing that is missing, so it fails.
460
+ */
461
+ async function loadEvents(patterns, deps) {
462
+ const required = namesEvents(patterns);
463
+ const reader = (deps.reader ?? configEvents)();
464
+ let meetings;
465
+ try {
466
+ meetings = await reader.meetings();
467
+ } catch (err) {
468
+ if (required) throw err;
469
+ return {
470
+ graphics: [],
471
+ skipped: errorMessage(err)
472
+ };
473
+ }
474
+ return {
475
+ graphics: eventGraphics(meetings),
476
+ skipped: null
477
+ };
478
+ }
479
+ /** Where one selection is written: flat, always `<stem>-<format>.png`. */
480
+ function destinationOf({ graphic, format }, out) {
481
+ return resolve(out, `${graphic.stem}-${format.name}.png`);
482
+ }
483
+ async function runGraphics(argv, deps = {}) {
484
+ const options = parseGraphicsArgs(argv);
485
+ try {
486
+ await graphics(options, deps);
487
+ } catch (err) {
488
+ explainError("Could not render that.", err, ["pnpm backstage graphics # pick from a list", "pnpm backstage graphics 'event/*' --all-formats --out ~/images"]);
489
+ process.exitCode = 1;
490
+ }
491
+ }
492
+ async function graphics(options, deps) {
493
+ const cwd = deps.cwd ?? process.cwd();
494
+ const registry = staticGraphics();
495
+ let skipped = null;
496
+ if (wantsEvents(options.patterns)) {
497
+ const events = await loadEvents(options.patterns, deps);
498
+ registry.push(...events.graphics);
499
+ skipped = events.skipped;
500
+ }
501
+ assertUniqueStems(registry);
502
+ const chosen = options.patterns.length === 0 ? await pickGraphics(registry) : resolvePatterns(options.patterns, registry);
503
+ if (chosen.length === 0) throw new UsageError("Nothing to render.");
504
+ const requested = options.allFormats ? formatsFor(chosen).map((format) => format.name) : options.formats.length > 0 ? options.formats : await pickFormats(chosen);
505
+ const unknown = requested.filter((name) => !FORMATS[name]);
506
+ if (unknown.length > 0) throw new UsageError(`No format called ${unknown.join(", ")}. Try one of ${Object.keys(FORMATS).join(", ")}.`);
507
+ const { selections, unsupported } = pair(chosen, requested);
508
+ if (selections.length === 0 && unsupported.length > 0) throw new UsageError(unsupported.map((miss) => `${miss.graphic} has no ${miss.format} rendition`).join("; "));
509
+ if (selections.length === 0) throw new UsageError("Nothing to render.");
510
+ const out = resolve(cwd, options.out === void 0 || options.out === "" ? "." : expandHome(options.out));
511
+ const display = (file) => {
512
+ const rel = relative(cwd, file);
513
+ return rel.startsWith("..") ? file : rel;
514
+ };
515
+ if (skipped) log.warn(`event graphics skipped: ${skipped}`);
516
+ if (options.noOutput) {
517
+ note(selections.map((selection) => `${selection.graphic.name} ${selection.format.name}\n ${display(destinationOf(selection, out))}\n ${selection.format.why}`).join("\n"), `${selections.length} image${selections.length === 1 ? "" : "s"}`);
518
+ return;
519
+ }
520
+ let bytes = 0;
521
+ for (const selection of selections) {
522
+ const file = destinationOf(selection, out);
523
+ const { png, width, height } = await render$1(selection.graphic.render(selection.format), selection.format);
524
+ await mkdir(dirname(file), { recursive: true });
525
+ await writeFile(file, png);
526
+ bytes += png.length;
527
+ log.success(`${display(file)} ${width}x${height} (${Math.round(png.length / 1024)} KB)`);
528
+ }
529
+ log.info(`${selections.length} image${selections.length === 1 ? "" : "s"}, ${Math.round(bytes / 1024)} KB total.`);
530
+ }
531
+ function resolvePatterns(patterns, registry) {
532
+ const { matched, unmatched } = matchGraphics(patterns, registry);
533
+ if (unmatched.length === 0) return matched;
534
+ const askedForEvents = unmatched.some((pattern) => pattern === "event" || pattern.startsWith("event/"));
535
+ const haveEvents = registry.some((graphic) => graphic.group === "event");
536
+ if (askedForEvents && !haveEvents) throw new UsageError("The published @devdogsuga/events has no meetings, so there are no event images to render.");
537
+ const groups = [...new Set(registry.map((graphic) => `${graphic.group}/*`))];
538
+ throw new UsageError(`Nothing called ${unmatched.join(", ")}. Names are group/name. Try ${groups.join(", ")}, or * for all of them.`);
539
+ }
540
+ const handleGraphics = async (rest) => {
541
+ await runGraphics(rest);
542
+ return DONE;
543
+ };
544
+ //#endregion
545
+ export { handleGraphics };
@@ -0,0 +1,13 @@
1
+ //#region ../cli-core/src/dispatch.ts
2
+ /**
3
+ * What a CLI's dispatcher and its domains agree on.
4
+ *
5
+ * A domain's `commands.ts` exports one `CommandHandler` per top-level command
6
+ * it owns; the CLI's `cli.ts` maps command names to those handlers and does
7
+ * nothing else with them. The wizard builds an argv and hands it to the same
8
+ * dispatcher, so a menu walk and a typed command line take the identical path.
9
+ */
10
+ /** The `outro()` line for a command that finished. */
11
+ const DONE = "Done.";
12
+ //#endregion
13
+ export { DONE as t };