@devdogsuga/devtools 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.
Files changed (253) hide show
  1. package/README.md +44 -0
  2. package/bin/devtools-ci-bare.mjs +48 -0
  3. package/bin/devtools-ci.mjs +17 -0
  4. package/bin/devtools.mjs +27 -0
  5. package/dist/args.d.ts +26 -0
  6. package/dist/args.js +78 -0
  7. package/dist/bws/bw.d.ts +9 -0
  8. package/dist/bws/bw.js +49 -0
  9. package/dist/bws/client.d.ts +43 -0
  10. package/dist/bws/client.js +363 -0
  11. package/dist/bws/environments.d.ts +96 -0
  12. package/dist/bws/environments.js +111 -0
  13. package/dist/bws/pace.d.ts +27 -0
  14. package/dist/bws/pace.js +43 -0
  15. package/dist/bws/retry.d.ts +12 -0
  16. package/dist/bws/retry.js +39 -0
  17. package/dist/bws/store.d.ts +1 -0
  18. package/dist/bws/store.js +32 -0
  19. package/dist/bws/token.d.ts +31 -0
  20. package/dist/bws/token.js +88 -0
  21. package/dist/bws/vault.d.ts +29 -0
  22. package/dist/bws/vault.js +200 -0
  23. package/dist/catalog.d.ts +55 -0
  24. package/dist/catalog.js +81 -0
  25. package/dist/cf/commands.d.ts +1 -0
  26. package/dist/cf/commands.js +182 -0
  27. package/dist/cf/local-env.d.ts +44 -0
  28. package/dist/cf/local-env.js +148 -0
  29. package/dist/ci.d.ts +1 -0
  30. package/dist/ci.js +319 -0
  31. package/dist/cli.d.ts +11 -0
  32. package/dist/cli.js +1077 -0
  33. package/dist/commands.d.ts +255 -0
  34. package/dist/commands.js +1279 -0
  35. package/dist/completions.d.ts +4 -0
  36. package/dist/completions.js +110 -0
  37. package/dist/cron/commands.d.ts +47 -0
  38. package/dist/cron/commands.js +372 -0
  39. package/dist/cron/discovery.d.ts +55 -0
  40. package/dist/cron/discovery.js +85 -0
  41. package/dist/cron/schema.d.ts +45 -0
  42. package/dist/cron/schema.js +35 -0
  43. package/dist/db/config-push.d.ts +8 -0
  44. package/dist/db/config-push.js +11 -0
  45. package/dist/db/connection.d.ts +79 -0
  46. package/dist/db/connection.js +114 -0
  47. package/dist/db/exec.d.ts +1 -0
  48. package/dist/db/exec.js +11 -0
  49. package/dist/db/generate-migration.d.ts +1 -0
  50. package/dist/db/generate-migration.js +67 -0
  51. package/dist/db/generate-types.d.ts +1 -0
  52. package/dist/db/generate-types.js +4 -0
  53. package/dist/db/introspect.d.ts +1 -0
  54. package/dist/db/introspect.js +113 -0
  55. package/dist/db/local-env.d.ts +65 -0
  56. package/dist/db/local-env.js +23 -0
  57. package/dist/db/new-migration.d.ts +1 -0
  58. package/dist/db/new-migration.js +8 -0
  59. package/dist/db/run.d.ts +70 -0
  60. package/dist/db/run.js +158 -0
  61. package/dist/db/seed-buckets.d.ts +2 -0
  62. package/dist/db/seed-buckets.js +4 -0
  63. package/dist/db/seed-production.d.ts +8 -0
  64. package/dist/db/seed-production.js +73 -0
  65. package/dist/db/seed-roles.d.ts +7 -0
  66. package/dist/db/seed-roles.js +31 -0
  67. package/dist/db/session-refresh.d.ts +43 -0
  68. package/dist/db/session-refresh.js +40 -0
  69. package/dist/deploy/migrations.d.ts +20 -0
  70. package/dist/deploy/migrations.js +68 -0
  71. package/dist/deploy/orphans.d.ts +20 -0
  72. package/dist/deploy/orphans.js +177 -0
  73. package/dist/deploy/preflight.d.ts +53 -0
  74. package/dist/deploy/preflight.js +189 -0
  75. package/dist/deploy/report.d.ts +26 -0
  76. package/dist/deploy/report.js +70 -0
  77. package/dist/deploy/require-planner.d.ts +2 -0
  78. package/dist/deploy/require-planner.js +60 -0
  79. package/dist/deploy/require-token.d.ts +15 -0
  80. package/dist/deploy/require-token.js +78 -0
  81. package/dist/deploy/secrets-file.d.ts +17 -0
  82. package/dist/deploy/secrets-file.js +167 -0
  83. package/dist/deploy/write-env.d.ts +40 -0
  84. package/dist/deploy/write-env.js +393 -0
  85. package/dist/docs/index-pages.d.ts +36 -0
  86. package/dist/docs/index-pages.js +184 -0
  87. package/dist/doctor.d.ts +62 -0
  88. package/dist/doctor.js +225 -0
  89. package/dist/emails/commands.d.ts +15 -0
  90. package/dist/emails/commands.js +130 -0
  91. package/dist/emails/email-types.d.ts +13 -0
  92. package/dist/emails/email-types.js +1 -0
  93. package/dist/emails/fixtures.d.ts +20 -0
  94. package/dist/emails/fixtures.js +20 -0
  95. package/dist/env/audit.d.ts +206 -0
  96. package/dist/env/audit.js +436 -0
  97. package/dist/env/cloudflare.d.ts +21 -0
  98. package/dist/env/cloudflare.js +61 -0
  99. package/dist/env/commands.d.ts +99 -0
  100. package/dist/env/commands.js +612 -0
  101. package/dist/env/discovery.d.ts +15 -0
  102. package/dist/env/discovery.js +226 -0
  103. package/dist/env/document.d.ts +119 -0
  104. package/dist/env/document.js +323 -0
  105. package/dist/env/example.d.ts +68 -0
  106. package/dist/env/example.js +603 -0
  107. package/dist/env/selection.d.ts +105 -0
  108. package/dist/env/selection.js +226 -0
  109. package/dist/environment.d.ts +92 -0
  110. package/dist/environment.js +206 -0
  111. package/dist/fingerprint.d.ts +10 -0
  112. package/dist/fingerprint.js +18 -0
  113. package/dist/gen/campus-map.d.ts +1 -0
  114. package/dist/gen/campus-map.js +473 -0
  115. package/dist/gen/commands.d.ts +1 -0
  116. package/dist/gen/commands.js +25 -0
  117. package/dist/gen/hypno.d.ts +1 -0
  118. package/dist/gen/hypno.js +134 -0
  119. package/dist/gh/client.d.ts +80 -0
  120. package/dist/gh/client.js +258 -0
  121. package/dist/gh/environments.d.ts +83 -0
  122. package/dist/gh/environments.js +178 -0
  123. package/dist/gh/rulesets/actors.d.ts +14 -0
  124. package/dist/gh/rulesets/actors.js +68 -0
  125. package/dist/gh/rulesets/api.d.ts +15 -0
  126. package/dist/gh/rulesets/api.js +99 -0
  127. package/dist/gh/rulesets/commands.d.ts +3 -0
  128. package/dist/gh/rulesets/commands.js +203 -0
  129. package/dist/gh/rulesets/desired.d.ts +112 -0
  130. package/dist/gh/rulesets/desired.js +247 -0
  131. package/dist/gh/rulesets/diff.d.ts +65 -0
  132. package/dist/gh/rulesets/diff.js +134 -0
  133. package/dist/gh/rulesets/types.d.ts +76 -0
  134. package/dist/gh/rulesets/types.js +13 -0
  135. package/dist/gh/settings/api.d.ts +40 -0
  136. package/dist/gh/settings/api.js +172 -0
  137. package/dist/gh/settings/commands.d.ts +1 -0
  138. package/dist/gh/settings/commands.js +245 -0
  139. package/dist/gh/settings/desired.d.ts +53 -0
  140. package/dist/gh/settings/desired.js +39 -0
  141. package/dist/gh/settings/diff.d.ts +57 -0
  142. package/dist/gh/settings/diff.js +156 -0
  143. package/dist/gh/settings/types.d.ts +92 -0
  144. package/dist/gh/settings/types.js +11 -0
  145. package/dist/gh/settings/workflows.d.ts +41 -0
  146. package/dist/gh/settings/workflows.js +124 -0
  147. package/dist/grantRoot.d.ts +42 -0
  148. package/dist/grantRoot.js +89 -0
  149. package/dist/help.d.ts +18 -0
  150. package/dist/help.js +169 -0
  151. package/dist/images/commands.d.ts +35 -0
  152. package/dist/images/commands.js +233 -0
  153. package/dist/images/events.d.ts +36 -0
  154. package/dist/images/events.js +152 -0
  155. package/dist/images/graphics.d.ts +70 -0
  156. package/dist/images/graphics.js +233 -0
  157. package/dist/images/open-graph-types.d.ts +75 -0
  158. package/dist/images/open-graph-types.js +1 -0
  159. package/dist/images/prompts.d.ts +34 -0
  160. package/dist/images/prompts.js +100 -0
  161. package/dist/images/render.d.ts +21 -0
  162. package/dist/images/render.js +57 -0
  163. package/dist/images/select.d.ts +64 -0
  164. package/dist/images/select.js +96 -0
  165. package/dist/instance.d.ts +67 -0
  166. package/dist/instance.js +129 -0
  167. package/dist/invocation.d.ts +49 -0
  168. package/dist/invocation.js +78 -0
  169. package/dist/launch-ci.d.ts +1 -0
  170. package/dist/launch-ci.js +86 -0
  171. package/dist/launch.d.ts +29 -0
  172. package/dist/launch.js +371 -0
  173. package/dist/menu.d.ts +31 -0
  174. package/dist/menu.js +277 -0
  175. package/dist/newsletter/commands.d.ts +16 -0
  176. package/dist/newsletter/commands.js +311 -0
  177. package/dist/newsletter/imap.d.ts +17 -0
  178. package/dist/newsletter/imap.js +127 -0
  179. package/dist/newsletter/loopback.d.ts +24 -0
  180. package/dist/newsletter/loopback.js +100 -0
  181. package/dist/newsletter/oauth.d.ts +26 -0
  182. package/dist/newsletter/oauth.js +130 -0
  183. package/dist/newsletter/smtp.d.ts +23 -0
  184. package/dist/newsletter/smtp.js +133 -0
  185. package/dist/oauth/browser.d.ts +1 -0
  186. package/dist/oauth/browser.js +13 -0
  187. package/dist/oauth/config.d.ts +24 -0
  188. package/dist/oauth/config.js +24 -0
  189. package/dist/oauth/connect-url.d.ts +17 -0
  190. package/dist/oauth/connect-url.js +23 -0
  191. package/dist/oauth/db.d.ts +37 -0
  192. package/dist/oauth/db.js +108 -0
  193. package/dist/oauth/device.d.ts +93 -0
  194. package/dist/oauth/device.js +190 -0
  195. package/dist/oauth/discovery.d.ts +28 -0
  196. package/dist/oauth/discovery.js +54 -0
  197. package/dist/oauth/env-file.d.ts +6 -0
  198. package/dist/oauth/env-file.js +35 -0
  199. package/dist/oauth/exchange.d.ts +43 -0
  200. package/dist/oauth/exchange.js +87 -0
  201. package/dist/oauth/label.d.ts +1 -0
  202. package/dist/oauth/label.js +25 -0
  203. package/dist/oauth/loopback.d.ts +52 -0
  204. package/dist/oauth/loopback.js +162 -0
  205. package/dist/oauth/pkce.d.ts +6 -0
  206. package/dist/oauth/pkce.js +34 -0
  207. package/dist/oauth/target.d.ts +54 -0
  208. package/dist/oauth/target.js +102 -0
  209. package/dist/oauth/transport.d.ts +41 -0
  210. package/dist/oauth/transport.js +32 -0
  211. package/dist/oauth/wizard.d.ts +33 -0
  212. package/dist/oauth/wizard.js +571 -0
  213. package/dist/pick.d.ts +6 -0
  214. package/dist/pick.js +64 -0
  215. package/dist/planner/checks.d.ts +26 -0
  216. package/dist/planner/checks.js +104 -0
  217. package/dist/planner/commands.d.ts +10 -0
  218. package/dist/planner/commands.js +367 -0
  219. package/dist/planner/db.d.ts +15 -0
  220. package/dist/planner/db.js +33 -0
  221. package/dist/planner/role.d.ts +33 -0
  222. package/dist/planner/role.js +68 -0
  223. package/dist/repo/peers.d.ts +24 -0
  224. package/dist/repo/peers.js +114 -0
  225. package/dist/repo/preflight.d.ts +109 -0
  226. package/dist/repo/preflight.js +266 -0
  227. package/dist/repo/resolve.d.ts +41 -0
  228. package/dist/repo/resolve.js +152 -0
  229. package/dist/repo/root.d.ts +23 -0
  230. package/dist/repo/root.js +91 -0
  231. package/dist/repo/source.d.ts +14 -0
  232. package/dist/repo/source.js +54 -0
  233. package/dist/repo/tsx-loader.d.ts +6 -0
  234. package/dist/repo/tsx-loader.js +41 -0
  235. package/dist/run/pick.d.ts +60 -0
  236. package/dist/run/pick.js +559 -0
  237. package/dist/setup.d.ts +1 -0
  238. package/dist/setup.js +152 -0
  239. package/dist/stack.d.ts +48 -0
  240. package/dist/stack.js +290 -0
  241. package/dist/telemetry.d.ts +34 -0
  242. package/dist/telemetry.js +93 -0
  243. package/dist/tier.d.ts +40 -0
  244. package/dist/tier.js +93 -0
  245. package/dist/tsconfig.typecheck.tsbuildinfo +1 -0
  246. package/dist/ui.d.ts +25 -0
  247. package/dist/ui.js +51 -0
  248. package/dist/workers.d.ts +5 -0
  249. package/dist/workers.js +35 -0
  250. package/dist/workflows/commands.d.ts +44 -0
  251. package/dist/workflows/commands.js +668 -0
  252. package/env.ts +158 -0
  253. package/package.json +80 -0
