@devdogsuga/backstage 0.1.8 → 0.1.10
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/README.md +42 -24
- package/dist/{catalog-BWtUtxMF.js → catalog-CaTnrGVr.js} +20 -10
- package/dist/{cli-D6swhEN-.js → cli-DjIwbjnx.js} +1 -1
- package/dist/{cli-J_avqnlu.js → cli-feRmGaGi.js} +4 -4
- package/dist/commands-C8pXvbv1.js +699 -0
- package/dist/{commands-D-sNogNX.js → commands-CD1v_-6y.js} +3 -3
- package/dist/{commands-Dwr4_CSu.js → commands-KY-9X1Rh.js} +2 -2
- package/dist/{commands-zApyD2Rk.js → commands-Ye0G_5bZ.js} +2 -2
- package/dist/{commands-CQ-vbNe9.js → commands-jJcS7D2a.js} +4 -4
- package/dist/launch.js +2 -2
- package/dist/{meetings-CtBMT_25.js → meetings-Du5asTAj.js} +6 -8
- package/dist/{queries-DeEwfe2c.js → queries-D3mTchag.js} +102 -10
- package/dist/{read-BLyKoe4l.js → read-B8DSwYfB.js} +1 -1
- package/package.json +6 -6
- package/dist/commands-D4FcDQuh.js +0 -225
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { n as isDryRun } from "./dry-run-CFCZUpjb.js";
|
|
2
2
|
import { o as unwrap, r as errorMessage, t as UsageError } from "./ui-Bb69ZfVv.js";
|
|
3
3
|
import { t as DONE } from "./dispatch-D048O65I.js";
|
|
4
|
-
import { a as
|
|
5
|
-
import { a as createAccounts, i as createAccountFor, n as accountFinder, r as accountsWith, t as parseCsv } from "./read-
|
|
6
|
-
import { n as findMeetingsWith, r as pickMeeting, t as describeMeeting } from "./meetings-
|
|
4
|
+
import { a as readInputFile, c as ProductionError, d as requireProductionKey, f as withConnection, i as plural, l as connect, n as confirmWrite, o as reportFailure, s as say, t as canPrompt } from "./cli-DjIwbjnx.js";
|
|
5
|
+
import { a as createAccounts, i as createAccountFor, n as accountFinder, r as accountsWith, t as parseCsv } from "./read-B8DSwYfB.js";
|
|
6
|
+
import { n as findMeetingsWith, r as pickMeeting, t as describeMeeting } from "./meetings-Du5asTAj.js";
|
|
7
7
|
import { text } from "@clack/prompts";
|
|
8
8
|
import { parseArgs } from "node:util";
|
|
9
9
|
//#region src/attendance/plan.ts
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { n as isDryRun } from "./dry-run-CFCZUpjb.js";
|
|
2
2
|
import { r as errorMessage, t as UsageError } from "./ui-Bb69ZfVv.js";
|
|
3
3
|
import { t as DONE } from "./dispatch-D048O65I.js";
|
|
4
|
-
import { a as
|
|
5
|
-
import { a as createAccounts, i as createAccountFor, n as accountFinder, o as readAccounts, t as parseCsv } from "./read-
|
|
4
|
+
import { a as readInputFile, d as requireProductionKey, f as withConnection, i as plural, n as confirmWrite, o as reportFailure, s as say, t as canPrompt } from "./cli-DjIwbjnx.js";
|
|
5
|
+
import { a as createAccounts, i as createAccountFor, n as accountFinder, o as readAccounts, t as parseCsv } from "./read-B8DSwYfB.js";
|
|
6
6
|
import { parseArgs } from "node:util";
|
|
7
7
|
//#region src/involvement/csv.ts
|
|
8
8
|
/**
|
|
@@ -2,8 +2,8 @@ import { i as explain } from "./ui-Bb69ZfVv.js";
|
|
|
2
2
|
//#region src/import/commands.ts
|
|
3
3
|
const handleImport = async (rest) => {
|
|
4
4
|
const [what, ...args] = rest;
|
|
5
|
-
if (what === "involvement") return (await import("./commands-
|
|
6
|
-
if (what === "attendance") return (await import("./commands-
|
|
5
|
+
if (what === "involvement") return (await import("./commands-KY-9X1Rh.js")).handleInvolvement(args);
|
|
6
|
+
if (what === "attendance") return (await import("./commands-CD1v_-6y.js")).handleAttendance(args);
|
|
7
7
|
explain(what ? `Unknown import "${what}". Try involvement or attendance.` : "Name the import: involvement or attendance.", "");
|
|
8
8
|
process.exitCode = 1;
|
|
9
9
|
return null;
|
|
@@ -6,22 +6,22 @@ import { dirname, relative, resolve } from "node:path";
|
|
|
6
6
|
import { autocompleteMultiselect, log, multiselect, note } from "@clack/prompts";
|
|
7
7
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
|
+
import { getClubConfig } from "@devdogsuga/events";
|
|
9
10
|
import { APPS, AppIcon, Banner, EmailSignature, EventCard, FORMATS, OPAQUE_FORMATS, PageCard, THEME } from "@devdogsuga/brand";
|
|
10
11
|
import { EVENT_SEGMENT_VISUALS, meetingCardDetail, meetingLocation } from "@devdogsuga/brand/event";
|
|
11
|
-
import { getClubConfig } from "@devdogsuga/events";
|
|
12
12
|
import { render as render$1 } from "@devdogsuga/brand/render";
|
|
13
13
|
//#region src/graphics/events.ts
|
|
14
14
|
/**
|
|
15
15
|
* Builds the reader from `@devdogsuga/events`'s committed data file.
|
|
16
16
|
*
|
|
17
|
-
* `Meeting.
|
|
17
|
+
* `Meeting.slug` is the card's `slug` (its platform URL) and `Meeting.title` is its authored name
|
|
18
18
|
* (`nameOverride` on the platform's meetings table).
|
|
19
19
|
*/
|
|
20
20
|
function configEvents() {
|
|
21
21
|
return { async meetings() {
|
|
22
22
|
return [...getClubConfig().meetings].sort((a, b) => Date.parse(b.startsAt) - Date.parse(a.startsAt)).map((row) => {
|
|
23
23
|
const meeting = {
|
|
24
|
-
slug: row.
|
|
24
|
+
slug: row.slug,
|
|
25
25
|
startsAt: new Date(row.startsAt),
|
|
26
26
|
endsAt: new Date(row.endsAt),
|
|
27
27
|
building: row.building,
|
|
@@ -41,7 +41,7 @@ function configEvents() {
|
|
|
41
41
|
location: meetingLocation(row.building, row.location)
|
|
42
42
|
});
|
|
43
43
|
return {
|
|
44
|
-
slug: row.
|
|
44
|
+
slug: row.slug,
|
|
45
45
|
hint: describeMeeting(meeting),
|
|
46
46
|
detail,
|
|
47
47
|
items: items.map((item) => {
|
package/dist/launch.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { C as discoverRepoRoot, E as setCliName, S as RepoNotFoundError, T as cliName, _ as installFailureLog, d as hasYes, g as stripTierFlag, h as stripNoEnvFlag, m as setNoEnv, n as captureDevtoolsError, o as initDevtoolsTelemetry, p as isNonInteractive, s as lastSentryEventId } from "./telemetry-Be_lbB5c.js";
|
|
2
|
-
import { c as setMenuEnvHook, d as formatCommand, f as helpPath, n as bareGroupStartPath, t as catalog } from "./catalog-
|
|
2
|
+
import { c as setMenuEnvHook, d as formatCommand, f as helpPath, n as bareGroupStartPath, t as catalog } from "./catalog-CaTnrGVr.js";
|
|
3
3
|
import { i as loadEnvSession, r as loadEnvLoad } from "./peers-u1zxe-Ub.js";
|
|
4
4
|
import { t as nonEmpty } from "./connection-D3XsOE3S.js";
|
|
5
5
|
import { i as setDryRun, n as isDryRun, r as resolveDryRun } from "./dry-run-CFCZUpjb.js";
|
|
@@ -322,7 +322,7 @@ async function launchWith(argv, options) {
|
|
|
322
322
|
*/
|
|
323
323
|
async function launch(argv) {
|
|
324
324
|
await launchWith(argv, { dispatch: async (args) => {
|
|
325
|
-
const { main } = await import("./cli-
|
|
325
|
+
const { main } = await import("./cli-feRmGaGi.js");
|
|
326
326
|
await main(args);
|
|
327
327
|
} });
|
|
328
328
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { t as UsageError } from "./ui-Bb69ZfVv.js";
|
|
2
|
-
import "./cli-
|
|
2
|
+
import "./cli-DjIwbjnx.js";
|
|
3
3
|
//#region src/production/time.ts
|
|
4
4
|
/**
|
|
5
5
|
* Dates as officers type them. Meetings happen in Athens, so a bare
|
|
@@ -64,18 +64,17 @@ function parseBound(flag, text) {
|
|
|
64
64
|
//#endregion
|
|
65
65
|
//#region src/production/meetings.ts
|
|
66
66
|
/**
|
|
67
|
-
* Naming a meeting on the command line: `--meeting <day|slug|
|
|
67
|
+
* Naming a meeting on the command line: `--meeting <day|slug|uuid>`.
|
|
68
68
|
*
|
|
69
69
|
* Slugs are the meeting's Eastern date (`2026-09-09`), with `-2` for a second
|
|
70
70
|
* meeting that day, so a day is how officers will usually name one. A day with
|
|
71
71
|
* two meetings is ambiguous and the error lists both slugs rather than picking.
|
|
72
72
|
*/
|
|
73
|
-
/** Live meetings an argument could mean: by id
|
|
73
|
+
/** Live meetings an argument could mean: by id or slug, or on that Eastern day. */
|
|
74
74
|
const MEETING_QUERY = `
|
|
75
75
|
select
|
|
76
76
|
m."id"::text as "id",
|
|
77
77
|
m."slug",
|
|
78
|
-
m."configId",
|
|
79
78
|
coalesce(m."nameOverride", m."kind", 'Meeting') as "title",
|
|
80
79
|
m."startsAt",
|
|
81
80
|
m."endsAt",
|
|
@@ -84,7 +83,7 @@ select
|
|
|
84
83
|
from "platform"."meetings" m
|
|
85
84
|
where m."deletedAt" is null
|
|
86
85
|
and (
|
|
87
|
-
m."id"::text = $1 or m."slug" = $1
|
|
86
|
+
m."id"::text = $1 or m."slug" = $1
|
|
88
87
|
or ($2::boolean and (m."startsAt" at time zone '${EVENT_TZ}')::date = $1::date)
|
|
89
88
|
)
|
|
90
89
|
order by m."startsAt"
|
|
@@ -94,7 +93,6 @@ function findMeetingsWith(sql) {
|
|
|
94
93
|
return (await sql.unsafe(MEETING_QUERY, [named, isDay(named)])).map((r) => ({
|
|
95
94
|
id: String(r.id),
|
|
96
95
|
slug: String(r.slug),
|
|
97
|
-
configId: r.configId ?? null,
|
|
98
96
|
title: String(r.title),
|
|
99
97
|
startsAt: r.startsAt,
|
|
100
98
|
endsAt: r.endsAt,
|
|
@@ -105,10 +103,10 @@ function findMeetingsWith(sql) {
|
|
|
105
103
|
}
|
|
106
104
|
/** The one meeting `named` means, or a usage error saying why there isn't one. */
|
|
107
105
|
function pickMeeting(named, found) {
|
|
108
|
-
const exact = found.filter((m) => m.id === named || m.slug === named
|
|
106
|
+
const exact = found.filter((m) => m.id === named || m.slug === named);
|
|
109
107
|
const candidates = exact.length > 0 ? exact : found;
|
|
110
108
|
if (candidates.length === 1) return candidates[0];
|
|
111
|
-
if (candidates.length === 0) throw new UsageError(`No meeting matches "${named}". Name it by day (2026-09-09), slug
|
|
109
|
+
if (candidates.length === 0) throw new UsageError(`No meeting matches "${named}". Name it by day (2026-09-09), slug or id.`);
|
|
112
110
|
throw new UsageError(`"${named}" could be ${candidates.map((m) => `${m.slug} (${m.title})`).join(" or ")}. Pass the slug.`);
|
|
113
111
|
}
|
|
114
112
|
function describeMeeting(m) {
|
|
@@ -1,17 +1,21 @@
|
|
|
1
|
+
import { describeAnswer } from "@devdogsuga/events";
|
|
1
2
|
//#region src/export/queries.ts
|
|
2
3
|
/**
|
|
3
|
-
* The three
|
|
4
|
-
* columns unchanged: anything downstream that read
|
|
5
|
-
* `backstage export stars` the same way.
|
|
4
|
+
* The exports. The first three moved from the platform's `server/export/`
|
|
5
|
+
* with their columns unchanged: anything downstream that read
|
|
6
|
+
* `/export/stars` reads `backstage export stars` the same way. `responses`
|
|
7
|
+
* (the check-in survey's answers) was born here.
|
|
6
8
|
*
|
|
7
|
-
* Each query selects
|
|
8
|
-
*
|
|
9
|
+
* Each query selects its columns, aliased to the header names, in header
|
|
10
|
+
* order; attendance also selects the names and UGA address the Bevy and
|
|
11
|
+
* Involvement Network formats need (see `formats.ts`), past its columns. Parameters: `$1` from (inclusive), `$2` to (exclusive), `$3`
|
|
9
12
|
* a meeting id; each null when not given.
|
|
10
13
|
*/
|
|
11
14
|
const EXPORT_KINDS = [
|
|
12
15
|
"stars",
|
|
13
16
|
"attendance",
|
|
14
|
-
"reflections"
|
|
17
|
+
"reflections",
|
|
18
|
+
"responses"
|
|
15
19
|
];
|
|
16
20
|
/** The member columns every export leads with. */
|
|
17
21
|
const MEMBER = (userId) => `
|
|
@@ -33,6 +37,90 @@ const MEMBER_COLUMNS = [
|
|
|
33
37
|
const MEMBER_JOINS = (userId) => `
|
|
34
38
|
left join "platform"."profile" p on p."userId" = ${userId}
|
|
35
39
|
left join "auth"."users" u on u."id" = ${userId}`;
|
|
40
|
+
/**
|
|
41
|
+
* The survey answers one export reads: every current answer, or for one
|
|
42
|
+
* meeting (`$3`), that meeting's answers plus each attendee's member answers
|
|
43
|
+
* AS THEY STOOD WHEN IT ENDED -- the latest revision at or before its end,
|
|
44
|
+
* else the first after it (a member who answered on the way out), from the
|
|
45
|
+
* append-only `surveyAnswerRevisions`. A member answer cleared by then is
|
|
46
|
+
* left out. `--from`/`--to` filter the unscoped case on when the answer was
|
|
47
|
+
* last changed.
|
|
48
|
+
*/
|
|
49
|
+
const PICKED_ANSWERS = `
|
|
50
|
+
picked as (
|
|
51
|
+
select a."userId", a."questionId", a."meetingId", a.answer,
|
|
52
|
+
a."updatedAt" as "answeredAt"
|
|
53
|
+
from "platform"."surveyAnswers" a
|
|
54
|
+
where $3::uuid is null
|
|
55
|
+
and ($1::timestamptz is null or a."updatedAt" >= $1::timestamptz)
|
|
56
|
+
and ($2::timestamptz is null or a."updatedAt" < $2::timestamptz)
|
|
57
|
+
|
|
58
|
+
union all
|
|
59
|
+
|
|
60
|
+
select a."userId", a."questionId", a."meetingId", a.answer, a."updatedAt"
|
|
61
|
+
from "platform"."surveyAnswers" a
|
|
62
|
+
where a."meetingId" = $3::uuid
|
|
63
|
+
|
|
64
|
+
union all
|
|
65
|
+
|
|
66
|
+
select "userId", "questionId", null::uuid, answer, "recordedAt"
|
|
67
|
+
from (
|
|
68
|
+
select distinct on (r."userId", r."questionId")
|
|
69
|
+
r."userId", r."questionId", r.answer, r."recordedAt"
|
|
70
|
+
from "platform"."surveyAnswerRevisions" r
|
|
71
|
+
join "platform"."attendance" att
|
|
72
|
+
on att."userId" = r."userId" and att."meetingId" = $3::uuid
|
|
73
|
+
join "platform"."meetings" ended on ended.id = $3::uuid
|
|
74
|
+
where r."meetingId" is null
|
|
75
|
+
order by r."userId", r."questionId",
|
|
76
|
+
r."recordedAt" > ended."endsAt",
|
|
77
|
+
case when r."recordedAt" <= ended."endsAt" then r."recordedAt" end
|
|
78
|
+
desc nulls last,
|
|
79
|
+
r."recordedAt"
|
|
80
|
+
) as stood
|
|
81
|
+
where answer is not null
|
|
82
|
+
)`;
|
|
83
|
+
/**
|
|
84
|
+
* One row per survey answer, worded with the question's current definition
|
|
85
|
+
* (so a relabelled option exports under its new label) beside the stored
|
|
86
|
+
* value, for anything that needs option ids.
|
|
87
|
+
*/
|
|
88
|
+
const RESPONSES = {
|
|
89
|
+
summary: "One row per survey answer; --meeting for one meeting's.",
|
|
90
|
+
columns: [
|
|
91
|
+
...MEMBER_COLUMNS,
|
|
92
|
+
"meeting_slug",
|
|
93
|
+
"meeting_title",
|
|
94
|
+
"question_id",
|
|
95
|
+
"question_scope",
|
|
96
|
+
"question_prompt",
|
|
97
|
+
"answer",
|
|
98
|
+
"answer_value",
|
|
99
|
+
"answered_at"
|
|
100
|
+
],
|
|
101
|
+
sql: `
|
|
102
|
+
with ${PICKED_ANSWERS}
|
|
103
|
+
select ${MEMBER("pa.\"userId\"")},
|
|
104
|
+
m."slug" as "meeting_slug",
|
|
105
|
+
case when m.id is not null
|
|
106
|
+
then coalesce(m."nameOverride", m."kind", 'Meeting') end as "meeting_title",
|
|
107
|
+
q.id as "question_id",
|
|
108
|
+
q.scope as "question_scope",
|
|
109
|
+
q.definition ->> 'prompt' as "question_prompt",
|
|
110
|
+
q.definition as "definition",
|
|
111
|
+
pa.answer as "answer_json",
|
|
112
|
+
pa."answeredAt" as "answered_at"
|
|
113
|
+
from picked pa
|
|
114
|
+
join "platform"."surveyQuestions" q on q.id = pa."questionId"
|
|
115
|
+
left join "platform"."meetings" m on m.id = pa."meetingId"
|
|
116
|
+
${MEMBER_JOINS("pa.\"userId\"")}
|
|
117
|
+
order by pa."userId", q.scope, q.id, m."startsAt" nulls first`,
|
|
118
|
+
transform: (row) => ({
|
|
119
|
+
...row,
|
|
120
|
+
answer: describeAnswer(row.definition, row.answer_json),
|
|
121
|
+
answer_value: JSON.stringify(row.answer_json)
|
|
122
|
+
})
|
|
123
|
+
};
|
|
36
124
|
const EXPORTS = {
|
|
37
125
|
/**
|
|
38
126
|
* One row per earned star: a meeting attended or a competition entered.
|
|
@@ -80,7 +168,7 @@ order by ms."startsAt", ms."userId", ms."activityType", ms."activityId"`
|
|
|
80
168
|
summary: "One row per check-in: who, which meeting, when, and how.",
|
|
81
169
|
columns: [
|
|
82
170
|
...MEMBER_COLUMNS,
|
|
83
|
-
"
|
|
171
|
+
"meeting_slug",
|
|
84
172
|
"meeting_title",
|
|
85
173
|
"meeting_starts_at",
|
|
86
174
|
"checked_in_at",
|
|
@@ -89,12 +177,15 @@ order by ms."startsAt", ms."userId", ms."activityType", ms."activityId"`
|
|
|
89
177
|
],
|
|
90
178
|
sql: `
|
|
91
179
|
select ${MEMBER("a.\"userId\"")},
|
|
92
|
-
m."
|
|
180
|
+
m."slug" as "meeting_slug",
|
|
93
181
|
coalesce(m."nameOverride", m."kind", 'Meeting') as "meeting_title",
|
|
94
182
|
m."startsAt" as "meeting_starts_at",
|
|
95
183
|
a."recordedAt" as "checked_in_at",
|
|
96
184
|
a."method"::text as "check_in_method",
|
|
97
|
-
m."countsForCredit" as "counts_for_credit"
|
|
185
|
+
m."countsForCredit" as "counts_for_credit",
|
|
186
|
+
lower(p."ugaEmail") as "uga_email",
|
|
187
|
+
coalesce(p."involvementFirstName", p."legalFirstName") as "first_name",
|
|
188
|
+
coalesce(p."involvementLastName", p."legalLastName") as "last_name"
|
|
98
189
|
from "platform"."attendance" a
|
|
99
190
|
join "platform"."meetings" m on m."id" = a."meetingId"
|
|
100
191
|
${MEMBER_JOINS("a.\"userId\"")}
|
|
@@ -146,7 +237,8 @@ where ($1::timestamptz is null or r."createdAt" >= $1::timestamptz)
|
|
|
146
237
|
and ($2::timestamptz is null or r."createdAt" < $2::timestamptz)
|
|
147
238
|
and $3::uuid is null
|
|
148
239
|
order by r."createdAt", r."userId", r."id"`
|
|
149
|
-
}
|
|
240
|
+
},
|
|
241
|
+
responses: RESPONSES
|
|
150
242
|
};
|
|
151
243
|
function exportParams(filters) {
|
|
152
244
|
return [
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { r as errorMessage } from "./ui-Bb69ZfVv.js";
|
|
2
|
-
import {
|
|
2
|
+
import { c as ProductionError, f as withConnection } from "./cli-DjIwbjnx.js";
|
|
3
3
|
//#region src/production/accounts.ts
|
|
4
4
|
/**
|
|
5
5
|
* Production accounts: reading them, finding one by email, and creating the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devdogsuga/backstage",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Officer and production CLI for DevDogsUGA: deploys, env sync with Bitwarden and GitHub, the migration planner role, club graphics and QR codes, GitHub rulesets and settings, and the newsletter. Ships built JS; run it anywhere with `pnpm dlx @devdogsuga/backstage` (no checkout needed to start; commands that read a checkout say so).",
|
|
@@ -40,10 +40,10 @@
|
|
|
40
40
|
"tsx": "^4.23.12",
|
|
41
41
|
"typescript": "^6.0.3",
|
|
42
42
|
"zod": "4.4.3",
|
|
43
|
-
"@devdogsuga/
|
|
44
|
-
"@devdogsuga/
|
|
45
|
-
"@devdogsuga/
|
|
46
|
-
"@devdogsuga/
|
|
43
|
+
"@devdogsuga/brand": "0.1.6",
|
|
44
|
+
"@devdogsuga/telemetry": "0.1.4",
|
|
45
|
+
"@devdogsuga/events": "0.1.7",
|
|
46
|
+
"@devdogsuga/newsletter": "0.1.12"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"@devdogsuga/db": "*",
|
|
@@ -64,9 +64,9 @@
|
|
|
64
64
|
"eslint": "^9.39.5",
|
|
65
65
|
"tsdown": "^0.23.0",
|
|
66
66
|
"vitest": "^4.1.11",
|
|
67
|
+
"@devdogsuga/cli-core": "0.0.0",
|
|
67
68
|
"@devdogsuga/config": "0.1.2",
|
|
68
69
|
"@devdogsuga/db": "0.1.3",
|
|
69
|
-
"@devdogsuga/cli-core": "0.0.0",
|
|
70
70
|
"@devdogsuga/env": "0.1.5"
|
|
71
71
|
},
|
|
72
72
|
"scripts": {
|
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
import { n as isDryRun } from "./dry-run-CFCZUpjb.js";
|
|
2
|
-
import { r as errorMessage, t as UsageError } from "./ui-Bb69ZfVv.js";
|
|
3
|
-
import { i as exportParams, n as EXPORT_KINDS, r as auditFilters, t as EXPORTS } from "./queries-DeEwfe2c.js";
|
|
4
|
-
import { t as DONE } from "./dispatch-D048O65I.js";
|
|
5
|
-
import { a as reportFailure, c as connect, l as dbError, o as say, r as plural, s as ProductionError, u as requireProductionKey } from "./cli-D6swhEN-.js";
|
|
6
|
-
import { i as parseBound, n as findMeetingsWith, r as pickMeeting, t as describeMeeting } from "./meetings-CtBMT_25.js";
|
|
7
|
-
import { open, rm } from "node:fs/promises";
|
|
8
|
-
import { execFile } from "node:child_process";
|
|
9
|
-
import { parseArgs, promisify } from "node:util";
|
|
10
|
-
/**
|
|
11
|
-
* Quotes a field only when it has to be: a comma, a quote, a carriage return
|
|
12
|
-
* or a line feed. Quoting only what needs it keeps the file readable in a
|
|
13
|
-
* terminal.
|
|
14
|
-
*
|
|
15
|
-
* A leading `=`, `+`, `-`, `@`, tab or carriage return gets a tab prefix:
|
|
16
|
-
* spreadsheets start a formula on the first four, and a member can name their
|
|
17
|
-
* team `=cmd|'/c calc'!A1`. The tab is invisible in a cell and neutralises it.
|
|
18
|
-
*/
|
|
19
|
-
function csvField(value) {
|
|
20
|
-
if (value === null || value === void 0) return "";
|
|
21
|
-
let text;
|
|
22
|
-
if (typeof value === "string") text = value;
|
|
23
|
-
else if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") text = String(value);
|
|
24
|
-
else if (value instanceof Date) text = csvTimestamp(value);
|
|
25
|
-
else text = JSON.stringify(value) ?? "";
|
|
26
|
-
const guarded = /^[=+\-@\t\r]/.test(text) ? `\t${text}` : text;
|
|
27
|
-
if (!/[",\r\n]/.test(guarded)) return guarded;
|
|
28
|
-
return `"${guarded.replace(/"/g, "\"\"")}"`;
|
|
29
|
-
}
|
|
30
|
-
function csvRow(values) {
|
|
31
|
-
return values.map(csvField).join(",") + "\r\n";
|
|
32
|
-
}
|
|
33
|
-
/**
|
|
34
|
-
* ISO 8601 with an explicit offset, never a bare local time: a timestamp
|
|
35
|
-
* without one is read in whatever zone the importer runs in.
|
|
36
|
-
*/
|
|
37
|
-
function csvTimestamp(value) {
|
|
38
|
-
if (value === null) return "";
|
|
39
|
-
const date = value instanceof Date ? value : new Date(value);
|
|
40
|
-
return Number.isNaN(date.getTime()) ? "" : date.toISOString();
|
|
41
|
-
}
|
|
42
|
-
//#endregion
|
|
43
|
-
//#region src/export/commands.ts
|
|
44
|
-
/**
|
|
45
|
-
* `backstage export <stars|attendance|reflections>`: the platform's CSV
|
|
46
|
-
* downloads, moved here from `/console/exports`.
|
|
47
|
-
*
|
|
48
|
-
* pnpm backstage export stars --from 2026-08-17 --to 2026-12-12
|
|
49
|
-
* pnpm backstage export attendance --meeting 2026-09-09 --out -
|
|
50
|
-
*
|
|
51
|
-
* ## Audited, as the downloads were
|
|
52
|
-
*
|
|
53
|
-
* Each export writes a `platform.exportAudit` row BEFORE the first row is
|
|
54
|
-
* read, as the web routes did: an export that fails halfway still put rows
|
|
55
|
-
* in front of somebody. The row names the officer's platform account, found
|
|
56
|
-
* through the GitHub login `gh` is signed in as (the identity they linked on
|
|
57
|
-
* the platform), so an export nobody can attribute is refused rather than
|
|
58
|
-
* recorded as nobody's. The row count is filled in when the file is done.
|
|
59
|
-
*
|
|
60
|
-
* ## The file
|
|
61
|
-
*
|
|
62
|
-
* Written to `./<kind>[-<meeting>][-<from>][-<to>].csv` by default, created
|
|
63
|
-
* readable by its owner only (it is member PII), never over an existing file
|
|
64
|
-
* without `--yes`. `--out -` streams to stdout without the byte-order mark,
|
|
65
|
-
* for a pipe. A failure partway removes the partial file.
|
|
66
|
-
*
|
|
67
|
-
* `--from`/`--to` take Eastern days, `--to` inclusive (see `production/time.ts`).
|
|
68
|
-
*/
|
|
69
|
-
function parse(argv) {
|
|
70
|
-
const [kind, ...rest] = argv;
|
|
71
|
-
if (!kind || !EXPORT_KINDS.includes(kind)) throw new UsageError(kind ? `Unknown export "${kind}". Try ${EXPORT_KINDS.join(", ")}.` : `Name the export: ${EXPORT_KINDS.join(", ")}.`);
|
|
72
|
-
try {
|
|
73
|
-
const { values } = parseArgs({
|
|
74
|
-
args: rest,
|
|
75
|
-
options: {
|
|
76
|
-
from: { type: "string" },
|
|
77
|
-
to: { type: "string" },
|
|
78
|
-
meeting: { type: "string" },
|
|
79
|
-
out: { type: "string" },
|
|
80
|
-
yes: { type: "boolean" },
|
|
81
|
-
"dry-run": { type: "boolean" }
|
|
82
|
-
},
|
|
83
|
-
allowPositionals: false,
|
|
84
|
-
strict: true
|
|
85
|
-
});
|
|
86
|
-
if (values.meeting && kind !== "attendance") throw new UsageError("--meeting only applies to the attendance export.");
|
|
87
|
-
return {
|
|
88
|
-
kind,
|
|
89
|
-
values
|
|
90
|
-
};
|
|
91
|
-
} catch (err) {
|
|
92
|
-
if (err instanceof UsageError) throw err;
|
|
93
|
-
throw new UsageError(errorMessage(err));
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
const run = promisify(execFile);
|
|
97
|
-
/** The GitHub login `gh` is signed in as. */
|
|
98
|
-
async function ghLogin() {
|
|
99
|
-
try {
|
|
100
|
-
const { stdout } = await run("gh", [
|
|
101
|
-
"api",
|
|
102
|
-
"user",
|
|
103
|
-
"--jq",
|
|
104
|
-
".login"
|
|
105
|
-
]);
|
|
106
|
-
const login = stdout.trim();
|
|
107
|
-
if (login) return login;
|
|
108
|
-
} catch {}
|
|
109
|
-
throw new ProductionError("Exports are audited under your GitHub login, and `gh` could not say who you are. Install it from https://cli.github.com and run `gh auth login`.");
|
|
110
|
-
}
|
|
111
|
-
const OFFICER_QUERY = `
|
|
112
|
-
select distinct i."user_id"::text as "userId"
|
|
113
|
-
from "auth"."identities" i
|
|
114
|
-
where i."provider" = 'github'
|
|
115
|
-
and lower(i."identity_data" ->> 'user_name') = lower($1)
|
|
116
|
-
`;
|
|
117
|
-
function sourceWith(sql) {
|
|
118
|
-
return {
|
|
119
|
-
officersFor: async (login) => (await sql.unsafe(OFFICER_QUERY, [login])).map((r) => String(r.userId)),
|
|
120
|
-
findMeetings: findMeetingsWith(sql),
|
|
121
|
-
audit: async (userId, kind, filters) => {
|
|
122
|
-
const [row] = await sql.unsafe(`insert into "platform"."exportAudit" ("userId", "kind", "filters")
|
|
123
|
-
values ($1, $2, $3::text::jsonb) returning "id"::text as "id"`, [
|
|
124
|
-
userId,
|
|
125
|
-
kind,
|
|
126
|
-
JSON.stringify(filters)
|
|
127
|
-
]);
|
|
128
|
-
return String(row.id);
|
|
129
|
-
},
|
|
130
|
-
rows: (kind, filters) => sql.unsafe(EXPORTS[kind].sql, exportParams(filters)).cursor(500),
|
|
131
|
-
finish: async (auditId, rowCount) => {
|
|
132
|
-
await sql.unsafe(`update "platform"."exportAudit" set "rowCount" = $1 where "id" = $2::uuid`, [rowCount, auditId]);
|
|
133
|
-
},
|
|
134
|
-
close: () => sql.end({ timeout: 5 })
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
async function productionSource() {
|
|
138
|
-
return sourceWith(connect(await requireProductionKey("DB_URL")));
|
|
139
|
-
}
|
|
140
|
-
async function fileSink(path, overwrite) {
|
|
141
|
-
if (path === "-") return {
|
|
142
|
-
write: (text) => new Promise((resolve, reject) => process.stdout.write(text, (err) => err ? reject(err) : resolve())),
|
|
143
|
-
close: () => Promise.resolve(),
|
|
144
|
-
discard: () => Promise.resolve()
|
|
145
|
-
};
|
|
146
|
-
let handle;
|
|
147
|
-
try {
|
|
148
|
-
handle = await open(path, overwrite ? "w" : "wx", 384);
|
|
149
|
-
} catch (err) {
|
|
150
|
-
if (err.code === "EEXIST") throw new UsageError(`${path} already exists. Pass --yes to replace it.`);
|
|
151
|
-
throw new UsageError(`Could not write ${path}.`);
|
|
152
|
-
}
|
|
153
|
-
await handle.write("");
|
|
154
|
-
return {
|
|
155
|
-
write: async (text) => {
|
|
156
|
-
await handle.write(text);
|
|
157
|
-
},
|
|
158
|
-
close: () => handle.close(),
|
|
159
|
-
discard: async () => {
|
|
160
|
-
await handle.close().catch(() => void 0);
|
|
161
|
-
await rm(path, { force: true });
|
|
162
|
-
}
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
function defaultPath(kind, values, meetingSlug) {
|
|
166
|
-
const parts = [kind];
|
|
167
|
-
if (meetingSlug) parts.push(meetingSlug);
|
|
168
|
-
if (values.from) parts.push(values.from.slice(0, 10));
|
|
169
|
-
if (values.to) parts.push(values.to.slice(0, 10));
|
|
170
|
-
return `${parts.join("-")}.csv`;
|
|
171
|
-
}
|
|
172
|
-
async function runExport(argv, deps = {}) {
|
|
173
|
-
let source;
|
|
174
|
-
let sink;
|
|
175
|
-
let done = false;
|
|
176
|
-
try {
|
|
177
|
-
const { kind, values } = parse(argv);
|
|
178
|
-
const filters = {
|
|
179
|
-
from: parseBound("--from", values.from),
|
|
180
|
-
to: parseBound("--to", values.to)
|
|
181
|
-
};
|
|
182
|
-
if (filters.from && filters.to && filters.from >= filters.to) throw new UsageError("--from must come before --to.");
|
|
183
|
-
const login = await (deps.ghLogin ?? ghLogin)();
|
|
184
|
-
source = await (deps.source ?? productionSource)();
|
|
185
|
-
const officers = await source.officersFor(login);
|
|
186
|
-
if (officers.length !== 1) throw new ProductionError(officers.length === 0 ? `No platform account has linked the GitHub login ${login}. Link GitHub on your platform profile, then export again.` : `${plural(officers.length, "platform account")} linked the GitHub login ${login}; an export must be attributable to one.`);
|
|
187
|
-
let slug;
|
|
188
|
-
if (values.meeting) {
|
|
189
|
-
const meeting = pickMeeting(values.meeting, await source.findMeetings(values.meeting));
|
|
190
|
-
filters.meetingId = meeting.id;
|
|
191
|
-
slug = meeting.slug;
|
|
192
|
-
say(`Meeting: ${describeMeeting(meeting)}.`);
|
|
193
|
-
}
|
|
194
|
-
const path = values.out ?? defaultPath(kind, values, slug);
|
|
195
|
-
if (values["dry-run"] === true || isDryRun()) {
|
|
196
|
-
say(`Would export ${kind} to ${path === "-" ? "stdout" : path}, audited as ${login}.`);
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
sink = await (deps.sink ?? fileSink)(path, values.yes === true);
|
|
200
|
-
const auditId = await source.audit(officers[0], kind, auditFilters(filters));
|
|
201
|
-
const { columns } = EXPORTS[kind];
|
|
202
|
-
await sink.write(csvRow(columns));
|
|
203
|
-
let count = 0;
|
|
204
|
-
for await (const page of source.rows(kind, filters)) {
|
|
205
|
-
await sink.write(page.map((row) => csvRow(columns.map((c) => row[c]))).join(""));
|
|
206
|
-
count += page.length;
|
|
207
|
-
}
|
|
208
|
-
await sink.close();
|
|
209
|
-
done = true;
|
|
210
|
-
await source.finish(auditId, count);
|
|
211
|
-
say(`Exported ${plural(count, "row")} to ${path === "-" ? "stdout" : path}.`, "success");
|
|
212
|
-
} catch (err) {
|
|
213
|
-
if (sink && !done) await sink.discard().catch(() => void 0);
|
|
214
|
-
const worded = err instanceof UsageError || err instanceof ProductionError ? err : err.code ? dbError("The export failed", err) : err;
|
|
215
|
-
reportFailure("export", worded);
|
|
216
|
-
} finally {
|
|
217
|
-
await source?.close().catch(() => void 0);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
const handleExport = async (rest) => {
|
|
221
|
-
await runExport(rest);
|
|
222
|
-
return process.exitCode ? null : DONE;
|
|
223
|
-
};
|
|
224
|
-
//#endregion
|
|
225
|
-
export { handleExport };
|