@devdogsuga/backstage 0.1.5 → 0.1.7
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 +82 -17
- package/dist/{catalog-BUOwCd2Q.js → catalog-BiXI0wyi.js} +134 -6
- package/dist/{cli-B2S6gw9o.js → cli-Cz8faASC.js} +132 -11
- package/dist/cli-Do2nWD7W.js +136 -0
- package/dist/{client-B7HhuTaA.js → client-B_MY9kVI.js} +2 -1
- package/dist/client-DwDGjalU.js +2 -0
- package/dist/commands-5Ou8xJw5.js +225 -0
- package/dist/commands-BQ2NIqub.js +436 -0
- package/dist/commands-CLSxKz-l.js +352 -0
- package/dist/commands-DzALVX5g.js +12 -0
- package/dist/{commands-Bk7IbhPD.js → commands-Sx1fDNNn.js} +2 -1
- package/dist/{commands-BMQ1OZIr.js → commands-zPSnOGzH.js} +3 -2
- package/dist/document-DumoZXoO.js +287 -0
- package/dist/dry-run-3IxPmCtW.js +58 -0
- package/dist/launch.js +3 -3
- package/dist/meetings-DfU867eV.js +118 -0
- package/dist/{options-BTjOf5KP.js → options-DCaqm8BI.js} +1 -57
- package/dist/queries-DeEwfe2c.js +170 -0
- package/dist/read-B-aJJewb.js +141 -0
- package/dist/{vault-C8LYJFOQ.js → vault-CsBC-1Mq.js} +1 -287
- package/env.ts +16 -1
- package/package.json +3 -3
- package/dist/client-DzXQHd58.js +0 -2
package/README.md
CHANGED
|
@@ -14,7 +14,8 @@ match. The two flags matter outside a DevDogsUGA checkout, where the workspace's
|
|
|
14
14
|
`pnpm backstage` is the script).
|
|
15
15
|
|
|
16
16
|
It **starts without a checkout**. Help, `version`, `completions`, the tools
|
|
17
|
-
below that need no secrets (`graphics`, `qr`, `github`, `newsletter`, `creds`)
|
|
17
|
+
below that need no secrets (`graphics`, `qr`, `github`, `newsletter`, `creds`),
|
|
18
|
+
`import`, `export` and
|
|
18
19
|
anything run with `--no-env` never look for one; a command that reads a checkout says
|
|
19
20
|
"run this from inside a DevDogsUGA clone" and exits 1. The DevDogsUGA libraries
|
|
20
21
|
it reads (`@devdogsuga/env`, `@devdogsuga/db`) are optional peers, resolved
|
|
@@ -33,25 +34,32 @@ pnpm backstage qr https://devdogsuga.org --format svg,png,webp --logo acm
|
|
|
33
34
|
pnpm backstage newsletter send 3.0.1 --to a@uga.edu # asks first; --yes with no terminal
|
|
34
35
|
pnpm backstage creds # share a club login with officers, as a Bitwarden Send
|
|
35
36
|
pnpm backstage creds renew # extend every Send 30 days
|
|
37
|
+
pnpm backstage import involvement --file OrganizationRoster.csv # verify members; previews, then asks
|
|
38
|
+
pnpm backstage import attendance --meeting 2026-09-09 --file sign-in.csv
|
|
39
|
+
pnpm backstage export stars --from 2026-08-17 # audited under your gh login
|
|
36
40
|
```
|
|
37
41
|
|
|
38
42
|
## Commands
|
|
39
43
|
|
|
40
|
-
| Command
|
|
41
|
-
|
|
|
42
|
-
| `deploy <app> --tier <t>`
|
|
43
|
-
| `deploy write-env`
|
|
44
|
-
| `deploy preflight`
|
|
45
|
-
| `deploy plan`, `deploy migrate`
|
|
46
|
-
| `deploy smoke --tier <t> [--app]`
|
|
47
|
-
| `deploy reconcile --tier <t>`
|
|
48
|
-
| `
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
52
|
-
| `
|
|
53
|
-
| `
|
|
54
|
-
| `
|
|
44
|
+
| Command | What it does |
|
|
45
|
+
| ------------------------------------------------ | -------------------------------------------------------------------------------------- |
|
|
46
|
+
| `deploy <app> --tier <t>` | Checks `CLOUDFLARE_API_TOKEN`, writes the Worker's secrets file, deploys, removes it. |
|
|
47
|
+
| `deploy write-env` | Composes `.env.<DEPLOY_ENV>` from the GitHub environment. |
|
|
48
|
+
| `deploy preflight` | Classifies the project: paused (skip) or broken (fail). |
|
|
49
|
+
| `deploy plan`, `deploy migrate` | Dry-run the migrations into the job summary; apply them to `DB_URL`. |
|
|
50
|
+
| `deploy smoke --tier <t> [--app]` | Public routes answer 200, the auth redirect works, this deploy's Sentry release shows. |
|
|
51
|
+
| `deploy reconcile --tier <t>` | The platform's config reconcile, after the deploy (`CRON_SECRET`). |
|
|
52
|
+
| `deploy prune-monitors --tier <t> [--app]` | Deletes the app's Sentry Crons monitors it no longer declares (production only). |
|
|
53
|
+
| `env pull\|push\|audit --target <t>` | One env file per target, synced to Bitwarden and GitHub. `audit` lists orphans. |
|
|
54
|
+
| `planner status\|create\|reset-password\|drop` | The `migration_planner` role the preflight tier holds. |
|
|
55
|
+
| `graphics [graphic…]` | Club images from `@devdogsuga/brand`: `brand/*`, `app/*`, `event/*`. |
|
|
56
|
+
| `qr <text>` | QR codes with every option of `/console/qr`. |
|
|
57
|
+
| `github rulesets\|settings` | Diff (and with `--apply` write) GitHub config, through `gh`. |
|
|
58
|
+
| `newsletter render\|draft\|send <issue…>` | Changelog issues as files, mailbox drafts, or a send. |
|
|
59
|
+
| `creds send\|add\|renew\|list\|report` | Club logins from Bitwarden as email-verified Sends, and the Linear report. |
|
|
60
|
+
| `import involvement --file <csv>` | Verifies the members on the Involvement Network roster, unverifies everyone else. |
|
|
61
|
+
| `import attendance --meeting <day> --file <csv>` | Records a meeting's attendance from a sign-in sheet (method `import`). |
|
|
62
|
+
| `export stars\|attendance\|reflections` | Member data as CSV, each export audited under the officer's gh login. |
|
|
55
63
|
|
|
56
64
|
`smoke` and `reconcile` replace DevDogsUGA's `packages/deploy-checks`. The
|
|
57
65
|
per-app data (hosts, public paths, the protected path and its redirect) stays in
|
|
@@ -148,6 +156,62 @@ checkout, else Secrets Manager. The Linear key: `--linear-token`, then
|
|
|
148
156
|
`LINEAR_API_KEY`, then the vault item "DevDogs Linear API key (backstage)", then
|
|
149
157
|
a prompt; it is never saved.
|
|
150
158
|
|
|
159
|
+
## Member data: `import` and `export`
|
|
160
|
+
|
|
161
|
+
These replace the platform's `/console/verification` upload and
|
|
162
|
+
`/console/exports` downloads. They read and write production only:
|
|
163
|
+
`DB_URL`, `API_URL` and `SECRET_KEY` come from `.env.production` in a checkout,
|
|
164
|
+
else Secrets Manager. There is no `--db-url`, since a database override would
|
|
165
|
+
leave account creation pointed at production. Days (`--meeting 2026-09-09`,
|
|
166
|
+
`--from`, `--to`) are Eastern, and `--to` includes the whole day.
|
|
167
|
+
|
|
168
|
+
### `import involvement`
|
|
169
|
+
|
|
170
|
+
Replaces the platform's `/console/verification` upload. Export the roster from
|
|
171
|
+
the UGA Involvement Network (Roster → Export → Organization Roster) and pass the
|
|
172
|
+
file as it downloads: the title lines above the header, the `(Hidden)` columns
|
|
173
|
+
and the one-row-per-position layout are all expected. A roster for another
|
|
174
|
+
organization is refused.
|
|
175
|
+
|
|
176
|
+
The roster is the whole truth: everyone on it is verified, and everyone
|
|
177
|
+
verified who is not on it loses that status. A roster email finds its account
|
|
178
|
+
by the profile's UGA email first, then the sign-in address; with no account,
|
|
179
|
+
one is created (confirmed, no invite email) so the member is verified the first
|
|
180
|
+
time they sign in. Every run previews the counts, who is newly verified, who
|
|
181
|
+
loses verification, and whose preferred name differs from the roster, then
|
|
182
|
+
asks; `--dry-run` stops there, `--yes` answers with no terminal. Preferred
|
|
183
|
+
names are never changed. Rerunning the same export changes nothing.
|
|
184
|
+
|
|
185
|
+
### `import attendance`
|
|
186
|
+
|
|
187
|
+
Records a meeting's attendance from a sign-in sheet, for members who could not
|
|
188
|
+
check in on the platform. The sheet is a Google or Microsoft Forms response
|
|
189
|
+
export or a hand-made spreadsheet, saved as CSV; one sheet is one meeting,
|
|
190
|
+
named with `--meeting` (its day, slug, config id or id; a day with two meetings
|
|
191
|
+
asks for the slug). The header and columns are found, not assumed: the email
|
|
192
|
+
column is the one holding UGA addresses, the name is "First/Last Name" or a
|
|
193
|
+
column mentioning "name", and `--email-column`/`--name-column` override
|
|
194
|
+
either. A sheet with no header is read as every address in it. Only UGA
|
|
195
|
+
addresses count; anything else is listed and skipped.
|
|
196
|
+
|
|
197
|
+
Check-ins are written with method `import`, stamped with the meeting's start,
|
|
198
|
+
and count for stars like any other. A member's own check-in is never touched.
|
|
199
|
+
Someone with no account gets one (confirmed, no invite), named from the sheet;
|
|
200
|
+
with no name on the sheet they are listed and skipped. Cancelled and future
|
|
201
|
+
meetings are refused. Rerunning a sheet records nothing new; `--replace`
|
|
202
|
+
makes the sheet the meeting's whole imported set, removing earlier imported
|
|
203
|
+
rows it no longer lists. Every run previews, then asks (`--dry-run`, `--yes`).
|
|
204
|
+
|
|
205
|
+
### `export stars|attendance|reflections`
|
|
206
|
+
|
|
207
|
+
The platform's three CSVs, columns unchanged: `stars` (one row per earned
|
|
208
|
+
star), `attendance` (one row per check-in; `--meeting` for one night) and
|
|
209
|
+
`reflections` (each member's current text). Each export writes a
|
|
210
|
+
`platform.exportAudit` row before reading anything, attributed to the
|
|
211
|
+
platform account linked to the GitHub login `gh` is signed in as, and refuses
|
|
212
|
+
to run when there is none. The file goes to `./<kind>….csv` (owner-readable
|
|
213
|
+
only, never over an existing file without `--yes`) or stdout with `--out -`.
|
|
214
|
+
|
|
151
215
|
No secret value reaches stdout, stderr, the failure log, Sentry, an error
|
|
152
216
|
message or argv: values go to `bw` as base64 JSON on stdin, every error is
|
|
153
217
|
scrubbed of every value read so far (`src/creds/secrets.ts`), and the clipboard
|
|
@@ -171,7 +235,8 @@ stderr, and every confirmation needs `--yes`. Against staging or production a
|
|
|
171
235
|
command that is not read-only asks once first (`--yes` answers it).
|
|
172
236
|
|
|
173
237
|
Each command checks for the secrets it uses up front: `deploy <app>` for
|
|
174
|
-
`CLOUDFLARE_API_TOKEN`, `deploy reconcile` for `CRON_SECRET`, `
|
|
238
|
+
`CLOUDFLARE_API_TOKEN`, `deploy reconcile` for `CRON_SECRET`, `deploy
|
|
239
|
+
prune-monitors` for `SENTRY_MONITORS_TOKEN` (skipped without it), `env` for the
|
|
175
240
|
Secrets Manager token (flag, environment, then the Bitwarden vault, which `env`
|
|
176
241
|
signs in to and unlocks itself, then asking).
|
|
177
242
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { A as YES, D as DRY_RUN, O as JSON_FLAG, j as createCatalog, k as SCOPES, v as noteRan, w as findRepoRoot } from "./telemetry-Bjoz29Hl.js";
|
|
2
|
-
import {
|
|
2
|
+
import { n as isDryRun } from "./dry-run-3IxPmCtW.js";
|
|
3
3
|
import { o as unwrap } from "./ui-CdKo8mLw.js";
|
|
4
4
|
import { n as positionals } from "./args-Cjr_Iqts.js";
|
|
5
|
+
import { t as EXPORTS } from "./queries-DeEwfe2c.js";
|
|
6
|
+
import { r as qrCatalogOptions } from "./options-DCaqm8BI.js";
|
|
5
7
|
import { existsSync, readFileSync } from "node:fs";
|
|
6
8
|
import { join } from "node:path";
|
|
7
9
|
import { confirm, note, select, text } from "@clack/prompts";
|
|
@@ -930,7 +932,7 @@ const ITEM = {
|
|
|
930
932
|
value: "<name>",
|
|
931
933
|
summary: "A DevDogs organization item, by name or id. Repeat for several."
|
|
932
934
|
};
|
|
933
|
-
const TO = {
|
|
935
|
+
const TO$1 = {
|
|
934
936
|
flag: "--to",
|
|
935
937
|
value: "<a@uga.edu,…>",
|
|
936
938
|
summary: "Recipients, comma-separated. Replaces the item's current list."
|
|
@@ -970,7 +972,7 @@ const credsCommand = {
|
|
|
970
972
|
hint: "pick accounts, then officers",
|
|
971
973
|
options: [
|
|
972
974
|
ITEM,
|
|
973
|
-
TO,
|
|
975
|
+
TO$1,
|
|
974
976
|
ROLE,
|
|
975
977
|
ALLOW_EMAIL,
|
|
976
978
|
DB_URL$1,
|
|
@@ -1013,7 +1015,7 @@ const credsCommand = {
|
|
|
1013
1015
|
flag: "--password-stdin",
|
|
1014
1016
|
summary: "Read the password from stdin, for scripts."
|
|
1015
1017
|
},
|
|
1016
|
-
TO,
|
|
1018
|
+
TO$1,
|
|
1017
1019
|
ROLE,
|
|
1018
1020
|
ALLOW_EMAIL,
|
|
1019
1021
|
DB_URL$1,
|
|
@@ -1141,6 +1143,16 @@ const deployCommand = {
|
|
|
1141
1143
|
summary: "Run the platform's config reconcile after a deploy.",
|
|
1142
1144
|
hint: "needs CRON_SECRET",
|
|
1143
1145
|
options: [DEPLOY_TIER, SMOKE_APP]
|
|
1146
|
+
},
|
|
1147
|
+
{
|
|
1148
|
+
name: "prune-monitors",
|
|
1149
|
+
summary: "Delete the Sentry Crons monitors an app no longer declares.",
|
|
1150
|
+
hint: "needs SENTRY_MONITORS_TOKEN; production only",
|
|
1151
|
+
options: [
|
|
1152
|
+
DEPLOY_TIER,
|
|
1153
|
+
SMOKE_APP,
|
|
1154
|
+
DRY_RUN
|
|
1155
|
+
]
|
|
1144
1156
|
}
|
|
1145
1157
|
]
|
|
1146
1158
|
};
|
|
@@ -1210,6 +1222,61 @@ const envCommand = {
|
|
|
1210
1222
|
]
|
|
1211
1223
|
};
|
|
1212
1224
|
//#endregion
|
|
1225
|
+
//#region src/export/catalog.ts
|
|
1226
|
+
/**
|
|
1227
|
+
* `export`'s place in the command tree: declaration only, nothing here runs.
|
|
1228
|
+
* The handler lives in `commands.ts` beside it.
|
|
1229
|
+
*
|
|
1230
|
+
* `envFree` like `involvement`: production's `DB_URL` comes from the
|
|
1231
|
+
* checkout's `.env.production` or Secrets Manager, and the audit identity from
|
|
1232
|
+
* `gh`, so it runs under `pnpm dlx`.
|
|
1233
|
+
*/
|
|
1234
|
+
const FROM = {
|
|
1235
|
+
flag: "--from",
|
|
1236
|
+
value: "<day>",
|
|
1237
|
+
summary: "From this Eastern day (or ISO timestamp), inclusive."
|
|
1238
|
+
};
|
|
1239
|
+
const TO = {
|
|
1240
|
+
flag: "--to",
|
|
1241
|
+
value: "<day>",
|
|
1242
|
+
summary: "Through this Eastern day (or before this ISO timestamp)."
|
|
1243
|
+
};
|
|
1244
|
+
const OUT = {
|
|
1245
|
+
flag: "--out",
|
|
1246
|
+
value: "<path|->",
|
|
1247
|
+
summary: "Where to write. Defaults to ./<kind>….csv; - for stdout."
|
|
1248
|
+
};
|
|
1249
|
+
function exportOf(kind, extra = []) {
|
|
1250
|
+
return {
|
|
1251
|
+
name: kind,
|
|
1252
|
+
envFree: true,
|
|
1253
|
+
dryRun: "handled",
|
|
1254
|
+
summary: EXPORTS[kind].summary,
|
|
1255
|
+
options: [
|
|
1256
|
+
FROM,
|
|
1257
|
+
TO,
|
|
1258
|
+
...extra,
|
|
1259
|
+
OUT,
|
|
1260
|
+
DRY_RUN,
|
|
1261
|
+
YES
|
|
1262
|
+
]
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
const exportCommand = {
|
|
1266
|
+
name: "export",
|
|
1267
|
+
summary: "Member data as CSV: stars, attendance, reflections. Audited.",
|
|
1268
|
+
hint: "production database; your gh login is recorded",
|
|
1269
|
+
subcommands: [
|
|
1270
|
+
exportOf("stars"),
|
|
1271
|
+
exportOf("attendance", [{
|
|
1272
|
+
flag: "--meeting",
|
|
1273
|
+
value: "<meeting>",
|
|
1274
|
+
summary: "One meeting: its day (2026-09-09), slug, config id or id."
|
|
1275
|
+
}]),
|
|
1276
|
+
exportOf("reflections")
|
|
1277
|
+
]
|
|
1278
|
+
};
|
|
1279
|
+
//#endregion
|
|
1213
1280
|
//#region src/github/catalog.ts
|
|
1214
1281
|
/**
|
|
1215
1282
|
* `github`'s place in the command tree: declaration only, nothing here runs.
|
|
@@ -1303,6 +1370,63 @@ const graphicsCommand = {
|
|
|
1303
1370
|
]
|
|
1304
1371
|
};
|
|
1305
1372
|
//#endregion
|
|
1373
|
+
//#region src/import/catalog.ts
|
|
1374
|
+
const importCommand = {
|
|
1375
|
+
name: "import",
|
|
1376
|
+
summary: "Member data in: the Involvement roster, a meeting's sign-in sheet.",
|
|
1377
|
+
hint: "production database; previews, then asks",
|
|
1378
|
+
subcommands: [{
|
|
1379
|
+
name: "involvement",
|
|
1380
|
+
envFree: true,
|
|
1381
|
+
dryRun: "handled",
|
|
1382
|
+
summary: "Import an Organization Roster export: verify everyone on it, unverify everyone else.",
|
|
1383
|
+
hint: "previews, then asks",
|
|
1384
|
+
options: [
|
|
1385
|
+
{
|
|
1386
|
+
flag: "--file",
|
|
1387
|
+
value: "<csv>",
|
|
1388
|
+
summary: "The roster export (Involvement Network → Roster → Export → Organization Roster)."
|
|
1389
|
+
},
|
|
1390
|
+
DRY_RUN,
|
|
1391
|
+
YES
|
|
1392
|
+
]
|
|
1393
|
+
}, {
|
|
1394
|
+
name: "attendance",
|
|
1395
|
+
envFree: true,
|
|
1396
|
+
dryRun: "handled",
|
|
1397
|
+
summary: "Import a sign-in sheet (form responses or a spreadsheet, as CSV) for one meeting.",
|
|
1398
|
+
hint: "previews, then asks",
|
|
1399
|
+
options: [
|
|
1400
|
+
{
|
|
1401
|
+
flag: "--meeting",
|
|
1402
|
+
value: "<meeting>",
|
|
1403
|
+
summary: "The meeting: its day (2026-09-09), slug, config id or id."
|
|
1404
|
+
},
|
|
1405
|
+
{
|
|
1406
|
+
flag: "--file",
|
|
1407
|
+
value: "<csv>",
|
|
1408
|
+
summary: "The sign-in sheet, saved as CSV."
|
|
1409
|
+
},
|
|
1410
|
+
{
|
|
1411
|
+
flag: "--email-column",
|
|
1412
|
+
value: "<header>",
|
|
1413
|
+
summary: "The column of UGA addresses, when there are several."
|
|
1414
|
+
},
|
|
1415
|
+
{
|
|
1416
|
+
flag: "--name-column",
|
|
1417
|
+
value: "<header>",
|
|
1418
|
+
summary: "The column of names, used for new accounts."
|
|
1419
|
+
},
|
|
1420
|
+
{
|
|
1421
|
+
flag: "--replace",
|
|
1422
|
+
summary: "Make this sheet the meeting's whole imported set: remove earlier imported rows it lacks."
|
|
1423
|
+
},
|
|
1424
|
+
DRY_RUN,
|
|
1425
|
+
YES
|
|
1426
|
+
]
|
|
1427
|
+
}]
|
|
1428
|
+
};
|
|
1429
|
+
//#endregion
|
|
1306
1430
|
//#region src/newsletter/catalog.ts
|
|
1307
1431
|
/**
|
|
1308
1432
|
* `newsletter`'s place in the command tree: declaration only, nothing here
|
|
@@ -1458,8 +1582,12 @@ const catalog = createCatalog({
|
|
|
1458
1582
|
commands: [envCommand]
|
|
1459
1583
|
},
|
|
1460
1584
|
{
|
|
1461
|
-
title: "
|
|
1462
|
-
commands: [
|
|
1585
|
+
title: "Production database",
|
|
1586
|
+
commands: [
|
|
1587
|
+
plannerCommand,
|
|
1588
|
+
importCommand,
|
|
1589
|
+
exportCommand
|
|
1590
|
+
]
|
|
1463
1591
|
},
|
|
1464
1592
|
{
|
|
1465
1593
|
title: "Graphics & QR codes (no credentials)",
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { C as discoverRepoRoot, T as cliName, b as ownPackageDir, c as reportDevtoolsError, f as isNoEnv, l as reportDevtoolsFailure, n as captureDevtoolsError, o as initDevtoolsTelemetry, p as isNonInteractive, w as findRepoRoot, x as ownVersion } from "./telemetry-Bjoz29Hl.js";
|
|
2
|
-
import { a as recordEnteredTier, f as helpPath, i as beginInvocation, l as dbPush, m as renderHelp, n as bareGroupStartPath, o as recordResolved, p as renderCommandList, r as runMenu, s as reproducibleCommand, t as catalog, u as dbPushDryRun } from "./catalog-
|
|
2
|
+
import { a as recordEnteredTier, f as helpPath, i as beginInvocation, l as dbPush, m as renderHelp, n as bareGroupStartPath, o as recordResolved, p as renderCommandList, r as runMenu, s as reproducibleCommand, t as catalog, u as dbPushDryRun } from "./catalog-BiXI0wyi.js";
|
|
3
3
|
import { a as repoPeerUrl, n as loadEnv, r as loadEnvLoad, t as getEnvSync } from "./peers-B0KDkI6a.js";
|
|
4
4
|
import { t as nonEmpty } from "./connection-D3XsOE3S.js";
|
|
5
|
-
import { a as
|
|
5
|
+
import { a as wouldRunLine, n as isDryRun, t as dryRunKind } from "./dry-run-3IxPmCtW.js";
|
|
6
6
|
import { a as explainError, i as explain, n as bail, o as unwrap, r as errorMessage } from "./ui-CdKo8mLw.js";
|
|
7
7
|
import { n as positionals, t as flagValue } from "./args-Cjr_Iqts.js";
|
|
8
|
-
import { a as listSecrets$1, c as updateSecret, i as createSecret, o as projectIdFor, r as byKey, s as setExplicitAccessToken } from "./client-B7HhuTaA.js";
|
|
9
|
-
import { c as EnvDocument } from "./vault-C8LYJFOQ.js";
|
|
10
8
|
import { t as DONE } from "./dispatch-D048O65I.js";
|
|
9
|
+
import { t as EnvDocument } from "./document-DumoZXoO.js";
|
|
10
|
+
import { a as listSecrets$1, c as updateSecret, i as createSecret, o as projectIdFor, r as byKey, s as setExplicitAccessToken } from "./client-B_MY9kVI.js";
|
|
11
11
|
import { register } from "node:module";
|
|
12
12
|
import { pathToFileURL } from "node:url";
|
|
13
13
|
import { appendFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -17,9 +17,9 @@ import { chmod, readFile, writeFile } from "node:fs/promises";
|
|
|
17
17
|
import { execFile, execFileSync, spawn } from "node:child_process";
|
|
18
18
|
import { promisify } from "node:util";
|
|
19
19
|
import { tmpdir } from "node:os";
|
|
20
|
+
import postgres from "postgres";
|
|
20
21
|
import { z } from "zod";
|
|
21
22
|
import { randomBytes } from "node:crypto";
|
|
22
|
-
import postgres from "postgres";
|
|
23
23
|
//#region ../cli-core/src/completions.ts
|
|
24
24
|
/**
|
|
25
25
|
* Groups all paths by their parent prefix to build per-parent completion lists.
|
|
@@ -553,6 +553,116 @@ async function runDeployMigrate(env = process.env, push = (url) => dbPush(url, {
|
|
|
553
553
|
say(["deploy migrate: migrations applied."]);
|
|
554
554
|
}
|
|
555
555
|
//#endregion
|
|
556
|
+
//#region src/deploy/monitors.ts
|
|
557
|
+
/**
|
|
558
|
+
* `deploy prune-monitors`: deletes the Sentry Crons monitors an app no longer
|
|
559
|
+
* declares.
|
|
560
|
+
*
|
|
561
|
+
* Sentry creates a monitor the first time a job checks in, and never removes
|
|
562
|
+
* one. Rename or drop a `monitorSlug` and the old monitor keeps expecting
|
|
563
|
+
* check-ins that will never come, opening a "missed check-in" issue every
|
|
564
|
+
* period until someone deletes it by hand (PLATFORM-2: the Discord role sync
|
|
565
|
+
* folded into the fifteen-minute cron, and its ten-minute monitor alerted
|
|
566
|
+
* every ten minutes after the deploy).
|
|
567
|
+
*
|
|
568
|
+
* The declared set is every `monitorSlug` on the entries of the app's
|
|
569
|
+
* `cloudflare/scheduled.ts` exports `CRON_ROUTES` and `WORKFLOW_CRONS`. Every
|
|
570
|
+
* monitor in the app's Sentry project that is not in it, and whose slug
|
|
571
|
+
* starts with `<app>-`, is deleted. The prefix keeps a monitor someone made
|
|
572
|
+
* by hand in the Sentry UI out of reach.
|
|
573
|
+
*
|
|
574
|
+
* Delete, not disable. Sentry drops every check-in sent to a disabled monitor
|
|
575
|
+
* and never re-enables one from a check-in, so a slug that came back would
|
|
576
|
+
* report nowhere. A deleted one is recreated by the next check-in that
|
|
577
|
+
* carries its config (each `withMonitor`/`captureCheckIn` here does), and the
|
|
578
|
+
* delete renames the old slug first, so the name is free at once.
|
|
579
|
+
*
|
|
580
|
+
* Production only. Monitors belong to a project, not a tier, and staging
|
|
581
|
+
* deploys `main`, which can be ahead of production: a monitor `main` dropped
|
|
582
|
+
* still has a production job checking in to it.
|
|
583
|
+
*
|
|
584
|
+
* `SENTRY_MONITORS_TOKEN`, not `SENTRY_AUTH_TOKEN`: the deploy token is an
|
|
585
|
+
* organization token (`org:ci`), which Sentry lets neither list nor delete
|
|
586
|
+
* monitors. This one needs `alerts:write` (`alerts:read` to list), and is
|
|
587
|
+
* held in this step's `env:` alone. Absent, the step is skipped with a
|
|
588
|
+
* notice, the same as the Sentry release check.
|
|
589
|
+
*/
|
|
590
|
+
const SENTRY_API$1 = "https://sentry.io/api/0";
|
|
591
|
+
/** Only the field read here; the exports' own shapes are devtools' to check. */
|
|
592
|
+
const Declarations = z.record(z.string(), z.object({ monitorSlug: z.string().min(1).optional() }));
|
|
593
|
+
const MonitorList = z.array(z.object({ slug: z.string() }));
|
|
594
|
+
/**
|
|
595
|
+
* Every `monitorSlug` the app's `cloudflare/scheduled.ts` declares, or
|
|
596
|
+
* `undefined` when the app has no such file (no crons, nothing to prune).
|
|
597
|
+
*/
|
|
598
|
+
async function declaredMonitorSlugs(app, root = findRepoRoot()) {
|
|
599
|
+
const path = join(root, "apps", app, "cloudflare", "scheduled.ts");
|
|
600
|
+
if (!existsSync(path)) return void 0;
|
|
601
|
+
const mod = await import(pathToFileURL(path).href);
|
|
602
|
+
const slugs = /* @__PURE__ */ new Set();
|
|
603
|
+
for (const name of ["CRON_ROUTES", "WORKFLOW_CRONS"]) {
|
|
604
|
+
const parsed = Declarations.safeParse(mod[name] ?? {});
|
|
605
|
+
if (!parsed.success) throw new DeployError(`${path}: ${name} is not a map of entries.`);
|
|
606
|
+
for (const entry of Object.values(parsed.data)) if (entry.monitorSlug) slugs.add(entry.monitorSlug);
|
|
607
|
+
}
|
|
608
|
+
return slugs;
|
|
609
|
+
}
|
|
610
|
+
/** The `rel="next"` URL of a Sentry `Link` header, when it has results. */
|
|
611
|
+
function nextPage(link) {
|
|
612
|
+
for (const part of link?.split(",") ?? []) if (part.includes("rel=\"next\"") && part.includes("results=\"true\"")) return /<([^>]+)>/.exec(part)?.[1];
|
|
613
|
+
}
|
|
614
|
+
async function listMonitorSlugs(org, project, headers, fetchImpl) {
|
|
615
|
+
const slugs = [];
|
|
616
|
+
let url = `${SENTRY_API$1}/organizations/${org}/monitors/?project=${encodeURIComponent(project)}`;
|
|
617
|
+
while (url) {
|
|
618
|
+
const response = await fetchImpl(url, { headers });
|
|
619
|
+
if (!response.ok) throw new DeployError(`Listing Sentry monitors: HTTP ${response.status}.`, ["SENTRY_MONITORS_TOKEN needs alerts:read and alerts:write."]);
|
|
620
|
+
slugs.push(...MonitorList.parse(await response.json()).map((m) => m.slug));
|
|
621
|
+
url = nextPage(response.headers.get("link"));
|
|
622
|
+
}
|
|
623
|
+
return slugs;
|
|
624
|
+
}
|
|
625
|
+
/** Returns the slugs it deleted (or would have, on a dry run). */
|
|
626
|
+
async function runPruneMonitors(options) {
|
|
627
|
+
const env = options.env ?? process.env;
|
|
628
|
+
const { app } = options;
|
|
629
|
+
if (options.tier !== "production") {
|
|
630
|
+
say([`prune-monitors: skipped on ${options.tier}; monitors are pruned against production only.`]);
|
|
631
|
+
return [];
|
|
632
|
+
}
|
|
633
|
+
const token = env.SENTRY_MONITORS_TOKEN;
|
|
634
|
+
if (!token) {
|
|
635
|
+
say(["::notice::Sentry monitor pruning skipped -- SENTRY_MONITORS_TOKEN is not configured."]);
|
|
636
|
+
return [];
|
|
637
|
+
}
|
|
638
|
+
if (!env.SENTRY_ORG) throw new DeployError("SENTRY_ORG is not set.");
|
|
639
|
+
const declared = options.declared ?? await declaredMonitorSlugs(app);
|
|
640
|
+
if (!declared) {
|
|
641
|
+
say([`prune-monitors: ${app} has no cloudflare/scheduled.ts; nothing to prune.`]);
|
|
642
|
+
return [];
|
|
643
|
+
}
|
|
644
|
+
const headers = { authorization: `Bearer ${token}` };
|
|
645
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
646
|
+
const orphans = (await listMonitorSlugs(env.SENTRY_ORG, app, headers, fetchImpl)).filter((slug) => slug.startsWith(`${app}-`) && !declared.has(slug));
|
|
647
|
+
if (orphans.length === 0) {
|
|
648
|
+
say([`prune-monitors: every ${app} monitor is declared.`]);
|
|
649
|
+
return [];
|
|
650
|
+
}
|
|
651
|
+
for (const slug of orphans) {
|
|
652
|
+
if (options.dryRun) {
|
|
653
|
+
say([`Would delete Sentry monitor ${slug}`]);
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
const response = await fetchImpl(`${SENTRY_API$1}/projects/${env.SENTRY_ORG}/${app}/monitors/${encodeURIComponent(slug)}/`, {
|
|
657
|
+
method: "DELETE",
|
|
658
|
+
headers
|
|
659
|
+
});
|
|
660
|
+
if (!response.ok && response.status !== 404) throw new DeployError(`Deleting Sentry monitor ${slug}: HTTP ${response.status}.`);
|
|
661
|
+
say([`Deleted Sentry monitor ${slug}`]);
|
|
662
|
+
}
|
|
663
|
+
return orphans;
|
|
664
|
+
}
|
|
665
|
+
//#endregion
|
|
556
666
|
//#region src/deploy/preflight.ts
|
|
557
667
|
/**
|
|
558
668
|
* `pnpm devtools deploy preflight`
|
|
@@ -1554,10 +1664,11 @@ function renderWriteEnvReport(result) {
|
|
|
1554
1664
|
* Sandbox (plain Worker) always used this shape, so all three apps share one
|
|
1555
1665
|
* deploy step.
|
|
1556
1666
|
*
|
|
1557
|
-
* Steps (`write-env`, `preflight`, `plan`, `migrate`, `smoke`, `reconcile
|
|
1558
|
-
* individually addressable for jobs that run only one.
|
|
1559
|
-
* single credential in the job's own `env:` block and
|
|
1560
|
-
* (`preflight`, `plan`, `migrate`, `smoke`, `reconcile
|
|
1667
|
+
* Steps (`write-env`, `preflight`, `plan`, `migrate`, `smoke`, `reconcile`,
|
|
1668
|
+
* `prune-monitors`) are individually addressable for jobs that run only one.
|
|
1669
|
+
* The ones that hold a single credential in the job's own `env:` block and
|
|
1670
|
+
* compose no env file (`preflight`, `plan`, `migrate`, `smoke`, `reconcile`,
|
|
1671
|
+
* `prune-monitors`), and `write-env`,
|
|
1561
1672
|
* which CREATES the file tier resolution would otherwise insist on reading,
|
|
1562
1673
|
* run with `--no-env`.
|
|
1563
1674
|
*/
|
|
@@ -1707,6 +1818,14 @@ async function runStep(sub, rest) {
|
|
|
1707
1818
|
});
|
|
1708
1819
|
return true;
|
|
1709
1820
|
}
|
|
1821
|
+
if (sub === "prune-monitors") {
|
|
1822
|
+
await runPruneMonitors({
|
|
1823
|
+
app: flagValue(rest, "--app") ?? "platform",
|
|
1824
|
+
tier: requireTier(rest),
|
|
1825
|
+
dryRun: rest.includes("--dry-run")
|
|
1826
|
+
});
|
|
1827
|
+
return true;
|
|
1828
|
+
}
|
|
1710
1829
|
if (sub === "write-env") {
|
|
1711
1830
|
await loadRegistry();
|
|
1712
1831
|
const index = rest.indexOf("--source");
|
|
@@ -5268,9 +5387,11 @@ const HANDLERS = {
|
|
|
5268
5387
|
completions: handleCompletions,
|
|
5269
5388
|
github: handleGithub,
|
|
5270
5389
|
graphics: async (rest) => (await import("./commands-Dv5TXjvb.js")).handleGraphics(rest),
|
|
5271
|
-
qr: async (rest) => (await import("./commands-
|
|
5390
|
+
qr: async (rest) => (await import("./commands-Sx1fDNNn.js")).handleQr(rest),
|
|
5272
5391
|
newsletter: async (rest) => (await import("./commands-D345F-Od.js")).handleNewsletter(rest),
|
|
5273
|
-
creds: async (rest) => (await import("./commands-
|
|
5392
|
+
creds: async (rest) => (await import("./commands-zPSnOGzH.js")).handleCreds(rest),
|
|
5393
|
+
import: async (rest) => (await import("./commands-DzALVX5g.js")).handleImport(rest),
|
|
5394
|
+
export: async (rest) => (await import("./commands-5Ou8xJw5.js")).handleExport(rest)
|
|
5274
5395
|
};
|
|
5275
5396
|
/**
|
|
5276
5397
|
* Names that were commands of the contributor CLI, refused with where they
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { C as discoverRepoRoot, p as isNonInteractive } from "./telemetry-Bjoz29Hl.js";
|
|
2
|
+
import { t as nonEmpty } from "./connection-D3XsOE3S.js";
|
|
3
|
+
import { a as explainError, i as explain, o as unwrap, t as UsageError } from "./ui-CdKo8mLw.js";
|
|
4
|
+
import { t as EnvDocument } from "./document-DumoZXoO.js";
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
import { confirm, log, text } from "@clack/prompts";
|
|
7
|
+
import { readFile } from "node:fs/promises";
|
|
8
|
+
import postgres from "postgres";
|
|
9
|
+
//#region src/production/access.ts
|
|
10
|
+
/**
|
|
11
|
+
* Production access for the commands that read or write member data
|
|
12
|
+
* (`involvement`, `attendance`, `export`).
|
|
13
|
+
*
|
|
14
|
+
* Every key comes from one place, so a run never splits across two projects:
|
|
15
|
+
* the checkout's `.env.production`, else the production Secrets Manager
|
|
16
|
+
* project. There is deliberately no `--db-url`; a database override would
|
|
17
|
+
* leave account creation (which goes through Auth with `API_URL`) pointed at
|
|
18
|
+
* production.
|
|
19
|
+
*/
|
|
20
|
+
/** A failure already worded for the officer. Safe to print. */
|
|
21
|
+
var ProductionError = class extends Error {
|
|
22
|
+
name = "ProductionError";
|
|
23
|
+
};
|
|
24
|
+
/** The production project in Secrets Manager (see `creds/roster.ts`). */
|
|
25
|
+
const PRODUCTION_PROJECT = "production";
|
|
26
|
+
/** `.env.production` in a checkout, else the production Secrets Manager project. */
|
|
27
|
+
async function readProductionKey(key) {
|
|
28
|
+
const root = discoverRepoRoot();
|
|
29
|
+
if (root) try {
|
|
30
|
+
const text = await readFile(resolve(root, ".env.production"), "utf8");
|
|
31
|
+
const value = nonEmpty(EnvDocument.parse(text).get(key));
|
|
32
|
+
if (value) return value;
|
|
33
|
+
} catch {}
|
|
34
|
+
const { listSecrets, projectIdFor } = await import("./client-DwDGjalU.js");
|
|
35
|
+
const secrets = await listSecrets(await projectIdFor(PRODUCTION_PROJECT));
|
|
36
|
+
return nonEmpty(secrets.find((s) => s.key === key)?.value);
|
|
37
|
+
}
|
|
38
|
+
async function requireProductionKey(key) {
|
|
39
|
+
const value = await readProductionKey(key);
|
|
40
|
+
if (!value) throw new ProductionError(`Could not find production's ${key}. Run \`backstage env pull --target production\` in a checkout, or sign in to Secrets Manager.`);
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A database failure, described without the connection string: postgres.js
|
|
45
|
+
* can quote the host, and the URL carries the password. A server error
|
|
46
|
+
* (a five-character SQLSTATE) is the server's own message, which never does.
|
|
47
|
+
*/
|
|
48
|
+
function dbError(what, err) {
|
|
49
|
+
const e = err;
|
|
50
|
+
return new ProductionError(`${what}: ${(e.code && /^[0-9A-Z]{5}$/.test(e.code) ? e.message : e.code) ?? "connection failed"}.`);
|
|
51
|
+
}
|
|
52
|
+
/** One short-lived connection; callers `end()` it. */
|
|
53
|
+
function connect(url) {
|
|
54
|
+
return postgres(url, {
|
|
55
|
+
max: 1,
|
|
56
|
+
prepare: false,
|
|
57
|
+
connect_timeout: 15
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/** Runs `fn` on a fresh connection and closes it, rewording failures. */
|
|
61
|
+
async function withConnection(url, what, fn) {
|
|
62
|
+
const sql = connect(url);
|
|
63
|
+
try {
|
|
64
|
+
return await fn(sql);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (err instanceof ProductionError) throw err;
|
|
67
|
+
throw dbError(what, err);
|
|
68
|
+
} finally {
|
|
69
|
+
await sql.end({ timeout: 5 });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/production/cli.ts
|
|
74
|
+
/**
|
|
75
|
+
* The terminal half shared by the member-data commands: progress lines, the
|
|
76
|
+
* input file, and the one confirmation before production is written.
|
|
77
|
+
*/
|
|
78
|
+
/** Progress for a person; plain stderr lines when nobody is watching a terminal. */
|
|
79
|
+
function say(message, level = "info") {
|
|
80
|
+
if (isNonInteractive()) process.stderr.write(`${message}\n`);
|
|
81
|
+
else log[level](message);
|
|
82
|
+
}
|
|
83
|
+
function plural(n, one, many = `${one}s`) {
|
|
84
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
85
|
+
}
|
|
86
|
+
/** Whether a person can answer a prompt. */
|
|
87
|
+
function canPrompt() {
|
|
88
|
+
return !isNonInteractive() && process.stdin.isTTY === true;
|
|
89
|
+
}
|
|
90
|
+
function expandHome(path) {
|
|
91
|
+
return path.startsWith("~/") ? `${process.env.HOME ?? "~"}${path.slice(1)}` : path;
|
|
92
|
+
}
|
|
93
|
+
/** `--file`, else asked for at a terminal; then read. */
|
|
94
|
+
async function readInputFile(file, options) {
|
|
95
|
+
let path = file;
|
|
96
|
+
if (!path) {
|
|
97
|
+
if (!options.interactive) throw new UsageError(`Pass the file with --file <${options.placeholder}>.`);
|
|
98
|
+
path = unwrap(await text({
|
|
99
|
+
message: options.message,
|
|
100
|
+
placeholder: options.placeholder,
|
|
101
|
+
validate: (v) => v?.trim() ? void 0 : "A path, please."
|
|
102
|
+
})).trim();
|
|
103
|
+
}
|
|
104
|
+
const full = expandHome(path);
|
|
105
|
+
try {
|
|
106
|
+
return await (options.read ?? ((p) => readFile(p, "utf8")))(full);
|
|
107
|
+
} catch {
|
|
108
|
+
throw new UsageError(`Could not read ${full}.`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The one question before production is written. `--yes` answers it; with no
|
|
113
|
+
* terminal and no `--yes`, the command stops rather than guess. Defaults to no.
|
|
114
|
+
*/
|
|
115
|
+
async function confirmWrite(question, options) {
|
|
116
|
+
if (options.yes) return true;
|
|
117
|
+
if (!options.interactive) throw new UsageError(`${question} Pass --yes to answer yes without a terminal.`);
|
|
118
|
+
return unwrap(await confirm({
|
|
119
|
+
message: question,
|
|
120
|
+
initialValue: false
|
|
121
|
+
}));
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The shared `catch`: a worded failure is explained and the run exits 1; any
|
|
125
|
+
* other error is unexpected and goes to Sentry with the command's name.
|
|
126
|
+
*/
|
|
127
|
+
function reportFailure(command, err, worded = []) {
|
|
128
|
+
process.exitCode = 1;
|
|
129
|
+
if (err instanceof UsageError || err instanceof ProductionError || worded.some((kind) => err instanceof kind)) {
|
|
130
|
+
explain(err.message, "");
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
explainError(`${command} failed.`, err);
|
|
134
|
+
}
|
|
135
|
+
//#endregion
|
|
136
|
+
export { reportFailure as a, connect as c, withConnection as d, readInputFile as i, dbError as l, confirmWrite as n, say as o, plural as r, ProductionError as s, canPrompt as t, requireProductionKey as u };
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { w as findRepoRoot } from "./telemetry-Bjoz29Hl.js";
|
|
2
2
|
import { n as loadEnv } from "./peers-B0KDkI6a.js";
|
|
3
3
|
import { o as unwrap } from "./ui-CdKo8mLw.js";
|
|
4
|
-
import {
|
|
4
|
+
import { t as EnvDocument } from "./document-DumoZXoO.js";
|
|
5
|
+
import { a as readTokenFromVault, o as saveTokenToVault, t as VAULT_ITEM_NAME } from "./vault-CsBC-1Mq.js";
|
|
5
6
|
import { dirname, join, resolve } from "node:path";
|
|
6
7
|
import { log, password, select, text } from "@clack/prompts";
|
|
7
8
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|