@devdogsuga/devtools 0.1.12 → 0.1.13

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 (56) hide show
  1. package/bin/devtools-ci.mjs +15 -1
  2. package/bin/devtools.mjs +15 -1
  3. package/dist/catalog.d.ts +7 -5
  4. package/dist/catalog.js +1 -25
  5. package/dist/cf/build.d.ts +13 -0
  6. package/dist/cf/build.js +65 -0
  7. package/dist/cf/commands.js +6 -16
  8. package/dist/cf/local-env.d.ts +14 -0
  9. package/dist/cf/local-env.js +29 -0
  10. package/dist/ci.js +8 -1
  11. package/dist/cli.js +134 -138
  12. package/dist/commands.d.ts +10 -4
  13. package/dist/commands.js +40 -30
  14. package/dist/db/generated-env.d.ts +69 -0
  15. package/dist/db/generated-env.js +113 -0
  16. package/dist/db/seed-production.js +12 -6
  17. package/dist/docs/index-pages.js +2 -2
  18. package/dist/emails/commands.js +3 -3
  19. package/dist/env-entry.d.ts +49 -0
  20. package/dist/env-entry.js +204 -0
  21. package/dist/environment-doctor.d.ts +9 -0
  22. package/dist/environment-doctor.js +18 -5
  23. package/dist/environment.d.ts +2 -2
  24. package/dist/environment.js +5 -28
  25. package/dist/images/commands.d.ts +3 -6
  26. package/dist/images/commands.js +26 -30
  27. package/dist/images/events.d.ts +27 -16
  28. package/dist/images/events.js +32 -68
  29. package/dist/images/graphics.js +2 -1
  30. package/dist/images/prompts.js +2 -2
  31. package/dist/instance.d.ts +29 -27
  32. package/dist/instance.js +49 -76
  33. package/dist/launch-ci.js +5 -5
  34. package/dist/launch.d.ts +2 -1
  35. package/dist/launch.js +74 -111
  36. package/dist/menu.d.ts +17 -5
  37. package/dist/menu.js +34 -11
  38. package/dist/moderation.d.ts +48 -40
  39. package/dist/moderation.js +95 -194
  40. package/dist/oauth/wizard.d.ts +2 -2
  41. package/dist/oauth/wizard.js +6 -26
  42. package/dist/persona.d.ts +18 -0
  43. package/dist/persona.js +221 -0
  44. package/dist/repo/peers.d.ts +2 -0
  45. package/dist/repo/peers.js +3 -0
  46. package/dist/repo/supabase-project.d.ts +60 -0
  47. package/dist/repo/supabase-project.js +124 -0
  48. package/dist/setup.js +2 -2
  49. package/dist/stack.js +142 -29
  50. package/dist/telemetry.d.ts +18 -5
  51. package/dist/telemetry.js +26 -5
  52. package/dist/ui.d.ts +15 -0
  53. package/dist/ui.js +20 -0
  54. package/dist/workflows/commands.d.ts +10 -0
  55. package/dist/workflows/commands.js +119 -25
  56. package/package.json +9 -4
@@ -12,9 +12,14 @@
12
12
  * not. So an officer can type `pnpm devtools images` and be walked through it,
13
13
  * and a script can pass every flag and never see a prompt.
14
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.
15
+ * Event graphics used to be the one group backed by a database rather than by
16
+ * files in this repo — and the one group that could therefore be unavailable
17
+ * without one. They no longer are: `configEvents()` (`events.ts`) reads
18
+ * `@devdogsuga/events`'s committed config directly, the same source a
19
+ * reconcile job used to copy into the database first, so this command needs
20
+ * no running stack at all any more. See {@link loadEvents} for what remains
21
+ * of the "unavailable" branch — a repo `@devdogsuga/events` cannot resolve
22
+ * in, rather than a database that is not up.
18
23
  */
19
24
  import { mkdir, writeFile } from "node:fs/promises";
20
25
  import { homedir } from "node:os";
@@ -23,9 +28,8 @@ import { log, note } from "@clack/prompts";
23
28
  import { loadOpenGraph } from "../repo/source.js";
24
29
  import { findRepoRoot } from "../repo/root.js";
25
30
  import { positionals } from "../args.js";
