@devdogsuga/devtools 0.1.12 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/devtools-ci.mjs +15 -1
- package/bin/devtools.mjs +15 -1
- package/dist/catalog.d.ts +7 -5
- package/dist/catalog.js +1 -25
- package/dist/cf/build.d.ts +13 -0
- package/dist/cf/build.js +65 -0
- package/dist/cf/commands.js +6 -16
- package/dist/cf/local-env.d.ts +14 -0
- package/dist/cf/local-env.js +29 -0
- package/dist/ci.js +8 -1
- package/dist/cli.js +134 -138
- package/dist/commands.d.ts +10 -4
- package/dist/commands.js +40 -30
- package/dist/db/generated-env.d.ts +69 -0
- package/dist/db/generated-env.js +113 -0
- package/dist/db/seed-production.js +12 -6
- package/dist/docs/index-pages.js +2 -2
- package/dist/emails/commands.js +3 -3
- package/dist/env-entry.d.ts +49 -0
- package/dist/env-entry.js +204 -0
- package/dist/environment-doctor.d.ts +9 -0
- package/dist/environment-doctor.js +18 -5
- package/dist/environment.d.ts +2 -2
- package/dist/environment.js +5 -28
- package/dist/images/commands.d.ts +3 -6
- package/dist/images/commands.js +26 -30
- package/dist/images/events.d.ts +27 -16
- package/dist/images/events.js +32 -68
- package/dist/images/graphics.js +2 -1
- package/dist/images/prompts.js +2 -2
- package/dist/instance.d.ts +29 -27
- package/dist/instance.js +49 -76
- package/dist/launch-ci.js +5 -5
- package/dist/launch.d.ts +2 -1
- package/dist/launch.js +74 -111
- package/dist/menu.d.ts +17 -5
- package/dist/menu.js +34 -11
- package/dist/moderation.d.ts +48 -40
- package/dist/moderation.js +95 -194
- package/dist/oauth/wizard.d.ts +2 -2
- package/dist/oauth/wizard.js +6 -26
- package/dist/persona.d.ts +18 -0
- package/dist/persona.js +221 -0
- package/dist/repo/peers.d.ts +2 -0
- package/dist/repo/peers.js +3 -0
- package/dist/repo/supabase-project.d.ts +60 -0
- package/dist/repo/supabase-project.js +124 -0
- package/dist/setup.js +2 -2
- package/dist/stack.js +142 -29
- package/dist/telemetry.d.ts +18 -5
- package/dist/telemetry.js +26 -5
- package/dist/ui.d.ts +15 -0
- package/dist/ui.js +20 -0
- package/dist/workflows/commands.d.ts +10 -0
- package/dist/workflows/commands.js +119 -25
- package/package.json +10 -5
package/dist/images/commands.js
CHANGED
|
@@ -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
|
|
16
|
-
* this repo
|
|
17
|
-
*
|
|
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 {
|
|
27
|
-
import {
|
|
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
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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
|
-
* - **
|
|
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
|
-
* - **
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
227
|
+
throw new UsageError(`Nothing called ${unmatched.join(", ")}. Names are group/name — try ` +
|
|
232
228
|
`${groups.join(", ")}, or * for all of them.`);
|
|
233
229
|
}
|
package/dist/images/events.d.ts
CHANGED
|
@@ -1,26 +1,37 @@
|
|
|
1
|
-
import type {
|
|
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
|
|
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
|
|
7
|
-
* function of meetings
|
|
8
|
-
* `@devdogsuga/events
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* the
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
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:
|
|
47
|
+
export declare function meetingTitle(nameOverride: string | null, kind: Meeting["kind"], startsAt: Date, agenda: readonly string[]): string;
|
package/dist/images/events.js
CHANGED
|
@@ -1,39 +1,29 @@
|
|
|
1
|
-
import { loadBrandEvent } from "../repo/peers.js";
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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.
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
package/dist/images/graphics.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/images/prompts.js
CHANGED
|
@@ -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
|
|
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.
|
package/dist/instance.d.ts
CHANGED
|
@@ -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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
*
|
|
42
|
+
* Resolves the session's database AND the Supabase client credentials for it.
|
|
46
43
|
*
|
|
47
|
-
*
|
|
48
|
-
* `
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
-
*
|
|
55
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
50
|
+
* Resolves the session's database AND the Supabase client credentials for it.
|
|
47
51
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
*
|
|
97
|
-
*
|
|
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
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|