@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/doctor.js ADDED
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Does my app's moderation integration actually work?
3
+ *
4
+ * Two checks, and the second is the one that matters.
5
+ *
6
+ * `conformance()` asks the database what it derived from your schema: is the
7
+ * table addressable, can an author be attributed, does quarantine have a column
8
+ * to write to, can a client still write that column. All of it is answerable
9
+ * from the catalog, and `platform.conformance_check()` does the answering.
10
+ *
11
+ * `quarantineRoundTrip()` answers the question the catalog cannot: *does
12
+ * quarantine do anything?* Quarantine is the only moderation outcome whose
13
+ * effect lives in the app's own policies rather than the platform's, so it is
14
+ * the only one that can be wired up wrong while everything appears to work.
15
+ * The platform records the decision, sets the column, and has no way to notice
16
+ * nobody reads it. The only proof is to file, resolve, and then look.
17
+ *
18
+ * It runs against `platform.profile`, where quarantine means FREEZE rather than
19
+ * hide: the display name is reset to the member's name of record and they lose
20
+ * the ability to change it back. A profile cannot be hidden: rosters, teams
21
+ * and standings all render the name, and the row is own-row-only over PostgREST
22
+ * anyway, so there is nobody to hide it from. See
23
+ * 20260808000000_platform_profile_moderation.sql.
24
+ *
25
+ * Both run as seeded personas over PostgREST rather than as `postgres` over
26
+ * SQL, because RLS is the thing under test and a superuser bypasses it.
27
+ */
28
+ import { adminClient, personaClient, PERSONA_PASSWORD, PERSONAS, } from "./instance.js";
29
+ /** Runs `platform.conformance_check()` as the seeded moderator. */
30
+ export async function conformance(instance, appSlug) {
31
+ const client = await personaClient(instance, PERSONAS.moderator);
32
+ const { data, error } = await client.rpc("conformance_check", {
33
+ app_slug: appSlug,
34
+ });
35
+ if (error)
36
+ throw new Error(error.message);
37
+ return (data ?? []);
38
+ }
39
+ /** The apps that have anything to check, for the picker. */
40
+ export async function listApps(instance) {
41
+ const { data, error } = await adminClient(instance)
42
+ .from("apps")
43
+ .select("slug")
44
+ .order("slug");
45
+ if (error)
46
+ throw new Error(error.message);
47
+ return (data ?? []).map((row) => row.slug);
48
+ }
49
+ /**
50
+ * Files a report against a profile, resolves it with quarantine, and checks
51
+ * that the freeze and the name reset both landed.
52
+ *
53
+ * The subject is a THROWAWAY member created for the run, not one of the seeded
54
+ * personas, and that is not squeamishness about touching fixtures.
55
+ * `file_report` corroborates an existing open report rather than filing a
56
+ * second one against the same content, and the seeds ship exactly such a report
57
+ * against the author persona's profile. Reusing it would make this function's
58
+ * first step silently become "add a corroboration", and every assertion after
59
+ * it would be reading the seed's decision instead of its own.
60
+ *
61
+ * Everything it creates, it deletes, including on the failure paths, which is
62
+ * why the teardown is in a `finally`. A contributor running this twice should
63
+ * get the same answer the second time.
64
+ */
65
+ export async function quarantineRoundTrip(instance) {
66
+ const steps = [];
67
+ const admin = adminClient(instance);
68
+ const email = `roundtrip-${crypto.randomUUID().slice(0, 8)}@devdogs.test`;
69
+ const abusiveName = "BUY CHEAP FOLLOWERS NOW";
70
+ let subjectId = null;
71
+ let reportId = null;
72
+ try {
73
+ // ── Arrange: a member with an abusive display name and a name of record ──
74
+ //
75
+ // Through the GoTrue admin API rather than `from("users")`: the `auth`
76
+ // schema is not exposed over PostgREST, and should not be.
77
+ const { data: created, error: createErr } = await admin.auth.admin.createUser({
78
+ email,
79
+ password: PERSONA_PASSWORD,
80
+ email_confirm: true,
81
+ });
82
+ if (createErr ?? !created.user) {
83
+ throw new Error(`Could not create the subject account: ${createErr?.message}`);
84
+ }
85
+ subjectId = created.user.id;
86
+ const { error: profileErr } = await admin.from("profile").insert({
87
+ userId: subjectId,
88
+ preferredName: abusiveName,
89
+ bio: "Created by `pnpm devtools`, and deleted again immediately.",
90
+ // The name the reset restores. Written by the Involvement roster import
91
+ // in production (server/actions/verification.ts); set by hand here so the
92
+ // round-trip exercises the branch that has something to restore.
93
+ legalFirstName: "Round",
94
+ legalLastName: "Trip",
95
+ });
96
+ if (profileErr) {
97
+ throw new Error(`Could not create a profile: ${profileErr.message}`);
98
+ }
99
+ steps.push({
100
+ name: "fixture",
101
+ ok: true,
102
+ detail: `Created a profile named "${abusiveName}" with a name of record.`,
103
+ });
104
+ // ── Editable before ─────────────────────────────────────────────────────
105
+ //
106
+ // Establishes the baseline the freeze is measured against. Without it a
107
+ // profile that was never editable in the first place would pass the freeze
108
+ // check for the wrong reason.
109
+ const subject = await personaClient(instance, email);
110
+ await subject
111
+ .from("profile")
112
+ .update({ bio: "Edited." })
113
+ .eq("userId", subjectId);
114
+ const { data: beforeRow } = await admin
115
+ .from("profile")
116
+ .select("bio")
117
+ .eq("userId", subjectId)
118
+ .single();
119
+ const editableBefore = beforeRow?.bio === "Edited.";
120
+ steps.push({
121
+ name: "editable_before",
122
+ ok: editableBefore,
123
+ detail: editableBefore
124
+ ? "The member can edit their own profile before it is reported."
125
+ : "The member could NOT edit their own profile before it was reported — the update policy is denying more than quarantine.",
126
+ });
127
+ // ── Act: file a report as the seeded member ─────────────────────────────
128
+ const memberClient = await personaClient(instance, PERSONAS.member);
129
+ // A literal, not a lookup. The vocabulary is a platform-owned enum, so
130
+ // there is no per-app row to find first. If this label ever stops existing,
131
+ // Postgres rejects the call by type rather than silently reporting "no
132
+ // reasons configured".
133
+ const { data: filed, error: fileErr } = await memberClient.rpc("file_report", {
134
+ app_slug: "platform",
135
+ content_type: "profile",
136
+ content_ref: subjectId,
137
+ reason: "spam",
138
+ description: "Filed by the devtools round-trip.",
139
+ });
140
+ if (fileErr)
141
+ throw new Error(`file_report failed: ${fileErr.message}`);
142
+ // A one-element array, not an object: these RPCs return `setof` now, so
143
+ // PostgREST serialises even a single result as a list.
144
+ const [row] = (filed ?? []);
145
+ if (!row)
146
+ throw new Error("file_report returned no rows");
147
+ reportId = row.reportId;
148
+ steps.push({
149
+ name: "file_report",
150
+ ok: true,
151
+ detail: `A member filed a report against the profile (${reportId}).`,
152
+ });
153
+ // ── Act: resolve with quarantine as the moderator ───────────────────────
154
+ const moderator = await personaClient(instance, PERSONAS.moderator);
155
+ const { error: resolveErr } = await moderator.rpc("resolve_report", {
156
+ report_id: reportId,
157
+ subject_action: "no_action",
158
+ filer_action: "no_action",
159
+ content_action: "quarantine",
160
+ moderator_note: "devtools round-trip",
161
+ });
162
+ if (resolveErr) {
163
+ throw new Error(`resolve_report failed: ${resolveErr.message}`);
164
+ }
165
+ steps.push({
166
+ name: "resolve_report",
167
+ ok: true,
168
+ detail: "A moderator resolved the report with `quarantine`.",
169
+ });
170
+ // ── Assert: the effect actually landed ──────────────────────────────────
171
+ const { data: after } = await admin
172
+ .from("profile")
173
+ .select("quarantinedBy, preferredName")
174
+ .eq("userId", subjectId)
175
+ .single();
176
+ const columnSet = Boolean(after?.quarantinedBy);
177
+ steps.push({
178
+ name: "quarantine_column",
179
+ ok: columnSet,
180
+ detail: columnSet
181
+ ? 'The resolution set "quarantinedBy" on the row.'
182
+ : 'The resolution did NOT set "quarantinedBy" — apply_content_action did not reach this table.',
183
+ });
184
+ const nameReset = after?.preferredName === "Round Trip";
185
+ steps.push({
186
+ name: "name_reset",
187
+ ok: nameReset,
188
+ detail: nameReset
189
+ ? 'The display name was reset to the name of record ("Round Trip").'
190
+ : `The display name is still "${String(after?.preferredName)}" — the reset trigger did not fire, so the remedy is a record with no effect.`,
191
+ });
192
+ // ── Assert: and that the member stopped being able to undo it ───────────
193
+ //
194
+ // The check that matters. A denied UPDATE under RLS is not an error, it
195
+ // simply matches no rows, so asserting on `error` here would pass just as
196
+ // happily if the write had landed. Read the value back instead.
197
+ await subject
198
+ .from("profile")
199
+ .update({ preferredName: abusiveName })
200
+ .eq("userId", subjectId);
201
+ const { data: frozenRow } = await admin
202
+ .from("profile")
203
+ .select("preferredName")
204
+ .eq("userId", subjectId)
205
+ .single();
206
+ const frozen = frozenRow?.preferredName !== abusiveName;
207
+ steps.push({
208
+ name: "frozen_after",
209
+ ok: frozen,
210
+ detail: frozen
211
+ ? "The member can no longer change their display name back."
212
+ : 'The member CHANGED IT STRAIGHT BACK. The update policy does not filter on "quarantinedBy" — this is the check that matters.',
213
+ });
214
+ return steps;
215
+ }
216
+ finally {
217
+ // Teardown runs whether or not the assertions held. Deleting the report
218
+ // first lets the resolution cascade, which releases the FK on the profile;
219
+ // deleting the auth user then cascades to the profile row itself.
220
+ if (reportId)
221
+ await admin.from("reports").delete().eq("id", reportId);
222
+ if (subjectId)
223
+ await admin.auth.admin.deleteUser(subjectId);
224
+ }
225
+ }
@@ -0,0 +1,15 @@
1
+ import { EMAIL_FIXTURES } from "./fixtures.js";
2
+ export type EmailFormat = "html" | "text";
3
+ export declare const EMAIL_FORMATS: readonly ["html", "text"];
4
+ export type EmailName = keyof typeof EMAIL_FIXTURES;
5
+ export declare const EMAIL_NAMES: EmailName[];
6
+ export interface EmailOptions {
7
+ names: EmailName[];
8
+ formats: EmailFormat[];
9
+ out: string;
10
+ noOutput: boolean;
11
+ }
12
+ export declare function parseEmailArgs(argv: readonly string[], cwd: string): EmailOptions | Error;
13
+ export declare function destination(out: string, name: EmailName, format: EmailFormat): string;
14
+ export declare function generateEmails(options: EmailOptions): Promise<string[]>;
15
+ export declare function runEmails(argv: string[]): Promise<void>;
@@ -0,0 +1,130 @@
1
+ /** `pnpm devtools emails [template…] [--format html,text] [--out dir]`. */
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, resolve } from "node:path";
5
+ import { isTTY, log, multiselect, note, text as askText } from "@clack/prompts";
6
+ import { positionals } from "../args.js";
7
+ import { errorMessage, explain, unwrap } from "../ui.js";
8
+ import { EMAIL_FIXTURES } from "./fixtures.js";
9
+ import { loadEmail } from "../repo/source.js";
10
+ export const EMAIL_FORMATS = ["html", "text"];
11
+ export const EMAIL_NAMES = Object.keys(EMAIL_FIXTURES);
12
+ function flagValue(argv, flag) {
13
+ const index = argv.indexOf(flag);
14
+ if (index === -1)
15
+ return undefined;
16
+ const value = argv[index + 1];
17
+ return value && !value.startsWith("--") ? value : undefined;
18
+ }
19
+ function expandHome(value) {
20
+ return value === "~" || value.startsWith("~/")
21
+ ? resolve(homedir(), value.slice(2))
22
+ : value;
23
+ }
24
+ export function parseEmailArgs(argv, cwd) {
25
+ const requested = positionals(argv);
26
+ const unknown = requested.filter((name) => name !== "*" && !EMAIL_NAMES.includes(name));
27
+ if (unknown.length) {
28
+ return new Error(`No email template called ${unknown.join(", ")}. Try ${EMAIL_NAMES.join(", ")}, or *.`);
29
+ }
30
+ const rawFormats = (flagValue(argv, "--format") ?? "html")
31
+ .split(",")
32
+ .map((value) => value.trim())
33
+ .filter(Boolean);
34
+ const badFormats = rawFormats.filter((value) => !EMAIL_FORMATS.includes(value));
35
+ if (badFormats.length)
36
+ return new Error(`Unknown format ${badFormats.join(", ")}. Try html or text.`);
37
+ const names = requested.includes("*")
38
+ ? [...EMAIL_NAMES]
39
+ : requested;
40
+ return {
41
+ names,
42
+ formats: [...new Set(rawFormats)],
43
+ out: resolve(cwd, expandHome(flagValue(argv, "--out") ?? "email-previews")),
44
+ noOutput: argv.includes("--dry-run") || argv.includes("--no-output"),
45
+ };
46
+ }
47
+ async function interactive(options) {
48
+ if (options.names.length)
49
+ return options;
50
+ if (!isTTY(process.stdout)) {
51
+ throw new Error("No terminal to choose templates. Name one, or pass * for all.");
52
+ }
53
+ const names = unwrap(await multiselect({
54
+ message: "Which emails?",
55
+ options: EMAIL_NAMES.map((name) => ({ value: name, label: name })),
56
+ initialValues: [...EMAIL_NAMES],
57
+ required: true,
58
+ }));
59
+ const formats = unwrap(await multiselect({
60
+ message: "Which formats?",
61
+ options: [
62
+ { value: "html", label: "HTML", hint: "open in a browser" },
63
+ { value: "text", label: "Plain text", hint: "the email alternative" },
64
+ ],
65
+ initialValues: ["html"],
66
+ required: true,
67
+ }));
68
+ const entered = unwrap(await askText({
69
+ message: "Where should these go?",
70
+ placeholder: "./email-previews",
71
+ defaultValue: "./email-previews",
72
+ })).trim();
73
+ return {
74
+ ...options,
75
+ names,
76
+ formats,
77
+ out: resolve(process.cwd(), expandHome(entered || "email-previews")),
78
+ };
79
+ }
80
+ export function destination(out, name, format) {
81
+ return resolve(out, `${name}.${format === "text" ? "txt" : "html"}`);
82
+ }
83
+ export async function generateEmails(options) {
84
+ // Dynamic so every unrelated devtools command can still run before the
85
+ // generated email package has been built on a fresh checkout.
86
+ const { render } = await loadEmail();
87
+ const written = [];
88
+ for (const name of options.names) {
89
+ const email = render(name, EMAIL_FIXTURES[name]);
90
+ for (const format of options.formats) {
91
+ const file = destination(options.out, name, format);
92
+ await mkdir(dirname(file), { recursive: true });
93
+ await writeFile(file, format === "html" ? email.html : email.text);
94
+ written.push(file);
95
+ }
96
+ }
97
+ return written;
98
+ }
99
+ export async function runEmails(argv) {
100
+ const parsed = parseEmailArgs(argv, process.cwd());
101
+ if (parsed instanceof Error) {
102
+ explain("Could not read that.", parsed.message, [
103
+ "pnpm devtools emails",
104
+ "pnpm devtools emails '*' --format html,text --out ~/emails",
105
+ ]);
106
+ process.exitCode = 1;
107
+ return;
108
+ }
109
+ try {
110
+ const options = await interactive(parsed);
111
+ if (options.noOutput) {
112
+ const { render } = await loadEmail();
113
+ note(options.names
114
+ .map((name) => `${name}\n ${render(name, EMAIL_FIXTURES[name]).subject}\n ${options.formats.map((f) => destination(options.out, name, f)).join("\n ")}`)
115
+ .join("\n"), `${options.names.length} email template${options.names.length === 1 ? "" : "s"}`);
116
+ return;
117
+ }
118
+ const written = await generateEmails(options);
119
+ for (const file of written)
120
+ log.success(file);
121
+ log.info(`${written.length} preview file${written.length === 1 ? "" : "s"} written.`);
122
+ }
123
+ catch (err) {
124
+ explain("Could not render those emails.", errorMessage(err), [
125
+ "Build the email package with `pnpm --filter @devdogsuga/email build`.",
126
+ "Then try `pnpm devtools emails '*' --out ~/emails`.",
127
+ ]);
128
+ process.exitCode = 1;
129
+ }
130
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Type-only stand-in for `@devdogsuga/email`'s generated `Templates` map.
3
+ *
4
+ * `@devdogsuga/email` is `"private": true` and never published — it stays
5
+ * in DevDogsUGA and devtools resolves it FROM the repo at runtime
6
+ * (`repo/source.ts`'s `loadEmail()`). Backstage has no copy to pull the
7
+ * real generated type from, so `fixtures.ts`'s `satisfies` check is
8
+ * loosened to "every template's fixture is a flat string-prop object"
9
+ * instead of the real per-template exact prop shape. A prop typo here is
10
+ * caught at runtime (the real `render()` throws on a missing required
11
+ * prop), not at Backstage's typecheck time.
12
+ */
13
+ export type EmailTemplates = Record<string, Record<string, string>>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Credible, non-sensitive values for local previews.
3
+ *
4
+ * `satisfies` is the maintenance mechanism: a new template or prop cannot be
5
+ * shipped without deciding what its preview should say.
6
+ */
7
+ export declare const EMAIL_FIXTURES: {
8
+ JoinRequest: {
9
+ leadName: string;
10
+ applicantName: string;
11
+ teamName: string;
12
+ reviewUrl: string;
13
+ };
14
+ TeamInvite: {
15
+ inviteeName: string;
16
+ teamName: string;
17
+ leadName: string;
18
+ acceptUrl: string;
19
+ };
20
+ };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Credible, non-sensitive values for local previews.
3
+ *
4
+ * `satisfies` is the maintenance mechanism: a new template or prop cannot be
5
+ * shipped without deciding what its preview should say.
6
+ */
7
+ export const EMAIL_FIXTURES = {
8
+ JoinRequest: {
9
+ leadName: "Jordan",
10
+ applicantName: "Avery",
11
+ teamName: "Byte Bulldogs",
12
+ reviewUrl: "https://devdogsuga.org/teams/requests",
13
+ },
14
+ TeamInvite: {
15
+ inviteeName: "Avery",
16
+ teamName: "Byte Bulldogs",
17
+ leadName: "Jordan",
18
+ acceptUrl: "https://devdogsuga.org/teams/requests",
19
+ },
20
+ };
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Four-way drift detection: local `.env`, Bitwarden, GitHub, Cloudflare.
3
+ *
4
+ * Each store answers a different question, and only one of them answers it
5
+ * completely:
6
+ *
7
+ * | Store | Exposes | So it can be checked for |
8
+ * |--------------------|-------------------|--------------------------|
9
+ * | local .env | names AND values | value drift |
10
+ * | Bitwarden | names AND values | value drift (the truth) |
11
+ * | GitHub *secrets* | names + updatedAt | presence, staleness |
12
+ * | GitHub *variables* | names AND values | value drift |
13
+ * | Cloudflare | names only | presence, orphans |
14
+ *
15
+ * That asymmetry is the whole design. A GitHub *secret* is write-only, so a
16
+ * clean audit of one does not mean "the values match". It means "nothing
17
+ * detectable is wrong", and the report says which is which rather than implying
18
+ * the stronger claim.
19
+ *
20
+ * Timestamps are what rescue that row from being presence-only. Bitwarden
21
+ * reports a `revisionDate` per secret and `gh secret list` reports `updatedAt`,
22
+ * so "was GitHub updated after Bitwarden last changed?" IS answerable. That is
23
+ * the failure this design actually has: a credential rotated in Bitwarden and
24
+ * never propagated, which a name-only check calls healthy right up until the old
25
+ * one is revoked.
26
+ *
27
+ * ⚠️ GitHub *variables* are the one downstream store that needs none of that
28
+ * reasoning: `gh variable list` returns the value, so the public per-environment
29
+ * keys are compared exactly the way the local file is. Staleness is deliberately
30
+ * NOT applied to them: a timestamp is a proxy for a comparison that could not
31
+ * be made, and using the proxy where the real answer is available would report a
32
+ * variable re-pushed with an identical value as drift. The security plan's §3.6
33
+ * limitation ("names only… a changed value is undetectable") now holds for the
34
+ * secret row alone.
35
+ *
36
+ * ⚠️ One scope sits outside that table altogether: the repository's OWN
37
+ * variables. Push writes environment-scoped values only, and an environment
38
+ * variable SHADOWS a repository one of the same name, so a repository-level
39
+ * copy of a managed key is read by nothing, drifts forever, and becomes the
40
+ * live value on the day somebody deletes the environment copy. It is checked
41
+ * here for the same reason §3.6's orphans are: nothing manages it, so nothing
42
+ * else in this system would ever mention it.
43
+ */
44
+ export type Severity = "error" | "warning" | "info";
45
+ export interface Finding {
46
+ key: string;
47
+ severity: Severity;
48
+ /** Where the problem is, for grouping. */
49
+ store: "local" | "github" | "cloudflare";
50
+ summary: string;
51
+ }
52
+ export interface BwsEntry {
53
+ value: string;
54
+ /** ISO 8601. Absent when the CLI did not report one. */
55
+ revisionDate?: string;
56
+ }
57
+ export interface GithubEntry {
58
+ /** Which GitHub environment this copy sits in. */
59
+ environment: string;
60
+ name: string;
61
+ /** ISO 8601. */
62
+ updatedAt?: string;
63
+ }
64
+ /**
65
+ * A GitHub environment *variable*: the same shape plus the thing that makes
66
+ * variables worth having, a readable value.
67
+ */
68
+ export interface GithubVariableEntry extends GithubEntry {
69
+ value: string;
70
+ }
71
+ export interface AuditInput {
72
+ /** Active assignments in the local `.env`. */
73
+ local: Map<string, string>;
74
+ /** Keys present in the local `.env` but commented out. */
75
+ localCommented?: Set<string>;
76
+ /** The source of truth. */
77
+ bws: Map<string, BwsEntry>;
78
+ /**
79
+ * Every GitHub copy found, across every environment this project feeds.
80
+ *
81
+ * A list rather than a map keyed by name, because one Bitwarden project can
82
+ * feed two GitHub environments and the interesting case is a key appearing in
83
+ * BOTH, which a name-keyed map would quietly collapse to one.
84
+ */
85
+ github: GithubEntry[];
86
+ /**
87
+ * Every GitHub environment *variable* found, across the same environments.
88
+ *
89
+ * Separate from `github` rather than merged with a discriminator, because
90
+ * GitHub genuinely allows one name to exist as both a secret and a variable
91
+ * in one environment and resolves the ambiguity by never telling you. See
92
+ * `deploy/write-env.ts`, which refuses that case outright. Two lists can
93
+ * represent it; one list keyed by name cannot, and would silently pick a
94
+ * winner.
95
+ */
96
+ githubVariables?: GithubVariableEntry[];
97
+ /**
98
+ * Keys that belong in the variable store rather than the secret store, from
99
+ * `variableKeys()` in the registry.
100
+ *
101
+ * Drives two things at once: which store a key is compared against, and the
102
+ * pair of misplacement errors. Absent, every key is treated as a secret,
103
+ * which is the pre-variables behaviour.
104
+ */
105
+ variables?: ReadonlySet<string>;
106
+ /**
107
+ * Where a key is supposed to live: the PRIMARY environment, the one whose
108
+ * absence is an error. `null` means "nowhere in this environment", which is
109
+ * ordinary rather than wrong.
110
+ */
111
+ route: (key: string) => string | null;
112
+ /**
113
+ * Whether a copy found in some OTHER environment is legitimate.
114
+ *
115
+ * Separate from `route` because the two questions stopped having the same
116
+ * answer when `production-apply` became a superset of `production`: an
117
+ * ordinary production key is now pushed to both, so "not where `route` says"
118
+ * no longer means "misplaced". Folding them back together reports every
119
+ * correctly-pushed copy as a stray to delete, and a reviewer who deletes 46
120
+ * of those learns to skim the stray finding, which is the one finding that
121
+ * catches an apply-tier credential sitting in the unreviewed environment.
122
+ *
123
+ * Defaults to the old behaviour (`only where route says`), so a caller that
124
+ * has not thought about fan-out gets the strict answer rather than a
125
+ * permissive one.
126
+ */
127
+ accepted?: (key: string, environment: string) => boolean;
128
+ /** Worker name → secret names on it. Values are unreadable. */
129
+ cloudflare?: Map<string, Set<string>>;
130
+ /** Keys that legitimately live outside Bitwarden: the non-secrets. */
131
+ ignore?: ReadonlySet<string>;
132
+ /**
133
+ * Credentials that must never be stored remotely at all.
134
+ *
135
+ * Distinct from `ignore`, and the opposite of it: an ignored key is expected
136
+ * somewhere else, whereas one of these appearing in ANY remote store is a
137
+ * finding. Being in the local `.env` and nowhere else is the correct state,
138
+ * so the ordinary "not in Bitwarden" warning must not fire for them.
139
+ */
140
+ neverStore?: ReadonlySet<string>;
141
+ /**
142
+ * Credentials signed at deploy time, whose only copy is on the deploy target.
143
+ *
144
+ * The third category, and it sits between the other two rather than beside
145
+ * them: unlike `ignore` it is a secret, and unlike `neverStore` there is one
146
+ * remote store it BELONGS in. A key like this is signed at deploy time and
147
+ * written straight to the Worker, so being on Cloudflare and nowhere else is
148
+ * exactly right. `SANDBOX_PROXY_TOKEN` is still declared this way in
149
+ * `apps/sandbox/env.ts` — nothing mints it any more, since the deploy that
150
+ * did was removed with the sandbox integration it authenticated, but the
151
+ * declaration is what keeps this category from reading "on production-
152
+ * sandbox, not in Bitwarden" the day someone does fill it in by hand.
153
+ *
154
+ * Without this set the Cloudflare pass below reports a key like that as an
155
+ * orphan, which the plan doc's §3.6 prune path then offers to delete.
156
+ * Marking it the other obvious way, as `never-store`, inverts the error into
157
+ * "must NEVER be a Worker secret" and is just as wrong in the opposite
158
+ * direction.
159
+ */
160
+ minted?: ReadonlySet<string>;
161
+ /**
162
+ * Every key the registry knows. When provided, a local key outside it is
163
+ * reported as UNDECLARED: its own category, not folded into drift, because
164
+ * the fix is different: drift wants a push or a pull, an undeclared key
165
+ * wants a `define()` in the owning manifest (or the line removed). It is
166
+ * also why `env push` skipped it, and the audit should say so rather
167
+ * than let "not in Bitwarden" imply pushing would help.
168
+ */
169
+ declared?: ReadonlySet<string>;
170
+ /**
171
+ * The repository's OWN variables, the scope with no environment, or the fact
172
+ * that they could not be listed.
173
+ *
174
+ * A union rather than a list, because three states have to stay apart and a
175
+ * list can only hold two of them: nobody looked (absent: the pre-existing
176
+ * behaviour, no claim either way), looked and saw these names
177
+ * (`readable`), and TRIED TO LOOK AND COULD NOT (`readable: false`). Listing
178
+ * repository variables can fail where listing environment ones succeeds, so
179
+ * the third state is ordinary rather than exotic, and collapsing it into an
180
+ * empty list is precisely the bug this check was added to catch, one scope
181
+ * up: a report that says "checked, nothing found" about a check that never
182
+ * ran.
183
+ *
184
+ * Names only. See `GhRepositoryVariable` for why the value is deliberately
185
+ * not fetched.
186
+ */
187
+ repositoryVariables?: RepositoryVariableScan;
188
+ }
189
+ /**
190
+ * What `gh variable list` (no `--env`) returned, or why it returned nothing.
191
+ *
192
+ * `reason` is carried rather than dropped because the two failures need
193
+ * different next steps and look identical from a finding that omits it: a
194
+ * missing permission is fixed by an admin, an unauthenticated CLI by the person
195
+ * reading the report.
196
+ */
197
+ export type RepositoryVariableScan = {
198
+ readable: true;
199
+ names: readonly string[];
200
+ } | {
201
+ readable: false;
202
+ reason: string;
203
+ };
204
+ export declare function audit(input: AuditInput): Finding[];
205
+ export declare function hasErrors(findings: Finding[]): boolean;
206
+ export declare function renderFindings(findings: Finding[]): string;