package/dist/help.js ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * `--help`, one level at a time.
3
+ *
4
+ * ## Why this is small
5
+ *
6
+ * The help this replaced was ~200 lines and printed the whole tree: every
7
+ * subcommand of every group, the Bitwarden target table, the four places an
8
+ * access token is looked for, the grants `migration_planner` holds, and which
9
+ * deploy steps must avoid the `with-env` wrapper. A contributor running
10
+ * `pnpm devtools --help` to find out how to start a database read all of it.
11
+ *
12
+ * Two rules now:
13
+ *
14
+ * 1. **A level prints its own children and stops.** `--help` lists the
15
+ * top-level commands; `env --help` lists env's subcommands; `env pull
16
+ * --help` lists that command's options. Depth is reached by asking for
17
+ * it.
18
+ * 2. **No more than the caller needs to choose.** Operator internals are
19
+ * `docs/`'s job: which project a target maps to, how a credential is
20
+ * resolved, what a deploy job's environment holds. Help says what a
21
+ * command does and what it takes.
22
+ *
23
+ * Both fall out of rendering `commands.ts` rather than hand-writing prose, so
24
+ * neither can rot back into a wall of text one paragraph at a time.
25
+ */
26
+ import { findCommand, GROUPS, SCOPES, } from "./commands.js";
27
+ const INDENT = " ";
28
+ /** `--target <t>`, or `--prune` for a boolean. */
29
+ function optionLabel(option) {
30
+ return option.value ? `${option.flag} ${option.value}` : option.flag;
31
+ }
32
+ /**
33
+ * Two columns, with the gutter sized to the widest label in THIS block.
34
+ *
35
+ * Per-block rather than one width for the whole file: `deploy`'s labels are
36
+ * long and `setup`'s are not, and a shared width would indent the short list
37
+ * halfway across the terminal to accommodate a group the reader is not
38
+ * looking at.
39
+ */
40
+ function columns(rows) {
41
+ const width = Math.max(...rows.map(([label]) => label.length));
42
+ return rows.map(([label, text]) => `${INDENT}${label.padEnd(width)} ${text}`);
43
+ }
44
+ /**
45
+ * A list of commands, split into scope blocks when any of them declares one.
46
+ *
47
+ * Used at two levels: a GROUP's own commands (no group has scoped commands
48
+ * today — `db` folded the old "Supabase" group's scopes into itself), and a
49
+ * command's own SUBCOMMANDS (`db`'s: `start`/`connect`/`stop`/`restart` on
50
+ * this machine, `migration` in the repo, `status`/`migrate`/`reset`/… on an
51
+ * endpoint, `planner`/`signing-key` naming their own connection). Either way,
52
+ * a flat list leaves the reader to work out which of `restart` and `reset` is
53
+ * which, the distinction that costs people an afternoon. A list with no
54
+ * scopes renders as it always did: one block, one indent, no headings.
55
+ *
56
+ * The blocks follow declaration order rather than `SCOPES` order, so the tree
57
+ * stays the one place that decides how the list reads. The gutter is sized
58
+ * across the whole list, not per block, so every block lines up with the
59
+ * others rather than each finding its own column.
60
+ */
61
+ function scopedBody(commands) {
62
+ if (!commands.some((command) => command.scope)) {
63
+ return columns(childRows(commands));
64
+ }
65
+ const width = Math.max(...commands.map((command) => command.name.length));
66
+ const lines = [];
67
+ let open;
68
+ for (const command of commands) {
69
+ if (command.scope && command.scope !== open) {
70
+ open = command.scope;
71
+ lines.push(`${INDENT}${SCOPES[open].help}:`);
72
+ }
73
+ lines.push(`${INDENT.repeat(2)}${command.name.padEnd(width)} ${command.summary}`);
74
+ }
75
+ return lines;
76
+ }
77
+ function optionRows(options) {
78
+ return options.map((option) => [optionLabel(option), option.summary]);
79
+ }
80
+ function childRows(children) {
81
+ return children.map((child) => [child.name, child.summary]);
82
+ }
83
+ // ── The three levels ─────────────────────────────────────────────────────────
84
+ /** `pnpm devtools --help`: the groups, and nothing below them. */
85
+ function renderRoot() {
86
+ const lines = [
87
+ "pnpm devtools [command] [options]",
88
+ "",
89
+ "Run with no command to choose from a menu.",
90
+ "",
91
+ "Common tasks:",
92
+ ...columns([
93
+ ["setup", "Prepare a new checkout"],
94
+ ["run dev", "Start development servers"],
95
+ ["db start", "Start Supabase on this machine"],
96
+ ["db reset", "Rebuild the local database"],
97
+ ["cron run", "Choose and run a scheduled job"],
98
+ ]),
99
+ ];
100
+ for (const group of GROUPS) {
101
+ lines.push("", `${group.title}:`, ...scopedBody(group.commands));
102
+ }
103
+ lines.push("", "Options:", ...columns([
104
+ ["--help, -h", "Show this message"],
105
+ [
106
+ "--tier <t>",
107
+ "Deploy tier for this whole invocation (development, staging, production)",
108
+ ],
109
+ ]), "", "`pnpm devtools <command> --help` shows what that command takes.");
110
+ return lines.join("\n");
111
+ }
112
+ /** `pnpm devtools <path…> --help`: one command's own children and options. */
113
+ function renderCommand(path, node) {
114
+ const children = node.subcommands ?? [];
115
+ const options = node.options ?? [];
116
+ const trail = path.join(" ");
117
+ const usage = [
118
+ "pnpm devtools",
119
+ trail,
120
+ children.length > 0 ? "<subcommand>" : "",
121
+ options.length > 0 ? "[options]" : "",
122
+ ]
123
+ .filter(Boolean)
124
+ .join(" ");
125
+ const lines = [usage, "", node.summary];
126
+ if (children.length > 0) {
127
+ lines.push("", "Subcommands:", ...scopedBody(children));
128
+ }
129
+ if (options.length > 0) {
130
+ lines.push("", "Options:", ...columns(optionRows(options)));
131
+ }
132
+ if (children.length > 0) {
133
+ lines.push("", `\`pnpm devtools ${trail} <subcommand> --help\` shows what that one takes.`);
134
+ }
135
+ return lines.join("\n");
136
+ }
137
+ /**
138
+ * Renders help for a path, falling back to the root.
139
+ *
140
+ * An unknown path renders the root rather than an error: this is only reached
141
+ * when `--help` was asked for, and answering a mistyped command with the list
142
+ * it was mistyped from is more use than a refusal.
143
+ */
144
+ export function renderHelp(path = []) {
145
+ if (path.length === 0)
146
+ return renderRoot();
147
+ const node = findCommand(path);
148
+ if (!node)
149
+ return renderRoot();
150
+ return renderCommand(path, node);
151
+ }
152
+ /**
153
+ * The command names in `argv` that precede any flag.
154
+ *
155
+ * `env pull --help` asks about `["env", "pull"]`; `--help env` asks about
156
+ * nothing, because a flag's value is not a command path. Kept separate from
157
+ * `positionals()`: that one strips a known set of value-taking flags to find a
158
+ * subcommand, and here anything after the first flag is not part of the path at
159
+ * all.
160
+ */
161
+ export function helpPath(argv) {
162
+ const path = [];
163
+ for (const arg of argv) {
164
+ if (arg.startsWith("-"))
165
+ break;
166
+ path.push(arg);
167
+ }
168
+ return path;
169
+ }
@@ -0,0 +1,35 @@
1
+ import type { Format } from "./open-graph-types.js";
2
+ import { type Instance } from "../instance.js";
3
+ import { type EventReader } from "./events.js";
4
+ import { type Selection } from "./select.js";
5
+ export interface ImagesOptions {
6
+ /** Graphic patterns. Empty means ask. */
7
+ patterns: string[];
8
+ /** Format names. Empty with `allFormats` false means ask. */
9
+ formats: string[];
10
+ allFormats: boolean;
11
+ /** An explicit directory; everything lands in it, flat. */
12
+ out?: string;
13
+ /** Use each graphic's own default directory. */
14
+ defaultOut: boolean;
15
+ /** List what would be written, and write nothing. */
16
+ noOutput: boolean;
17
+ }
18
+ export declare function parseImagesArgs(argv: readonly string[]): ImagesOptions;
19
+ /** `~/images` when the shell did not expand it, which quoting prevents. */
20
+ export declare function expandHome(path: string): string;
21
+ /** Whether anything asked for could be an event, so whether to reach for a database. */
22
+ export declare function wantsEvents(patterns: readonly string[]): boolean;
23
+ /** Whether a pattern names events specifically, rather than sweeping them up. */
24
+ export declare function namesEvents(patterns: readonly string[]): boolean;
25
+ export interface ImagesDeps {
26
+ /** Resolves the database, printing its own diagnosis on failure. */
27
+ connect: () => Promise<Instance | null>;
28
+ /** Overridden in tests so nothing opens a socket. */
29
+ reader?: (instance: Instance) => EventReader;
30
+ }
31
+ /** Where one selection is written. */
32
+ export declare function destinationOf({ graphic, format }: Selection, out: string | undefined): string;
33
+ export declare function runImages(argv: string[], deps: ImagesDeps): Promise<void>;
34
+ /** Re-exported so `render.ts`'s options type is the format's own shape. */
35
+ export type { Format };
@@ -0,0 +1,233 @@
1
+ /**
2
+ * `pnpm devtools images [graphic…] [--format …] [--out …]`
3
+ *
4
+ * Renders the club's pictures from the templates in `@devdogsuga/open-graph`. Two axes,
5
+ * asked for separately: WHICH picture (`event/2026-09-08`, `page/events`,
6
+ * `app/dogdays`) and at WHAT SIZE (`gdgc-square`, `og`, `icon-512`). That split
7
+ * is the point of the command — an event poster for the GDG on Campus platform
8
+ * is exactly the pairing "this meeting" x "that platform's banner", and it
9
+ * could not be asked for while the two were one hard-coded list.
10
+ *
11
+ * Anything the command line leaves out, it asks for; anything it names, it does
12
+ * not. So an officer can type `pnpm devtools images` and be walked through it,
13
+ * and a script can pass every flag and never see a prompt.
14
+ *
15
+ * Event graphics are the one group backed by a database rather than by files in
16
+ * this repo, and the one group that can therefore be unavailable. See
17
+ * {@link loadEvents} for what the command says when it is.
18
+ */
19
+ import { mkdir, writeFile } from "node:fs/promises";
20
+ import { homedir } from "node:os";
21
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
22
+ import { log, note } from "@clack/prompts";
23
+ import { loadOpenGraph } from "../repo/source.js";
24
+ import { findRepoRoot } from "../repo/root.js";
25
+ import { positionals } from "../args.js";
26
+ import { adminClient } from "../instance.js";
27
+ import { errorMessage, explain } from "../ui.js";
28
+ import { supabaseEvents } from "./events.js";
29
+ import { assertUniqueStems, eventGraphics, staticGraphics, } from "./graphics.js";
30
+ import { pickFormats, pickGraphics, pickOutput } from "./prompts.js";
31
+ import { render } from "./render.js";
32
+ import { commaList, formatsFor, matchGraphics, normalizeArgv, pair, } from "./select.js";
33
+ /**
34
+ * The workspace root. Every default directory is written relative to it, and
35
+ * resolving those against `process.cwd()` would put the club's icons wherever
36
+ * the contributor happened to be standing.
37
+ */
38
+ function flagValue(argv, flag) {
39
+ const index = argv.indexOf(flag);
40
+ if (index === -1)
41
+ return undefined;
42
+ const value = argv[index + 1];
43
+ return value && !value.startsWith("--") ? value : undefined;
44
+ }
45
+ export function parseImagesArgs(argv) {
46
+ const normalized = normalizeArgv(argv);
47
+ return {
48
+ patterns: positionals(normalized),
49
+ formats: commaList(flagValue(normalized, "--format")),
50
+ allFormats: normalized.includes("--all-formats"),
51
+ out: flagValue(normalized, "--out"),
52
+ defaultOut: normalized.includes("--default-out"),
53
+ noOutput: normalized.includes("--dry-run") || normalized.includes("--no-output"),
54
+ };
55
+ }
56
+ /** `~/images` when the shell did not expand it, which quoting prevents. */
57
+ export function expandHome(path) {
58
+ return path === "~" || path.startsWith("~/")
59
+ ? resolve(homedir(), path.slice(2))
60
+ : path;
61
+ }
62
+ /** Whether anything asked for could be an event, so whether to reach for a database. */
63
+ export function wantsEvents(patterns) {
64
+ // No pattern means the picker, and the picker should list meetings.
65
+ if (patterns.length === 0)
66
+ return true;
67
+ return patterns.some((pattern) => pattern === "*" ||
68
+ pattern === "*/*" ||
69
+ pattern === "event" ||
70
+ pattern.startsWith("event/"));
71
+ }
72
+ /** Whether a pattern names events specifically, rather than sweeping them up. */
73
+ export function namesEvents(patterns) {
74
+ return patterns.some((pattern) => pattern === "event" || pattern.startsWith("event/"));
75
+ }
76
+ /**
77
+ * Meetings, or a clear account of why there are none.
78
+ *
79
+ * The database is the one dependency of this command that a contributor cannot
80
+ * see, so every failure here says which database it looked at and what to run.
81
+ * Two failures, treated differently, matching what each one means:
82
+ *
83
+ * - **Unreachable, under a wildcard.** `images *` means "everything", and
84
+ * everything else is renderable. It renders, and reports the events it
85
+ * could not.
86
+ * - **Unreachable, named.** `images 'event/*'` asked for exactly the thing
87
+ * that is missing. Nothing to degrade to, so it fails.
88
+ */
89
+ async function loadEvents(patterns, deps) {
90
+ const required = namesEvents(patterns);
91
+ const instance = await deps.connect();
92
+ if (!instance) {
93
+ if (required) {
94
+ throw new Error("Event images come from the database, and there is no database to read.");
95
+ }
96
+ return {
97
+ graphics: [],
98
+ skipped: "no database reachable — run `pnpm devtools db start`",
99
+ };
100
+ }
101
+ const reader = (deps.reader ?? ((i) => supabaseEvents(adminClient(i))))(instance);
102
+ let meetings;
103
+ try {
104
+ meetings = await reader.meetings();
105
+ }
106
+ catch (err) {
107
+ if (required)
108
+ throw err;
109
+ return { graphics: [], skipped: errorMessage(err) };
110
+ }
111
+ return { graphics: eventGraphics(await loadOpenGraph(), meetings), skipped: null };
112
+ }
113
+ /** Where one selection is written. */
114
+ export function destinationOf({ graphic, format }, out) {
115
+ if (out) {
116
+ // Flat, and always `<stem>-<format>.png`: the pinned filenames a graphic
117
+ // uses in its own directory (`icon.png`, `opengraph-image.png`) are only
118
+ // unambiguous BECAUSE the directories differ. Side by side they collide.
119
+ return resolve(expandHome(out), `${graphic.stem}-${format.name}.png`);
120
+ }
121
+ const { dir, file } = graphic.destination(format);
122
+ return resolve(findRepoRoot(), dir, file);
123
+ }
124
+ /** Repo-relative where possible, so the log reads as paths in this project. */
125
+ function display(file) {
126
+ const rel = relative(findRepoRoot(), file);
127
+ return rel.startsWith("..") || isAbsolute(rel) ? file : rel;
128
+ }
129
+ export async function runImages(argv, deps) {
130
+ const options = parseImagesArgs(argv);
131
+ if (options.out && options.defaultOut) {
132
+ explain("Two answers to one question.", "--out names a directory and --default-out says to use each graphic's own.", [
133
+ "pnpm devtools images 'event/*' --out ~/images",
134
+ "pnpm devtools images '*' --default-out",
135
+ ]);
136
+ process.exitCode = 1;
137
+ return;
138
+ }
139
+ try {
140
+ await images(options, deps);
141
+ }
142
+ catch (err) {
143
+ explain("Could not render that.", errorMessage(err), [
144
+ "pnpm devtools images # pick from a list",
145
+ "pnpm devtools images 'event/*' --all-formats --out ~/images",
146
+ "pnpm devtools images '*' --default-out",
147
+ ]);
148
+ process.exitCode = 1;
149
+ }
150
+ }
151
+ async function images(options, deps) {
152
+ // ── Which pictures ────────────────────────────────────────────────────────
153
+ const registry = await staticGraphics();
154
+ let skipped = null;
155
+ if (wantsEvents(options.patterns)) {
156
+ const events = await loadEvents(options.patterns, deps);
157
+ registry.push(...events.graphics);
158
+ skipped = events.skipped;
159
+ }
160
+ assertUniqueStems(registry);
161
+ const graphics = options.patterns.length === 0
162
+ ? await pickGraphics(registry)
163
+ : resolvePatterns(options.patterns, registry);
164
+ if (graphics.length === 0) {
165
+ throw new Error("Nothing to render.");
166
+ }
167
+ // ── At what sizes ─────────────────────────────────────────────────────────
168
+ const { FORMATS } = await loadOpenGraph();
169
+ const requested = options.allFormats
170
+ ? (await formatsFor(graphics)).map((format) => format.name)
171
+ : options.formats.length > 0
172
+ ? options.formats
173
+ : await pickFormats(graphics);
174
+ const unknown = requested.filter((name) => !FORMATS[name]);
175
+ if (unknown.length > 0) {
176
+ throw new Error(`No format called ${unknown.join(", ")}. Try one of ${Object.keys(FORMATS).join(", ")}.`);
177
+ }
178
+ const { selections, unsupported } = await pair(graphics, requested);
179
+ // Naming one graphic and one format it cannot do is a mistake worth saying
180
+ // out loud; sweeping up combinations that do not exist under a wildcard is
181
+ // not, and reporting every one of those would bury the output.
182
+ if (selections.length === 0 && unsupported.length > 0) {
183
+ throw new Error(unsupported
184
+ .map((miss) => `${miss.graphic} has no ${miss.format} rendition`)
185
+ .join("; "));
186
+ }
187
+ if (selections.length === 0)
188
+ throw new Error("Nothing to render.");
189
+ // ── Where ─────────────────────────────────────────────────────────────────
190
+ const out = options.defaultOut
191
+ ? undefined
192
+ : options.out
193
+ ? options.out
194
+ : options.noOutput
195
+ ? undefined
196
+ : await pickOutput(graphics);
197
+ if (skipped)
198
+ log.warn(`event graphics skipped — ${skipped}`);
199
+ if (options.noOutput) {
200
+ note(selections
201
+ .map((selection) => `${selection.graphic.name} ${selection.format.name}\n ${display(destinationOf(selection, out))}\n ${selection.format.why}`)
202
+ .join("\n"), `${selections.length} image${selections.length === 1 ? "" : "s"}`);
203
+ return;
204
+ }
205
+ // ── Render ────────────────────────────────────────────────────────────────
206
+ let bytes = 0;
207
+ for (const selection of selections) {
208
+ const file = destinationOf(selection, out);
209
+ const { png, width, height } = await render(selection.graphic.render(selection.format), selection.format);
210
+ await mkdir(dirname(file), { recursive: true });
211
+ await writeFile(file, png);
212
+ bytes += png.length;
213
+ log.success(`${display(file)} ${width}x${height} (${Math.round(png.length / 1024)} KB)`);
214
+ }
215
+ log.info(`${selections.length} image${selections.length === 1 ? "" : "s"}, ${Math.round(bytes / 1024)} KB total.`);
216
+ }
217
+ function resolvePatterns(patterns, registry) {
218
+ const { matched, unmatched } = matchGraphics(patterns, registry);
219
+ if (unmatched.length === 0)
220
+ return matched;
221
+ // An event pattern that matched nothing is almost never a typo — it is a
222
+ // database with no meetings in it. Saying "try brand/*, page/*" sends the
223
+ // reader hunting for a spelling mistake that is not there.
224
+ const askedForEvents = unmatched.some((pattern) => pattern === "event" || pattern.startsWith("event/"));
225
+ const haveEvents = registry.some((graphic) => graphic.group === "event");
226
+ if (askedForEvents && !haveEvents) {
227
+ throw new Error("This database has no meetings, so there are no event images to render. " +
228
+ "Reconcile it from `@devdogsuga/events`, or point at a database that has been.");
229
+ }
230
+ const groups = [...new Set(registry.map((graphic) => `${graphic.group}/*`))];
231
+ throw new Error(`Nothing called ${unmatched.join(", ")}. Names are group/name — try ` +
232
+ `${groups.join(", ")}, or * for all of them.`);
233
+ }
@@ -0,0 +1,36 @@
1
+ import type { DevtoolsClient } from "../instance.js";
2
+ import type { EventGraphicSource } from "./graphics.js";
3
+ /**
4
+ * Meetings, for the one group of graphics that cannot be drawn from the repo.
5
+ *
6
+ * Every other graphic is a function of committed files. An event poster is a
7
+ * function of meetings reconciled into the database from
8
+ * `@devdogsuga/events`, so `devtools images event/*` needs a running
9
+ * database that has that config loaded into it — and there is nothing about
10
+ * typing a command that makes that obvious. Making it obvious is most of what
11
+ * this file is for: the reads are small, and {@link EventReader} is the seam
12
+ * the command talks to, so tests never open a socket.
13
+ *
14
+ * Reads go through `adminClient`, against the local instance resolved by the
15
+ * command. `platform` is exposed to PostgREST (`supabase/config.toml`),
16
+ * and the service role bypasses the RLS that otherwise keeps `meetings` and
17
+ * `workshops` closed to every client.
18
+ */
19
+ /** The seam the command talks to, so tests never open a socket. */
20
+ export interface EventReader {
21
+ meetings(): Promise<EventGraphicSource[]>;
22
+ }
23
+ export declare function supabaseEvents(client: DevtoolsClient): EventReader;
24
+ /**
25
+ * The meeting's name, in descending order of how much somebody meant it.
26
+ *
27
+ * Mirrors `apps/platform/src/lib/meetingTitle.ts`: an authored name, then the
28
+ * kind, then the workshops it teaches, then the date. Two named workshops read
29
+ * as a heading; three do not, and the date carries it while the agenda below
30
+ * shows the detail.
31
+ *
32
+ * Duplicated rather than imported because it lives in the Next app, which this
33
+ * package cannot depend on. `events.test.ts` pins the chain so a change to one
34
+ * shows up as a failure rather than as a differently-titled poster.
35
+ */
36
+ export declare function meetingTitle(nameOverride: string | null, kind: string | null, startsAt: Date, agenda: readonly string[]): string;
@@ -0,0 +1,152 @@
1
+ import { loadBrandEvent } from "../repo/peers.js";
2
+ export function supabaseEvents(client) {
3
+ return {
4
+ async meetings() {
5
+ // Cancelled nights are included deliberately: a cancellation is exactly
6
+ // when somebody needs a fresh graphic, and the card has a layout for it.
7
+ // Deleted ones are not.
8
+ //
9
+ // No competitions query any more: a competition is a mirrored GitHub
10
+ // issue now (see apps/platform/src/server/github/competitions.ts), not
11
+ // something a meeting kicks off or judges, so there is no "kickoff" or
12
+ // "judging" segment left for a meeting poster to draw. Every workshop
13
+ // item is plain "workshop".
14
+ const [meetings, workshops] = await Promise.all([
15
+ client
16
+ .from("meetings")
17
+ .select("id, slug, nameOverride, kind, building, location, startsAt, endsAt, cancelledAt, cancellationReason")
18
+ .is("deletedAt", null)
19
+ .order("startsAt", { ascending: false }),
20
+ client
21
+ .from("workshops")
22
+ .select("id, meetingId, title, projects(displayName, sortOrder)")
23
+ .is("deletedAt", null),
24
+ ]);
25
+ if (meetings.error)
26
+ throw new Error(`Could not read meetings: ${meetings.error.message}`);
27
+ if (workshops.error) {
28
+ throw new Error(`Could not read workshops: ${workshops.error.message}`);
29
+ }
30
+ const workshopRows = (workshops.data ?? []);
31
+ const agendas = groupAgendas(workshopRows);
32
+ const { EVENT_SEGMENT_VISUALS, meetingCardDetail, meetingLocation } = await loadBrandEvent();
33
+ return (meetings.data ?? []).map((row) => {
34
+ const agenda = agendas.get(row.id) ?? [];
35
+ const meeting = {
36
+ slug: row.slug,
37
+ startsAt: new Date(row.startsAt),
38
+ endsAt: new Date(row.endsAt),
39
+ building: row.building,
40
+ location: row.location,
41
+ kind: row.kind,
42
+ cancelledAt: row.cancelledAt ? new Date(row.cancelledAt) : null,
43
+ cancellationReason: row.cancellationReason,
44
+ };
45
+ const items = workshopRows
46
+ .filter((workshop) => workshop.meetingId === row.id)
47
+ .map((workshop) => ({
48
+ label: workshopLabel(workshop),
49
+ segment: "workshop",
50
+ }));
51
+ const detail = meetingCardDetail({
52
+ meeting,
53
+ title: meetingTitle(row.nameOverride, row.kind, meeting.startsAt, agenda),
54
+ agenda: items.map((item) => `${EVENT_SEGMENT_VISUALS[item.segment].label}: ${item.label}`),
55
+ location: meetingLocation(row.building, row.location),
56
+ });
57
+ return {
58
+ slug: row.slug,
59
+ hint: describeMeeting(meeting),
60
+ detail,
61
+ items: items.map((item) => {
62
+ const visual = EVENT_SEGMENT_VISUALS[item.segment];
63
+ return {
64
+ stem: slugPart(item.label),
65
+ detail: {
66
+ ...detail,
67
+ title: item.label,
68
+ agenda: undefined,
69
+ badge: { label: visual.label, accent: visual.accent },
70
+ },
71
+ };
72
+ }),
73
+ };
74
+ });
75
+ },
76
+ };
77
+ }
78
+ /** A readable path component for a workshop card. */
79
+ function slugPart(label) {
80
+ return (label
81
+ .toLowerCase()
82
+ .normalize("NFKD")
83
+ .replace(/[^a-z0-9]+/g, "-")
84
+ .replace(/^-|-$/g, "") || "agenda-item");
85
+ }
86
+ function workshopLabel(row) {
87
+ return row.title ?? row.projects?.displayName ?? "Workshop";
88
+ }
89
+ /**
90
+ * Workshop labels per meeting, in the order the app shows them.
91
+ *
92
+ * `projects.sortOrder` first, then the project's name, then the workshop's own
93
+ * title — the ordering `getMeetingWorkshops` uses, so an agenda here reads the
94
+ * same way it does on the page. A workshop with no project keeps its own title
95
+ * and sorts last, which is what `nulls last` does there.
96
+ */
97
+ function groupAgendas(rows) {
98
+ const byMeeting = new Map();
99
+ for (const row of rows) {
100
+ const list = byMeeting.get(row.meetingId) ?? [];
101
+ list.push(row);
102
+ byMeeting.set(row.meetingId, list);
103
+ }
104
+ const agendas = new Map();
105
+ for (const [meetingId, list] of byMeeting) {
106
+ const labels = list
107
+ .sort((a, b) => {
108
+ const order = (a.projects?.sortOrder ?? Number.MAX_SAFE_INTEGER) -
109
+ (b.projects?.sortOrder ?? Number.MAX_SAFE_INTEGER);
110
+ if (order !== 0)
111
+ return order;
112
+ return ((a.projects?.displayName ?? "").localeCompare(b.projects?.displayName ?? "") || (a.title ?? "").localeCompare(b.title ?? ""));
113
+ })
114
+ .map(workshopLabel);
115
+ agendas.set(meetingId, labels);
116
+ }
117
+ return agendas;
118
+ }
119
+ /**
120
+ * The meeting's name, in descending order of how much somebody meant it.
121
+ *
122
+ * Mirrors `apps/platform/src/lib/meetingTitle.ts`: an authored name, then the
123
+ * kind, then the workshops it teaches, then the date. Two named workshops read
124
+ * as a heading; three do not, and the date carries it while the agenda below
125
+ * shows the detail.
126
+ *
127
+ * Duplicated rather than imported because it lives in the Next app, which this
128
+ * package cannot depend on. `events.test.ts` pins the chain so a change to one
129
+ * shows up as a failure rather than as a differently-titled poster.
130
+ */
131
+ export function meetingTitle(nameOverride, kind, startsAt, agenda) {
132
+ if (nameOverride !== null)
133
+ return nameOverride;
134
+ if (kind !== null)
135
+ return kind;
136
+ if (agenda.length === 1)
137
+ return `Workshop: ${agenda[0]}`;
138
+ if (agenda.length === 2)
139
+ return `Workshop: ${agenda[0]} & ${agenda[1]}`;
140
+ return startsAt.toLocaleDateString("en-US", {
141
+ timeZone: "America/New_York",
142
+ dateStyle: "long",
143
+ });
144
+ }
145
+ /** One line for the picker: when it is, and whether it is still on. */
146
+ function describeMeeting(meeting) {
147
+ const when = meeting.startsAt.toLocaleDateString("en-US", {
148
+ timeZone: "America/New_York",
149
+ dateStyle: "medium",
150
+ });
151
+ return meeting.cancelledAt === null ? when : `${when}, cancelled`;
152
+ }