26
- import { adminClient } from "../instance.js";
27
- import { errorMessage, explain } from "../ui.js";
28
- import { supabaseEvents } from "./events.js";
31
+ import { errorMessage, explain, explainError, UsageError } from "../ui.js";
32
+ import { configEvents } from "./events.js";
29
33
  import { assertUniqueStems, eventGraphics, staticGraphics, } from "./graphics.js";
30
34
  import { pickFormats, pickGraphics, pickOutput } from "./prompts.js";
31
35
  import { render } from "./render.js";
@@ -76,29 +80,21 @@ export function namesEvents(patterns) {
76
80
  /**
77
81
  * Meetings, or a clear account of why there are none.
78
82
  *
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:
83
+ * `@devdogsuga/events` is an optional peer (see `repo/peers.ts`'s header), so
84
+ * loading it can still fail — outside a DevDogsUGA checkout, or one where
85
+ * nothing depends on it. That is the one way this command still needs an
86
+ * account of what went wrong, and two failures are treated differently,
87
+ * matching what each one means:
82
88
  *
83
- * - **Unreachable, under a wildcard.** `images *` means "everything", and
89
+ * - **Unavailable, under a wildcard.** `images *` means "everything", and
84
90
  * everything else is renderable. It renders, and reports the events it
85
91
  * could not.
86
- * - **Unreachable, named.** `images 'event/*'` asked for exactly the thing
92
+ * - **Unavailable, named.** `images 'event/*'` asked for exactly the thing
87
93
  * that is missing. Nothing to degrade to, so it fails.
88
94
  */
89
95
  async function loadEvents(patterns, deps) {
90
96
  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);
97
+ const reader = (deps.reader ?? configEvents)();
102
98
  let meetings;
103
99
  try {
104
100
  meetings = await reader.meetings();
@@ -126,7 +122,7 @@ function display(file) {
126
122
  const rel = relative(findRepoRoot(), file);
127
123
  return rel.startsWith("..") || isAbsolute(rel) ? file : rel;
128
124
  }
129
- export async function runImages(argv, deps) {
125
+ export async function runImages(argv, deps = {}) {
130
126
  const options = parseImagesArgs(argv);
131
127
  if (options.out && options.defaultOut) {
132
128
  explain("Two answers to one question.", "--out names a directory and --default-out says to use each graphic's own.", [
@@ -140,7 +136,7 @@ export async function runImages(argv, deps) {
140
136
  await images(options, deps);
141
137
  }
142
138
  catch (err) {
143
- explain("Could not render that.", errorMessage(err), [
139
+ explainError("Could not render that.", err, [
144
140
  "pnpm devtools images # pick from a list",
145
141
  "pnpm devtools images 'event/*' --all-formats --out ~/images",
146
142
  "pnpm devtools images '*' --default-out",
@@ -162,7 +158,7 @@ async function images(options, deps) {
162
158
  ? await pickGraphics(registry)
163
159
  : resolvePatterns(options.patterns, registry);
164
160
  if (graphics.length === 0) {
165
- throw new Error("Nothing to render.");
161
+ throw new UsageError("Nothing to render.");
166
162
  }
167
163
  // ── At what sizes ─────────────────────────────────────────────────────────
168
164
  const { FORMATS } = await loadOpenGraph();
@@ -173,19 +169,19 @@ async function images(options, deps) {
173
169
  : await pickFormats(graphics);
174
170
  const unknown = requested.filter((name) => !FORMATS[name]);
175
171
  if (unknown.length > 0) {
176
- throw new Error(`No format called ${unknown.join(", ")}. Try one of ${Object.keys(FORMATS).join(", ")}.`);
172
+ throw new UsageError(`No format called ${unknown.join(", ")}. Try one of ${Object.keys(FORMATS).join(", ")}.`);
177
173
  }
178
174
  const { selections, unsupported } = await pair(graphics, requested);
179
175
  // Naming one graphic and one format it cannot do is a mistake worth saying
180
176
  // out loud; sweeping up combinations that do not exist under a wildcard is
181
177
  // not, and reporting every one of those would bury the output.
182
178
  if (selections.length === 0 && unsupported.length > 0) {
183
- throw new Error(unsupported
179
+ throw new UsageError(unsupported
184
180
  .map((miss) => `${miss.graphic} has no ${miss.format} rendition`)
185
181
  .join("; "));
186
182
  }
187
183
  if (selections.length === 0)
188
- throw new Error("Nothing to render.");
184
+ throw new UsageError("Nothing to render.");
189
185
  // ── Where ─────────────────────────────────────────────────────────────────
190
186
  const out = options.defaultOut
191
187
  ? undefined
@@ -224,10 +220,10 @@ function resolvePatterns(patterns, registry) {
224
220
  const askedForEvents = unmatched.some((pattern) => pattern === "event" || pattern.startsWith("event/"));
225
221
  const haveEvents = registry.some((graphic) => graphic.group === "event");
226
222
  if (askedForEvents && !haveEvents) {
227
- throw new Error("This database has no meetings, so there are no event images to render. " +
223
+ throw new UsageError("This database has no meetings, so there are no event images to render. " +
228
224
  "Reconcile it from `@devdogsuga/events`, or point at a database that has been.");
229
225
  }
230
226
  const groups = [...new Set(registry.map((graphic) => `${graphic.group}/*`))];
231
- throw new Error(`Nothing called ${unmatched.join(", ")}. Names are group/name — try ` +
227
+ throw new UsageError(`Nothing called ${unmatched.join(", ")}. Names are group/name — try ` +
232
228
  `${groups.join(", ")}, or * for all of them.`);
233
229
  }
@@ -1,26 +1,37 @@
1
- import type { DevtoolsClient } from "../instance.js";
1
+ import type { Meeting } from "@devdogsuga/events";
2
2
  import type { EventGraphicSource } from "./graphics.js";
3
3
  /**
4
- * Meetings, for the one group of graphics that cannot be drawn from the repo.
4
+ * Meetings, for the one group of graphics that cannot be drawn from files
5
+ * alone.
5
6
  *
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.
7
+ * Every other graphic is a function of committed files. An event poster used
8
+ * to be a function of meetings RECONCILED into the database from
9
+ * `@devdogsuga/events` — which meant `devtools images event/*` needed a
10
+ * running local stack just to draw a picture that comes entirely out of this
11
+ * repo. It no longer does: `@devdogsuga/events`'s `getClubConfig()` is the
12
+ * authored source those database rows were always reconciled FROM (see
13
+ * `apps/platform/src/server/config/reconcile.ts` in the target repo), so
14
+ * reading it directly here skips the round trip through Postgres rather than
15
+ * duplicating it. {@link EventReader} is the seam the command talks to, so
16
+ * tests never touch the filesystem or the network.
18
17
  */
19
18
  /** The seam the command talks to, so tests never open a socket. */
20
19
  export interface EventReader {
21
20
  meetings(): Promise<EventGraphicSource[]>;
22
21
  }
23
- export declare function supabaseEvents(client: DevtoolsClient): EventReader;
22
+ /**
23
+ * Builds the reader from `@devdogsuga/events`'s committed data file.
24
+ *
25
+ * Replaces `supabaseEvents`, which read the `meetings`/`workshops` tables a
26
+ * reconcile job had already copied this same config into. The config's shape
27
+ * (`schema.ts`) is deliberately a near-mirror of those tables' columns for
28
+ * exactly this reason — see that file's header — so the mapping below is
29
+ * closer to a rename than a translation: `Meeting.id` is the row's `slug`,
30
+ * `Meeting.title` is what the DB called `nameOverride`, and a workshop's
31
+ * `project` (free text in the config) stands in for the DB's
32
+ * `projects.displayName` join.
33
+ */
34
+ export declare function configEvents(): EventReader;
24
35
  /**
25
36
  * The meeting's name, in descending order of how much somebody meant it.
26
37
  *
@@ -33,4 +44,4 @@ export declare function supabaseEvents(client: DevtoolsClient): EventReader;
33
44
  * package cannot depend on. `events.test.ts` pins the chain so a change to one
34
45
  * shows up as a failure rather than as a differently-titled poster.
35
46
  */
36
- export declare function meetingTitle(nameOverride: string | null, kind: string | null, startsAt: Date, agenda: readonly string[]): string;
47
+ export declare function meetingTitle(nameOverride: string | null, kind: Meeting["kind"], startsAt: Date, agenda: readonly string[]): string;
@@ -1,39 +1,29 @@
1
- import { loadBrandEvent } from "../repo/peers.js";
2
- export function supabaseEvents(client) {
1
+ import { loadBrandEvent, loadEvents } from "../repo/peers.js";
2
+ /**
3
+ * Builds the reader from `@devdogsuga/events`'s committed data file.
4
+ *
5
+ * Replaces `supabaseEvents`, which read the `meetings`/`workshops` tables a
6
+ * reconcile job had already copied this same config into. The config's shape
7
+ * (`schema.ts`) is deliberately a near-mirror of those tables' columns for
8
+ * exactly this reason — see that file's header — so the mapping below is
9
+ * closer to a rename than a translation: `Meeting.id` is the row's `slug`,
10
+ * `Meeting.title` is what the DB called `nameOverride`, and a workshop's
11
+ * `project` (free text in the config) stands in for the DB's
12
+ * `projects.displayName` join.
13
+ */
14
+ export function configEvents() {
3
15
  return {
4
16
  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);
17
+ const { getClubConfig } = await loadEvents();
32
18
  const { EVENT_SEGMENT_VISUALS, meetingCardDetail, meetingLocation } = await loadBrandEvent();
33
- return (meetings.data ?? []).map((row) => {
34
- const agenda = agendas.get(row.id) ?? [];
19
+ // Newest first, matching the DB reader's `order("startsAt", {
20
+ // ascending: false })` — the picker should offer the most recent
21
+ // meeting first, not whatever order the config file happens to list
22
+ // them in.
23
+ const meetings = [...getClubConfig().meetings].sort((a, b) => Date.parse(b.startsAt) - Date.parse(a.startsAt));
24
+ return meetings.map((row) => {
35
25
  const meeting = {
36
- slug: row.slug,
26
+ slug: row.id,
37
27
  startsAt: new Date(row.startsAt),
38
28
  endsAt: new Date(row.endsAt),
39
29
  building: row.building,
@@ -42,20 +32,21 @@ export function supabaseEvents(client) {
42
32
  cancelledAt: row.cancelledAt ? new Date(row.cancelledAt) : null,
43
33
  cancellationReason: row.cancellationReason,
44
34
  };
45
- const items = workshopRows
46
- .filter((workshop) => workshop.meetingId === row.id)
47
- .map((workshop) => ({
35
+ // No competitions here either, for the same reason `supabaseEvents`
36
+ // dropped them: a competition is a mirrored GitHub issue now, not
37
+ // config a meeting kicks off. Every agenda item is plain "workshop".
38
+ const items = row.agenda.map((workshop) => ({
48
39
  label: workshopLabel(workshop),
49
40
  segment: "workshop",
50
41
  }));
51
42
  const detail = meetingCardDetail({
52
43
  meeting,
53
- title: meetingTitle(row.nameOverride, row.kind, meeting.startsAt, agenda),
44
+ title: meetingTitle(row.title, row.kind, meeting.startsAt, items.map((item) => item.label)),
54
45
  agenda: items.map((item) => `${EVENT_SEGMENT_VISUALS[item.segment].label}: ${item.label}`),
55
46
  location: meetingLocation(row.building, row.location),
56
47
  });
57
48
  return {
58
- slug: row.slug,
49
+ slug: row.id,
59
50
  hint: describeMeeting(meeting),
60
51
  detail,
61
52
  items: items.map((item) => {
@@ -83,38 +74,11 @@ function slugPart(label) {
83
74
  .replace(/[^a-z0-9]+/g, "-")
84
75
  .replace(/^-|-$/g, "") || "agenda-item");
85
76
  }
77
+ /** Unlike the old DB `WorkshopRow`, `Workshop.title` is non-nullable in the
78
+ * config schema (`workshopSchema`), so there is no `projects.displayName`
79
+ * fallback left to reach for. */
86
80
  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;
81
+ return row.title;
118
82
  }
119
83
  /**
120
84
  * The meeting's name, in descending order of how much somebody meant it.
@@ -1,4 +1,5 @@
1
1
  import { loadOpenGraph } from "../repo/source.js";
2
+ import { UsageError } from "../ui.js";
2
3
  const BRAND = "apps/platform/public/brand";
3
4
  /** The two renditions anything may need, because anything may reach the GDG platform. */
4
5
  const GDG = ["gdgc-wide", "gdgc-square"];
@@ -226,7 +227,7 @@ export function assertUniqueStems(graphics) {
226
227
  for (const graphic of graphics) {
227
228
  const clash = seen.get(graphic.stem);
228
229
  if (clash) {
229
- throw new Error(`${graphic.name} and ${clash} share the leaf "${graphic.stem}", so a flat --out would overwrite one with the other.`);
230
+ throw new UsageError(`${graphic.name} and ${clash} share the leaf "${graphic.stem}", so a flat --out would overwrite one with the other.`);
230
231
  }
231
232
  seen.set(graphic.stem, graphic.name);
232
233
  }
@@ -1,6 +1,6 @@
1
1
  import { autocompleteMultiselect, isTTY, multiselect, path, select, } from "@clack/prompts";
2
2
  import { loadOpenGraph } from "../repo/source.js";
3
- import { unwrap } from "../ui.js";
3
+ import { unwrap, UsageError } from "../ui.js";
4
4
  import { formatsFor } from "./select.js";
5
5
  /**
6
6
  * What the command asks for when the command line did not say.
@@ -18,7 +18,7 @@ import { formatsFor } from "./select.js";
18
18
  export function requireInteractive(what, flag) {
19
19
  if (isTTY(process.stdout))
20
20
  return;
21
- throw new Error(`No terminal to ask which ${what} to use. Pass ${flag} — this is not an interactive shell.`);
21
+ throw new UsageError(`No terminal to ask which ${what} to use. Pass ${flag} — this is not an interactive shell.`);
22
22
  }
23
23
  /**
24
24
  * Which graphics.
@@ -1,3 +1,4 @@
1
+ import { type DbConnection } from "./db/connection.js";
1
2
  import { findRepoRoot } from "./repo/root.js";
2
3
  /**
3
4
  * The repo root, which is where `supabase/config.toml` lives.
@@ -25,43 +26,44 @@ export interface Instance {
25
26
  publishableKey: string;
26
27
  secretKey: string;
27
28
  }
28
- /** The seeded personas, from `supabase/seed/development/02_moderation.sql`. */
29
- export declare const PERSONAS: {
30
- readonly member: "member@devdogs.test";
31
- readonly author: "author@devdogs.test";
32
- readonly moderator: "moderator@devdogs.test";
33
- };
34
29
  /** The built-in Root role, from `supabase/seed/production/01_roles.sql`. */
35
30
  export declare const ROOT_ROLE_ID = "00000000-0000-0000-0000-000000000002";
36
- export declare const PERSONA_PASSWORD = "password";
37
- /**
38
- * Reads the local stack's credentials from the Supabase CLI.
39
- *
40
- * `-o env` rather than parsing the human-readable table: the table's labels
41
- * have changed between CLI versions, the env output has not.
42
- */
43
- export declare function detectLocalInstance(cwd?: string): Instance;
31
+ export interface ResolvedInstance {
32
+ connection: DbConnection;
33
+ instance: Instance;
34
+ }
35
+ export interface ResolveInstanceOptions {
36
+ /** Stderr prefix, e.g. "devtools grant-root". */
37
+ label?: string;
38
+ /** Injectable for tests; defaults to `process.env`. */
39
+ env?: NodeJS.ProcessEnv;
40
+ }
44
41
  /**
45
- * Confirms the instance has actually been migrated.
42
+ * Resolves the session's database AND the Supabase client credentials for it.
46
43
  *
47
- * This used to be `assertNotProduction()`, which read a tier out of a singleton
48
- * `platform."instance"` table and refused anything reporting itself as
49
- * production. That table is gone, and the tier column was guarding a door that
50
- * was already walled up: `detectLocalInstance()` above reads `supabase status`,
51
- * which only ever describes the Docker stack on this machine, so a remote
52
- * project cannot reach these commands in the first place.
44
+ * Goes through `resolveDbConnection()` first — the exact check `db
45
+ * migrate`/`db reset` run, including the local-stack-offline and
46
+ * inherited-development-values guards — so a caller here refuses in exactly
47
+ * the same situations and with the same messages those commands already do.
48
+ * What this adds is reading `API_URL`/`PUBLISHABLE_KEY`/`SECRET_KEY` out of
49
+ * the same already-entered session env `DB_URL` came from (see this file's
50
+ * header), rather than shelling out to `supabase status` — which is what
51
+ * makes hosted tiers reachable here at all.
53
52
  *
54
- * What was useful was its error message, because "have you run migrations?" is
55
- * the failure a contributor actually hits. That is all this does now.
53
+ * Returns `null` after reporting the reason on stderr, same contract as
54
+ * `resolveDbConnection` — never throws for an expected failure.
56
55
  */
57
- export declare function assertMigrated(instance: Instance): Promise<void>;
56
+ export declare function resolveInstance(opts?: ResolveInstanceOptions): Promise<ResolvedInstance | null>;
58
57
  /** A service-role client. Bypasses RLS, so setup and teardown only. */
59
58
  export declare function adminClient(instance: Instance, schema?: string): DevtoolsClient;
60
59
  /**
61
- * A client signed in as a seeded persona, subject to RLS.
60
+ * A client signed in with a real email/password, subject to RLS.
62
61
  *
63
62
  * Signs in for real rather than hand-signing a JWT, so the token path exercised
64
63
  * is the one production uses, with whatever claims Supabase Auth actually puts
65
- * in a token rather than the ones we assume.
64
+ * in a token rather than the ones we assume. Takes the password explicitly —
65
+ * there is no fixed persona password any more (see `persona.ts` and
66
+ * `moderation.ts`'s `withTemporaryModerator`, both of which generate a fresh
67
+ * random one per account).
66
68
  */
67
- export declare function personaClient(instance: Instance, email: string, schema?: string): Promise<DevtoolsClient>;
69
+ export declare function signedInClient(instance: Instance, email: string, password: string, schema?: string): Promise<DevtoolsClient>;
package/dist/instance.js CHANGED
@@ -1,12 +1,23 @@
1
1
  /**
2
2
  * Finding the instance to work against, and signing in to it.
3
3
  *
4
- * Detection is automatic where possible: `supabase status -o env` already
5
- * prints everything needed, so nobody has to copy a URL and a key from one
6
- * terminal into another.
4
+ * `resolveInstance()` is the one entry point every command that needs a
5
+ * Supabase client — `db`'s own commands excepted, which only ever need a
6
+ * Postgres URL — should call. It goes through the SAME session
7
+ * (`resolveDbConnection()`, `db/connection.ts`) `db migrate`/`db reset` do,
8
+ * rather than a local-only `supabase status` probe: every session's env
9
+ * carries `API_URL`/`PUBLISHABLE_KEY`/`SECRET_KEY` alongside `DB_URL` (see
10
+ * `.env.example`), so whichever tier `--tier` named is exactly the instance
11
+ * this resolves to, local Docker included.
12
+ *
13
+ * This replaces `detectLocalInstance()`+`assertMigrated()`, which read
14
+ * `supabase status -o env` directly and therefore only ever found the Docker
15
+ * stack on this machine — the reason `moderation check` and `grant-root`
16
+ * used to be local-only long after `db reset`/`db migrate` moved onto the
17
+ * session system.
7
18
  */
8
- import { execFileSync } from "node:child_process";
9
19
  import { createClient } from "@supabase/supabase-js";
20
+ import { resolveDbConnection } from "./db/connection.js";
10
21
  import { findRepoRoot } from "./repo/root.js";
11
22
  /**
12
23
  * The repo root, which is where `supabase/config.toml` lives.
@@ -33,97 +44,59 @@ function makeClient(url, key, schema) {
33
44
  auth: { autoRefreshToken: false, persistSession: false },
34
45
  });
35
46
  }
36
- /** The seeded personas, from `supabase/seed/development/02_moderation.sql`. */
37
- export const PERSONAS = {
38
- member: "member@devdogs.test",
39
- author: "author@devdogs.test",
40
- moderator: "moderator@devdogs.test",
41
- };
42
47
  /** The built-in Root role, from `supabase/seed/production/01_roles.sql`. */
43
48
  export const ROOT_ROLE_ID = "00000000-0000-0000-0000-000000000002";
44
- export const PERSONA_PASSWORD = "password";
45
49
  /**
46
- * Reads the local stack's credentials from the Supabase CLI.
50
+ * Resolves the session's database AND the Supabase client credentials for it.
47
51
  *
48
- * `-o env` rather than parsing the human-readable table: the table's labels
49
- * have changed between CLI versions, the env output has not.
50
- */
51
- export function detectLocalInstance(cwd = findRepoRoot()) {
52
- let output;
53
- try {
54
- // Through `pnpm exec` because the Supabase CLI is a workspace devDependency
55
- // rather than a global install. Invoking `supabase` directly works only on
56
- // machines that happen to have it on PATH.
57
- output = execFileSync("pnpm", ["exec", "supabase", "status", "-o", "env"], {
58
- cwd,
59
- encoding: "utf8",
60
- stdio: ["ignore", "pipe", "pipe"],
61
- });
62
- }
63
- catch (err) {
64
- throw new Error(`Could not read the local Supabase stack.\n${err instanceof Error ? err.message : String(err)}`);
65
- }
66
- // The CLI prefixes the block with a "Stopped services: [...]" line whenever
67
- // any optional service is down, which is the normal state here. Lines that do
68
- // not parse as KEY="value" are skipped rather than treated as an error.
69
- const values = new Map();
70
- for (const line of output.split("\n")) {
71
- const match = /^([A-Z0-9_]+)="?([^"]*)"?$/.exec(line.trim());
72
- if (match?.[1])
73
- values.set(match[1], match[2] ?? "");
74
- }
75
- const apiUrl = values.get("API_URL");
76
- // Newer CLI versions print PUBLISHABLE_KEY/SECRET_KEY; older ones print the
77
- // ANON_KEY/SERVICE_ROLE_KEY names. Both are accepted so the tool does not
78
- // break on whichever the contributor happens to have installed.
79
- const publishableKey = values.get("PUBLISHABLE_KEY") ?? values.get("ANON_KEY");
80
- const secretKey = values.get("SECRET_KEY") ?? values.get("SERVICE_ROLE_KEY");
81
- if (!apiUrl || !publishableKey || !secretKey) {
82
- throw new Error("`supabase status` did not report an API URL and keys. Is the stack running?");
83
- }
84
- return { apiUrl, publishableKey, secretKey };
85
- }
86
- /**
87
- * Confirms the instance has actually been migrated.
88
- *
89
- * This used to be `assertNotProduction()`, which read a tier out of a singleton
90
- * `platform."instance"` table and refused anything reporting itself as
91
- * production. That table is gone, and the tier column was guarding a door that
92
- * was already walled up: `detectLocalInstance()` above reads `supabase status`,
93
- * which only ever describes the Docker stack on this machine, so a remote
94
- * project cannot reach these commands in the first place.
52
+ * Goes through `resolveDbConnection()` first — the exact check `db
53
+ * migrate`/`db reset` run, including the local-stack-offline and
54
+ * inherited-development-values guards — so a caller here refuses in exactly
55
+ * the same situations and with the same messages those commands already do.
56
+ * What this adds is reading `API_URL`/`PUBLISHABLE_KEY`/`SECRET_KEY` out of
57
+ * the same already-entered session env `DB_URL` came from (see this file's
58
+ * header), rather than shelling out to `supabase status` — which is what
59
+ * makes hosted tiers reachable here at all.
95
60
  *
96
- * What was useful was its error message, because "have you run migrations?" is
97
- * the failure a contributor actually hits. That is all this does now.
61
+ * Returns `null` after reporting the reason on stderr, same contract as
62
+ * `resolveDbConnection` — never throws for an expected failure.
98
63
  */
99
- export async function assertMigrated(instance) {
100
- const client = makeClient(instance.apiUrl, instance.secretKey, "platform");
101
- const { error } = await client.from("apps").select("slug").limit(1);
102
- if (error) {
103
- throw new Error(`Could not read platform."apps": ${error.message}. ` +
104
- "Have migrations been applied? `pnpm devtools db reset` rebuilds from scratch.");
64
+ export async function resolveInstance(opts = {}) {
65
+ const label = opts.label ?? "devtools";
66
+ const connection = await resolveDbConnection({ label: opts.label });
67
+ if (!connection)
68
+ return null;
69
+ const env = opts.env ?? process.env;
70
+ const apiUrl = env.API_URL;
71
+ const publishableKey = env.PUBLISHABLE_KEY;
72
+ const secretKey = env.SECRET_KEY;
73
+ if (!apiUrl || !publishableKey || !secretKey) {
74
+ process.stderr.write(`${label}: this session has a DB_URL but no API_URL/PUBLISHABLE_KEY/` +
75
+ "SECRET_KEY — run `pnpm devtools env pull` for this tier, or " +
76
+ "`pnpm devtools db start` for the local stack.\n");
77
+ return null;
105
78
  }
79
+ return { connection, instance: { apiUrl, publishableKey, secretKey } };
106
80
  }
107
81
  /** A service-role client. Bypasses RLS, so setup and teardown only. */
108
82
  export function adminClient(instance, schema = "platform") {
109
83
  return makeClient(instance.apiUrl, instance.secretKey, schema);
110
84
  }
111
85
  /**
112
- * A client signed in as a seeded persona, subject to RLS.
86
+ * A client signed in with a real email/password, subject to RLS.
113
87
  *
114
88
  * Signs in for real rather than hand-signing a JWT, so the token path exercised
115
89
  * is the one production uses, with whatever claims Supabase Auth actually puts
116
- * in a token rather than the ones we assume.
90
+ * in a token rather than the ones we assume. Takes the password explicitly —
91
+ * there is no fixed persona password any more (see `persona.ts` and
92
+ * `moderation.ts`'s `withTemporaryModerator`, both of which generate a fresh
93
+ * random one per account).
117
94
  */
118
- export async function personaClient(instance, email, schema = "platform") {
95
+ export async function signedInClient(instance, email, password, schema = "platform") {
119
96
  const client = makeClient(instance.apiUrl, instance.publishableKey, schema);
120
- const { error } = await client.auth.signInWithPassword({
121
- email,
122
- password: PERSONA_PASSWORD,
123
- });
97
+ const { error } = await client.auth.signInWithPassword({ email, password });
124
98
  if (error) {
125
- throw new Error(`Could not sign in as ${email}: ${error.message}. ` +
126
- "Seeded personas come from `pnpm devtools` → Reset database.");
99
+ throw new Error(`Could not sign in as ${email}: ${error.message}.`);
127
100
  }
128
101
  return client;
129
102
  }
package/dist/launch-ci.js CHANGED
@@ -31,8 +31,12 @@
31
31
  import { findRepoRoot } from "./repo/root.js";
32
32
  import { loadEnvLoad, loadEnvSession } from "./repo/peers.js";
33
33
  import { stripTierFlag } from "./launch.js";
34
+ import { captureDevtoolsError, initDevtoolsTelemetry } from "./telemetry.js";
34
35
  export async function launchCi(argv) {
35
36
  const { explicit, rest } = stripTierFlag(argv);
37
+ // See `launch.ts`'s matching call: early, so tier resolution and env entry
38
+ // are covered too. Same tag `ci.ts`'s `main()` would compute.
39
+ initDevtoolsTelemetry(rest.slice(0, 2).join(" ") || "help");
36
40
  const envLoad = await loadEnvLoad();
37
41
  const envSession = await loadEnvSession();
38
42
  const resolution = await envSession.resolveSessionTier({
@@ -70,11 +74,7 @@ export async function launchCi(argv) {
70
74
  }
71
75
  catch (err) {
72
76
  process.stderr.write(`devtools-ci: ${err instanceof Error ? err.message : String(err)}\n`);
73
- // Report BEFORE exiting — see `captureDevtoolsError`'s header. `ci.ts`'s
74
- // own `main()` already initialized telemetry (it does so at its own
75
- // bootstrap, before this throw could happen), so this call only needs to
76
- // capture and flush.
77
- const { captureDevtoolsError } = await import("./telemetry.js");
77
+ // Report BEFORE exiting — see `captureDevtoolsError`'s header.
78
78
  await captureDevtoolsError(err);
79
79
  process.exit(1);
80
80
  }
package/dist/launch.d.ts CHANGED
@@ -8,7 +8,8 @@ export declare function stripTierFlag(argv: readonly string[]): {
8
8
  rest: string[];
9
9
  };
10
10
  /**
11
- * Resolves the session's deploy tier, enters it, and hands off to `cli.ts`.
11
+ * Resolves the session's deploy tier, enters it (or defers entry to the
12
+ * menu — see this file's header), and hands off to `cli.ts`.
12
13
  *
13
14
  * Exits the process directly on a refusal from `resolveSessionTier` — there
14
15
  * is no command dispatched yet for a caller to fall back to, so there is