@devdogsuga/backstage 0.1.4 → 0.1.6
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 +90 -23
- package/dist/{catalog-BuNLUu08.js → catalog-Dke7bQVz.js} +131 -8
- package/dist/{cli-BI5Vn9sR.js → cli-BhZ4je2D.js} +9 -7
- 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-Dl3UlhBf.js → commands-zPSnOGzH.js} +86 -46
- 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/package.json +7 -7
- package/dist/client-DzXQHd58.js +0 -2
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { n as isDryRun } from "./dry-run-3IxPmCtW.js";
|
|
2
|
+
import { r as errorMessage, t as UsageError } from "./ui-CdKo8mLw.js";
|
|
3
|
+
import { t as DONE } from "./dispatch-D048O65I.js";
|
|
4
|
+
import { a as reportFailure, d as withConnection, i as readInputFile, n as confirmWrite, o as say, r as plural, t as canPrompt, u as requireProductionKey } from "./cli-Do2nWD7W.js";
|
|
5
|
+
import { a as createAccounts, i as createAccountFor, n as accountFinder, o as readAccounts, t as parseCsv } from "./read-B-aJJewb.js";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
//#region src/involvement/csv.ts
|
|
8
|
+
/**
|
|
9
|
+
* Reading the UGA Involvement Network's "Organization Roster" export.
|
|
10
|
+
*
|
|
11
|
+
* The export is not a plain CSV. It opens with a title line, a blank line and
|
|
12
|
+
* an `Organization,date` line before the header, hides most columns as
|
|
13
|
+
* `(Hidden)`, and lists a person once per position they hold, so an officer
|
|
14
|
+
* appears as both `Member` and `President`. The header is found rather than
|
|
15
|
+
* assumed to be the first line, which is what broke the platform's upload: it
|
|
16
|
+
* took `Organization Roster` for the header and found no rows.
|
|
17
|
+
*/
|
|
18
|
+
var RosterFormatError = class extends Error {
|
|
19
|
+
name = "RosterFormatError";
|
|
20
|
+
};
|
|
21
|
+
/** Header names for each field, the first one present wins. */
|
|
22
|
+
const COLUMNS = {
|
|
23
|
+
firstName: ["first name"],
|
|
24
|
+
lastName: ["last name"],
|
|
25
|
+
email: ["campus email", "email"],
|
|
26
|
+
organization: ["organization name"],
|
|
27
|
+
position: ["position name"]
|
|
28
|
+
};
|
|
29
|
+
function columnIndex(header, names) {
|
|
30
|
+
const normalized = header.map((h) => h.trim().toLowerCase());
|
|
31
|
+
for (const name of names) {
|
|
32
|
+
const i = normalized.indexOf(name);
|
|
33
|
+
if (i >= 0) return i;
|
|
34
|
+
}
|
|
35
|
+
return -1;
|
|
36
|
+
}
|
|
37
|
+
function clean(value) {
|
|
38
|
+
const v = (value ?? "").trim();
|
|
39
|
+
return v === "(Hidden)" ? "" : v;
|
|
40
|
+
}
|
|
41
|
+
function parseRoster(text) {
|
|
42
|
+
const rows = parseCsv(text);
|
|
43
|
+
const headerAt = rows.findIndex((r) => columnIndex(r, COLUMNS.firstName) >= 0 && columnIndex(r, COLUMNS.lastName) >= 0 && columnIndex(r, COLUMNS.email) >= 0);
|
|
44
|
+
if (headerAt < 0) throw new RosterFormatError("No header row with \"First Name\", \"Last Name\" and \"Campus Email\". Export the roster from the Involvement Network's Roster page (Export → Organization Roster) and pass that file.");
|
|
45
|
+
const header = rows[headerAt];
|
|
46
|
+
const at = {
|
|
47
|
+
firstName: columnIndex(header, COLUMNS.firstName),
|
|
48
|
+
lastName: columnIndex(header, COLUMNS.lastName),
|
|
49
|
+
email: columnIndex(header, COLUMNS.email),
|
|
50
|
+
organization: columnIndex(header, COLUMNS.organization),
|
|
51
|
+
position: columnIndex(header, COLUMNS.position)
|
|
52
|
+
};
|
|
53
|
+
const byEmail = /* @__PURE__ */ new Map();
|
|
54
|
+
const organizations = /* @__PURE__ */ new Set();
|
|
55
|
+
const skippedLines = [];
|
|
56
|
+
const conflicting = /* @__PURE__ */ new Set();
|
|
57
|
+
rows.slice(headerAt + 1).forEach((row, i) => {
|
|
58
|
+
if (row.every((cell) => cell.trim() === "")) return;
|
|
59
|
+
const email = clean(row[at.email]).toLowerCase();
|
|
60
|
+
const firstName = clean(row[at.firstName]);
|
|
61
|
+
const lastName = clean(row[at.lastName]);
|
|
62
|
+
if (!email.includes("@") || !firstName || !lastName) {
|
|
63
|
+
skippedLines.push(headerAt + i + 2);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (at.organization >= 0) {
|
|
67
|
+
const org = clean(row[at.organization]);
|
|
68
|
+
if (org) organizations.add(org);
|
|
69
|
+
}
|
|
70
|
+
const position = at.position >= 0 ? clean(row[at.position]) : "";
|
|
71
|
+
const existing = byEmail.get(email);
|
|
72
|
+
if (existing) {
|
|
73
|
+
if (existing.firstName !== firstName || existing.lastName !== lastName) conflicting.add(email);
|
|
74
|
+
if (position && !existing.positions.includes(position)) existing.positions.push(position);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
byEmail.set(email, {
|
|
78
|
+
email,
|
|
79
|
+
firstName,
|
|
80
|
+
lastName,
|
|
81
|
+
positions: position ? [position] : []
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
return {
|
|
85
|
+
members: [...byEmail.values()],
|
|
86
|
+
organizations: [...organizations],
|
|
87
|
+
skippedLines,
|
|
88
|
+
conflictingNames: [...conflicting]
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
//#endregion
|
|
92
|
+
//#region src/involvement/plan.ts
|
|
93
|
+
/**
|
|
94
|
+
* What an import would do, worked out before anything is written.
|
|
95
|
+
*
|
|
96
|
+
* The roster is the whole truth about who is involved: everyone on it is
|
|
97
|
+
* verified, and everyone verified who is not on it loses that status. So the
|
|
98
|
+
* plan is four lists, and the preview prints them before the confirmation.
|
|
99
|
+
*
|
|
100
|
+
* Preferred names are never changed: the roster's spelling may not be the
|
|
101
|
+
* name a member goes by, and the preferred name is what the site shows. A
|
|
102
|
+
* difference is listed for an officer to follow up on.
|
|
103
|
+
*
|
|
104
|
+
* A roster email finds its account the way `production/accounts.ts` says:
|
|
105
|
+
* the profile's `ugaEmail` first, then the sign-in address.
|
|
106
|
+
*/
|
|
107
|
+
function nonBlank(value) {
|
|
108
|
+
const v = value?.trim();
|
|
109
|
+
if (!v) return void 0;
|
|
110
|
+
return v;
|
|
111
|
+
}
|
|
112
|
+
function planImport(members, accounts) {
|
|
113
|
+
const find = accountFinder(accounts);
|
|
114
|
+
const plan = {
|
|
115
|
+
create: [],
|
|
116
|
+
verify: [],
|
|
117
|
+
keep: [],
|
|
118
|
+
drop: [],
|
|
119
|
+
sharedAccounts: [],
|
|
120
|
+
nameDiffers: []
|
|
121
|
+
};
|
|
122
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
123
|
+
for (const member of members) {
|
|
124
|
+
const account = find(member.email);
|
|
125
|
+
if (!account) {
|
|
126
|
+
plan.create.push(member);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (claimed.has(account.userId)) {
|
|
130
|
+
plan.sharedAccounts.push({
|
|
131
|
+
email: member.email,
|
|
132
|
+
userId: account.userId
|
|
133
|
+
});
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
claimed.add(account.userId);
|
|
137
|
+
const match = {
|
|
138
|
+
member,
|
|
139
|
+
userId: account.userId,
|
|
140
|
+
hasProfile: account.hasProfile,
|
|
141
|
+
preferredName: account.preferredName
|
|
142
|
+
};
|
|
143
|
+
const rosterName = `${member.firstName} ${member.lastName}`.toLowerCase();
|
|
144
|
+
if (account.hasProfile && account.preferredName?.trim().toLowerCase() !== rosterName) plan.nameDiffers.push(match);
|
|
145
|
+
if (account.involvementFirstName != null) plan.keep.push(match);
|
|
146
|
+
else plan.verify.push(match);
|
|
147
|
+
}
|
|
148
|
+
for (const account of accounts) {
|
|
149
|
+
if (account.involvementFirstName == null || claimed.has(account.userId)) continue;
|
|
150
|
+
plan.drop.push({
|
|
151
|
+
userId: account.userId,
|
|
152
|
+
name: nonBlank(account.preferredName) ?? `${account.involvementFirstName} ${account.involvementLastName ?? ""}`.trim()
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
plan.drop.sort((a, b) => a.name.localeCompare(b.name));
|
|
156
|
+
return plan;
|
|
157
|
+
}
|
|
158
|
+
/** Everyone the import writes a profile for, created accounts excluded. */
|
|
159
|
+
function matches(plan) {
|
|
160
|
+
return [...plan.verify, ...plan.keep];
|
|
161
|
+
}
|
|
162
|
+
//#endregion
|
|
163
|
+
//#region src/involvement/store.ts
|
|
164
|
+
/**
|
|
165
|
+
* The write half of `import involvement`, after `plan.ts` has matched the
|
|
166
|
+
* roster against production's accounts (`production/accounts.ts`) and any
|
|
167
|
+
* missing accounts have been created.
|
|
168
|
+
*
|
|
169
|
+
* Accounts are created before this transaction, because Auth is a separate
|
|
170
|
+
* service. If the transaction then fails, those accounts exist without
|
|
171
|
+
* profiles; the next run finds them by sign-in address and inserts the
|
|
172
|
+
* profiles, so a rerun is the recovery.
|
|
173
|
+
*/
|
|
174
|
+
/**
|
|
175
|
+
* Upserts every roster member's profile, then clears involvement from every
|
|
176
|
+
* profile not among them, in one transaction.
|
|
177
|
+
*
|
|
178
|
+
* A new profile takes the roster's name as its preferred name; an existing
|
|
179
|
+
* one keeps its own (see `plan.ts`). The durable identity columns (`ugaEmail`, `legal*`,
|
|
180
|
+
* `identitySourcedAt`) are written for everyone on the roster and never
|
|
181
|
+
* cleared, so missing one export does not erase who someone is.
|
|
182
|
+
*/
|
|
183
|
+
const APPLY_UPSERT = `
|
|
184
|
+
insert into "platform"."profile" as p (
|
|
185
|
+
"userId", "preferredName",
|
|
186
|
+
"involvementFirstName", "involvementLastName", "involvementImportedAt",
|
|
187
|
+
"ugaEmail", "legalFirstName", "legalLastName", "identitySourcedAt"
|
|
188
|
+
)
|
|
189
|
+
select t.u, t.f || ' ' || t.l, t.f, t.l, now(), t.e, t.f, t.l, now()
|
|
190
|
+
from unnest($1::uuid[], $2::text[], $3::text[], $4::text[]) as t(u, f, l, e)
|
|
191
|
+
on conflict ("userId") do update set
|
|
192
|
+
"involvementFirstName" = excluded."involvementFirstName",
|
|
193
|
+
"involvementLastName" = excluded."involvementLastName",
|
|
194
|
+
"involvementImportedAt" = excluded."involvementImportedAt",
|
|
195
|
+
"ugaEmail" = excluded."ugaEmail",
|
|
196
|
+
"legalFirstName" = excluded."legalFirstName",
|
|
197
|
+
"legalLastName" = excluded."legalLastName",
|
|
198
|
+
"identitySourcedAt" = excluded."identitySourcedAt"
|
|
199
|
+
`;
|
|
200
|
+
const APPLY_CLEAR = `
|
|
201
|
+
update "platform"."profile"
|
|
202
|
+
set "involvementFirstName" = null,
|
|
203
|
+
"involvementLastName" = null,
|
|
204
|
+
"involvementImportedAt" = null
|
|
205
|
+
where not ("userId" = any($1::uuid[]))
|
|
206
|
+
and ("involvementFirstName" is not null or "involvementImportedAt" is not null)
|
|
207
|
+
`;
|
|
208
|
+
function applyImportFor(url) {
|
|
209
|
+
return (writes) => {
|
|
210
|
+
const ids = writes.map((w) => w.userId);
|
|
211
|
+
return withConnection(url, "The import was rolled back", (sql) => sql.begin(async (tx) => {
|
|
212
|
+
const upserted = await tx.unsafe(APPLY_UPSERT, [
|
|
213
|
+
ids,
|
|
214
|
+
writes.map((w) => w.firstName),
|
|
215
|
+
writes.map((w) => w.lastName),
|
|
216
|
+
writes.map((w) => w.email)
|
|
217
|
+
]);
|
|
218
|
+
const cleared = await tx.unsafe(APPLY_CLEAR, [ids]);
|
|
219
|
+
return {
|
|
220
|
+
written: upserted.count,
|
|
221
|
+
cleared: cleared.count
|
|
222
|
+
};
|
|
223
|
+
}));
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
//#endregion
|
|
227
|
+
//#region src/involvement/commands.ts
|
|
228
|
+
/**
|
|
229
|
+
* `backstage import involvement --file <OrganizationRoster.csv>`: verify the
|
|
230
|
+
* members on the UGA Involvement Network roster.
|
|
231
|
+
*
|
|
232
|
+
* Replaces the platform's /console/verification upload. Officers export the
|
|
233
|
+
* roster from the Involvement Network (Roster → Export → Organization Roster)
|
|
234
|
+
* and run this against production:
|
|
235
|
+
*
|
|
236
|
+
* 1. read and check the export (`csv.ts`): its organization must be DevDogs;
|
|
237
|
+
* 2. read every account and plan the import (`plan.ts`);
|
|
238
|
+
* 3. print who is newly verified, who loses verification, and how many
|
|
239
|
+
* accounts would be created, then ask;
|
|
240
|
+
* 4. create the missing accounts and write the profiles (`store.ts`).
|
|
241
|
+
*
|
|
242
|
+
* `--dry-run` stops after the preview. Production's `DB_URL`, `API_URL` and
|
|
243
|
+
* `SECRET_KEY` come from the checkout's `.env.production`, else Secrets
|
|
244
|
+
* Manager, so it runs under `pnpm dlx` with no checkout.
|
|
245
|
+
*
|
|
246
|
+
* Preferred names are never changed, so the homepage's officer board (which
|
|
247
|
+
* caches them) needs no invalidation.
|
|
248
|
+
*/
|
|
249
|
+
/** The organization a roster must be for. Compared case-insensitively. */
|
|
250
|
+
const ORGANIZATION = "DevDogs";
|
|
251
|
+
function parse(argv) {
|
|
252
|
+
try {
|
|
253
|
+
return parseArgs({
|
|
254
|
+
args: [...argv],
|
|
255
|
+
options: {
|
|
256
|
+
file: { type: "string" },
|
|
257
|
+
yes: { type: "boolean" },
|
|
258
|
+
"dry-run": { type: "boolean" }
|
|
259
|
+
},
|
|
260
|
+
allowPositionals: false,
|
|
261
|
+
strict: true
|
|
262
|
+
}).values;
|
|
263
|
+
} catch (err) {
|
|
264
|
+
throw new UsageError(errorMessage(err));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/** The preview: what changes, by whom. Never lists every kept member. */
|
|
268
|
+
function describePlan(plan, members) {
|
|
269
|
+
const lines = [
|
|
270
|
+
`${plural(members, "member")} on the roster:`,
|
|
271
|
+
` ${plan.keep.length} already verified`,
|
|
272
|
+
` ${plan.verify.length} newly verified (have an account)`,
|
|
273
|
+
` ${plan.create.length} without an account, to be created and verified`,
|
|
274
|
+
` ${plan.drop.length} verified now but not on the roster, to lose verification`
|
|
275
|
+
];
|
|
276
|
+
if (plan.verify.length > 0) lines.push("", "Newly verified:", ...plan.verify.map((m) => ` ${m.member.firstName} ${m.member.lastName} <${m.member.email}>`));
|
|
277
|
+
if (plan.drop.length > 0) lines.push("", "Losing verification:", ...plan.drop.map((d) => ` ${d.name}`));
|
|
278
|
+
if (plan.nameDiffers.length > 0) lines.push("", "Preferred name differs from the roster (left as is):", ...plan.nameDiffers.map((m) => ` ${m.preferredName ?? "(none)"}: roster says ${m.member.firstName} ${m.member.lastName}`));
|
|
279
|
+
if (plan.sharedAccounts.length > 0) lines.push("", "Skipped, their account already matched another roster email:", ...plan.sharedAccounts.map((s) => ` ${s.email}`));
|
|
280
|
+
return lines.join("\n");
|
|
281
|
+
}
|
|
282
|
+
async function runInvolvement(argv, deps = {}) {
|
|
283
|
+
const interactive = deps.interactive ?? canPrompt();
|
|
284
|
+
try {
|
|
285
|
+
const values = parse(argv);
|
|
286
|
+
const dryRun = values["dry-run"] === true || isDryRun();
|
|
287
|
+
const roster = parseRoster(await readInputFile(values.file, {
|
|
288
|
+
interactive,
|
|
289
|
+
message: "Path to the Involvement Network roster export",
|
|
290
|
+
placeholder: "OrganizationRoster.csv",
|
|
291
|
+
read: deps.readFile
|
|
292
|
+
}));
|
|
293
|
+
const wrongOrg = roster.organizations.filter((o) => o.toLowerCase() !== ORGANIZATION.toLowerCase());
|
|
294
|
+
if (wrongOrg.length > 0) throw new UsageError(`This roster is for ${wrongOrg.join(", ")}, not ${ORGANIZATION}.`);
|
|
295
|
+
if (roster.members.length === 0) throw new UsageError("The roster has no members with a name and email.");
|
|
296
|
+
if (roster.skippedLines.length > 0) say(`Skipped ${plural(roster.skippedLines.length, "row")} missing a name or email (lines ${roster.skippedLines.join(", ")}).`, "warn");
|
|
297
|
+
if (roster.conflictingNames.length > 0) say(`Listed under two names, kept the first: ${roster.conflictingNames.join(", ")}.`, "warn");
|
|
298
|
+
const dbUrl = deps.accounts ? void 0 : await requireProductionKey("DB_URL");
|
|
299
|
+
const accounts = await (deps.accounts ?? (() => readAccounts(dbUrl)))();
|
|
300
|
+
const plan = planImport(roster.members, accounts);
|
|
301
|
+
process.stderr.write(`${describePlan(plan, roster.members.length)}\n`);
|
|
302
|
+
if (dryRun) {
|
|
303
|
+
say("Dry run: nothing written.");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (plan.create.length === 0 && plan.verify.length === 0 && plan.drop.length === 0 && plan.keep.length === 0) {
|
|
307
|
+
say("Nothing to import.");
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const question = `Import into production: create ${plural(plan.create.length, "account")}, verify ${plan.verify.length}, unverify ${plan.drop.length}?`;
|
|
311
|
+
if (!await confirmWrite(question, {
|
|
312
|
+
yes: values.yes === true,
|
|
313
|
+
interactive
|
|
314
|
+
})) {
|
|
315
|
+
say("Nothing written.");
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
let create = deps.createAccount;
|
|
319
|
+
if (!create && plan.create.length > 0) create = createAccountFor(await requireProductionKey("API_URL"), await requireProductionKey("SECRET_KEY"));
|
|
320
|
+
const { created: createdIds, failed } = create ? await createAccounts(plan.create.map((m) => m.email), create) : {
|
|
321
|
+
created: /* @__PURE__ */ new Map(),
|
|
322
|
+
failed: []
|
|
323
|
+
};
|
|
324
|
+
const created = plan.create.flatMap((m) => {
|
|
325
|
+
const userId = createdIds.get(m.email);
|
|
326
|
+
return userId ? [{
|
|
327
|
+
userId,
|
|
328
|
+
email: m.email,
|
|
329
|
+
firstName: m.firstName,
|
|
330
|
+
lastName: m.lastName
|
|
331
|
+
}] : [];
|
|
332
|
+
});
|
|
333
|
+
if (failed.length > 0) say(`Could not create ${plural(failed.length, "account")}; they stay unverified until the next import:\n${failed.map((f) => ` ${f.email}: ${f.reason}`).join("\n")}`, "warn");
|
|
334
|
+
const writes = [...matches(plan).map((m) => ({
|
|
335
|
+
userId: m.userId,
|
|
336
|
+
email: m.member.email,
|
|
337
|
+
firstName: m.member.firstName,
|
|
338
|
+
lastName: m.member.lastName
|
|
339
|
+
})), ...created];
|
|
340
|
+
const { written, cleared } = await (deps.apply ?? applyImportFor(dbUrl))(writes);
|
|
341
|
+
say(`Imported: ${plural(written, "profile")} verified (${plural(created.length, "new account")}), ${cleared} unverified.`, "success");
|
|
342
|
+
if (failed.length > 0) process.exitCode = 1;
|
|
343
|
+
} catch (err) {
|
|
344
|
+
reportFailure("import involvement", err, [RosterFormatError]);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
const handleInvolvement = async (rest) => {
|
|
348
|
+
await runInvolvement(rest);
|
|
349
|
+
return process.exitCode ? null : DONE;
|
|
350
|
+
};
|
|
351
|
+
//#endregion
|
|
352
|
+
export { handleInvolvement };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { i as explain } from "./ui-CdKo8mLw.js";
|
|
2
|
+
//#region src/import/commands.ts
|
|
3
|
+
const handleImport = async (rest) => {
|
|
4
|
+
const [what, ...args] = rest;
|
|
5
|
+
if (what === "involvement") return (await import("./commands-CLSxKz-l.js")).handleInvolvement(args);
|
|
6
|
+
if (what === "attendance") return (await import("./commands-BQ2NIqub.js")).handleAttendance(args);
|
|
7
|
+
explain(what ? `Unknown import "${what}". Try involvement or attendance.` : "Name the import: involvement or attendance.", "");
|
|
8
|
+
process.exitCode = 1;
|
|
9
|
+
return null;
|
|
10
|
+
};
|
|
11
|
+
//#endregion
|
|
12
|
+
export { handleImport };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { p as isNonInteractive } from "./telemetry-Bjoz29Hl.js";
|
|
2
|
-
import {
|
|
2
|
+
import { n as isDryRun } from "./dry-run-3IxPmCtW.js";
|
|
3
3
|
import { a as explainError, o as unwrap, t as UsageError } from "./ui-CdKo8mLw.js";
|
|
4
|
+
import { i as qrParseOptions, n as QR_FLAGS, t as QR_EXTRA_FLAGS } from "./options-DCaqm8BI.js";
|
|
4
5
|
import { t as DONE } from "./dispatch-D048O65I.js";
|
|
5
6
|
import { dirname, extname, relative, resolve } from "node:path";
|
|
6
7
|
import { log, text } from "@clack/prompts";
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { C as discoverRepoRoot, p as isNonInteractive } from "./telemetry-Bjoz29Hl.js";
|
|
2
2
|
import { t as nonEmpty } from "./connection-D3XsOE3S.js";
|
|
3
3
|
import { a as explainError, i as explain, o as unwrap, r as errorMessage, t as UsageError } from "./ui-CdKo8mLw.js";
|
|
4
|
-
import { c as EnvDocument, i as readPasswordFromVault, n as bwArgs, r as openVault, s as bwCommand } from "./vault-C8LYJFOQ.js";
|
|
5
4
|
import { t as DONE } from "./dispatch-D048O65I.js";
|
|
5
|
+
import { t as EnvDocument } from "./document-DumoZXoO.js";
|
|
6
|
+
import { i as readPasswordFromVault, n as bwArgs, r as openVault, s as bwCommand } from "./vault-CsBC-1Mq.js";
|
|
6
7
|
import { resolve } from "node:path";
|
|
7
|
-
import { confirm, log, multiselect, note, password, text } from "@clack/prompts";
|
|
8
|
+
import { confirm, log, multiselect, note, password, select, text } from "@clack/prompts";
|
|
8
9
|
import { readFile } from "node:fs/promises";
|
|
9
10
|
import { spawn } from "node:child_process";
|
|
10
11
|
import { parseArgs } from "node:util";
|
|
@@ -51,12 +52,12 @@ var CredsError = class extends Error {
|
|
|
51
52
|
//#endregion
|
|
52
53
|
//#region src/creds/item.ts
|
|
53
54
|
/**
|
|
54
|
-
* A shared account as `creds` sees it: a
|
|
55
|
-
*
|
|
55
|
+
* A shared account as `creds` sees it: a login or secure note in the DevDogs
|
|
56
|
+
* Bitwarden organization, plus the custom fields that record who has it.
|
|
56
57
|
*
|
|
57
58
|
* Those fields are the only access record. The Send, the Linear report and
|
|
58
59
|
* every "who has Canva?" answer are generated from them, so they are plain
|
|
59
|
-
* text fields anyone
|
|
60
|
+
* text fields anyone with the item can read and correct in Bitwarden
|
|
60
61
|
* itself:
|
|
61
62
|
*
|
|
62
63
|
* Recipients comma-separated UGA emails, the Send's `--emails` list
|
|
@@ -194,8 +195,8 @@ function isoDay(date) {
|
|
|
194
195
|
//#endregion
|
|
195
196
|
//#region src/creds/bitwarden.ts
|
|
196
197
|
/**
|
|
197
|
-
* The
|
|
198
|
-
* bundled `bw` on the officer's personal session (`bws/vault.ts`).
|
|
198
|
+
* The DevDogs Bitwarden organization's items and the Sends made from them,
|
|
199
|
+
* through the bundled `bw` on the officer's personal session (`bws/vault.ts`).
|
|
199
200
|
*
|
|
200
201
|
* Sends belong to the account that creates them, not to the organization, so
|
|
201
202
|
* this always runs as a person and never on CI.
|
|
@@ -203,20 +204,23 @@ function isoDay(date) {
|
|
|
203
204
|
* ## Secret handling
|
|
204
205
|
*
|
|
205
206
|
* * Every value travels to `bw` on **stdin**, base64 JSON as `bw create`,
|
|
206
|
-
* `bw edit` and `bw send create|edit` document. argv carries only ids
|
|
207
|
-
* the
|
|
207
|
+
* `bw edit` and `bw send create|edit` document. argv carries only ids and
|
|
208
|
+
* the session key `bwArgs` already passes.
|
|
208
209
|
* * `bw`'s stdout is parsed and never printed: an item or a Send read back
|
|
209
210
|
* holds the password.
|
|
210
211
|
* * `bw`'s stderr is only shown through `CredsError`, which scrubs every value
|
|
211
212
|
* registered so far.
|
|
212
213
|
*
|
|
213
|
-
* ## The
|
|
214
|
+
* ## The organization fence
|
|
214
215
|
*
|
|
215
|
-
* `creds` reads and writes
|
|
216
|
-
*
|
|
217
|
-
* never edited and never sent;
|
|
216
|
+
* `creds` reads and writes the logins and secure notes of ONE organization,
|
|
217
|
+
* found by name. Anything else the account can see (a personal login, another
|
|
218
|
+
* organization's item) is never listed, never edited and never sent;
|
|
219
|
+
* `updateItem` re-checks before every write. Production secrets are not
|
|
220
|
+
* vault items at all: they live in Secrets Manager, which `bw` cannot read.
|
|
218
221
|
*/
|
|
219
|
-
|
|
222
|
+
/** Matched case-insensitively against the organizations the account belongs to. */
|
|
223
|
+
const ORGANIZATION_NAME = "DevDogs";
|
|
220
224
|
function encode(value) {
|
|
221
225
|
return Buffer.from(JSON.stringify(value)).toString("base64");
|
|
222
226
|
}
|
|
@@ -249,7 +253,7 @@ function spawnRunner(key) {
|
|
|
249
253
|
});
|
|
250
254
|
}
|
|
251
255
|
let runnerOverride;
|
|
252
|
-
/** Opens the vault (asking first) and the
|
|
256
|
+
/** Opens the vault (asking first) and finds the DevDogs organization in it. */
|
|
253
257
|
async function connectSharedVault() {
|
|
254
258
|
let runner = runnerOverride;
|
|
255
259
|
if (!runner) {
|
|
@@ -259,7 +263,7 @@ async function connectSharedVault() {
|
|
|
259
263
|
}
|
|
260
264
|
const shared = new SharedVault(runner);
|
|
261
265
|
await shared.sync();
|
|
262
|
-
await shared.
|
|
266
|
+
await shared.organization();
|
|
263
267
|
return shared;
|
|
264
268
|
}
|
|
265
269
|
var SharedVault = class {
|
|
@@ -287,34 +291,45 @@ var SharedVault = class {
|
|
|
287
291
|
async account() {
|
|
288
292
|
return (await this.json(["status"])).userEmail ?? "unknown";
|
|
289
293
|
}
|
|
290
|
-
/** The one
|
|
291
|
-
async
|
|
294
|
+
/** The one organization named like {@link ORGANIZATION_NAME} this account belongs to. */
|
|
295
|
+
async organization() {
|
|
292
296
|
if (this.found) return this.found;
|
|
293
|
-
const matches = (await this.json(["list", "
|
|
294
|
-
if (matches.length !== 1) throw new CredsError(matches.length === 0 ? `
|
|
297
|
+
const matches = (await this.json(["list", "organizations"])).filter((o) => o.name.toLowerCase().includes(ORGANIZATION_NAME.toLowerCase()));
|
|
298
|
+
if (matches.length !== 1) throw new CredsError(matches.length === 0 ? `Your Bitwarden account is not in the ${ORGANIZATION_NAME} organization. Ask an organization admin to invite you.` : `Your Bitwarden account is in ${matches.length} organizations named like "${ORGANIZATION_NAME}" (${matches.map((o) => o.name).join(", ")}); creds cannot tell which one to use.`);
|
|
295
299
|
this.found = matches[0];
|
|
296
300
|
return this.found;
|
|
297
301
|
}
|
|
298
|
-
|
|
299
|
-
return raw.organizationId ===
|
|
302
|
+
shareable(raw, org) {
|
|
303
|
+
return raw.organizationId === org.id && (raw.type === ITEM_TYPE.login || raw.type === ITEM_TYPE.secureNote);
|
|
300
304
|
}
|
|
301
|
-
/** Every
|
|
305
|
+
/** Every login and secure note in the organization, by name. */
|
|
302
306
|
async listItems() {
|
|
303
|
-
const
|
|
307
|
+
const org = await this.organization();
|
|
304
308
|
return (await this.json([
|
|
305
309
|
"list",
|
|
306
310
|
"items",
|
|
307
|
-
"--
|
|
308
|
-
|
|
309
|
-
])).filter((item) => this.
|
|
311
|
+
"--organizationid",
|
|
312
|
+
org.id
|
|
313
|
+
])).filter((item) => this.shareable(item, org)).map(parseItem).sort((a, b) => a.name.localeCompare(b.name));
|
|
310
314
|
}
|
|
311
|
-
/**
|
|
315
|
+
/** The organization's collections this account can see, by name. */
|
|
316
|
+
async collections() {
|
|
317
|
+
const org = await this.organization();
|
|
318
|
+
return (await this.json([
|
|
319
|
+
"list",
|
|
320
|
+
"collections",
|
|
321
|
+
"--organizationid",
|
|
322
|
+
org.id
|
|
323
|
+
])).filter((c) => c.organizationId === org.id).sort((a, b) => a.name.localeCompare(b.name));
|
|
324
|
+
}
|
|
325
|
+
/** A new login, owned by the organization and placed in `collection`. */
|
|
312
326
|
async createLogin(input) {
|
|
313
327
|
registerSecret(input.password);
|
|
314
|
-
const
|
|
328
|
+
const org = await this.organization();
|
|
329
|
+
if (input.collection.organizationId !== org.id) throw new CredsError(`The "${input.collection.name}" collection is not in the ${org.name} organization.`);
|
|
315
330
|
const item = {
|
|
316
|
-
organizationId:
|
|
317
|
-
collectionIds: [collection.id],
|
|
331
|
+
organizationId: org.id,
|
|
332
|
+
collectionIds: [input.collection.id],
|
|
318
333
|
folderId: null,
|
|
319
334
|
type: 1,
|
|
320
335
|
name: input.name,
|
|
@@ -338,10 +353,10 @@ var SharedVault = class {
|
|
|
338
353
|
};
|
|
339
354
|
return parseItem(await this.json(["create", "item"], encode(item)));
|
|
340
355
|
}
|
|
341
|
-
/** Saves `raw`, which must still be
|
|
356
|
+
/** Saves `raw`, which must still be a login or secure note of the organization. */
|
|
342
357
|
async updateItem(raw) {
|
|
343
|
-
const
|
|
344
|
-
if (!this.
|
|
358
|
+
const org = await this.organization();
|
|
359
|
+
if (!this.shareable(raw, org)) throw new CredsError(`"${raw.name}" is not a login or secure note of the ${org.name} organization; creds will not change it.`);
|
|
345
360
|
return parseItem(await this.json([
|
|
346
361
|
"edit",
|
|
347
362
|
"item",
|
|
@@ -420,7 +435,7 @@ function sendName(item) {
|
|
|
420
435
|
}
|
|
421
436
|
/** Private to the sender: how to find the item this Send came from. */
|
|
422
437
|
function sendNotes(item) {
|
|
423
|
-
return `Made by backstage creds from the ${
|
|
438
|
+
return `Made by backstage creds from the ${ORGANIZATION_NAME} item "${item.name}" (${item.id}).`;
|
|
424
439
|
}
|
|
425
440
|
function deletionDate(now) {
|
|
426
441
|
return new Date(now.getTime() + 2592e6).toISOString();
|
|
@@ -617,7 +632,7 @@ async function readCheckoutDbUrl() {
|
|
|
617
632
|
}
|
|
618
633
|
}
|
|
619
634
|
async function readSecretsManagerDbUrl() {
|
|
620
|
-
const { listSecrets, projectIdFor } = await import("./client-
|
|
635
|
+
const { listSecrets, projectIdFor } = await import("./client-DwDGjalU.js");
|
|
621
636
|
const secrets = await listSecrets(await projectIdFor(PRODUCTION_PROJECT));
|
|
622
637
|
return nonEmpty(secrets.find((s) => s.key === "DB_URL")?.value);
|
|
623
638
|
}
|
|
@@ -653,17 +668,18 @@ function cell(text) {
|
|
|
653
668
|
/** The document's markdown. Pure: `now` decides the statuses. */
|
|
654
669
|
function renderReport(items, now, roster) {
|
|
655
670
|
const lines = [
|
|
656
|
-
"> Generated by `backstage creds` from the
|
|
671
|
+
"> Generated by `backstage creds` from the DevDogs Bitwarden organization: every item shared as a Send. Manual edits are overwritten on the next run; change the item's fields in Bitwarden, or run `backstage creds send`.",
|
|
657
672
|
"",
|
|
658
673
|
`Last generated ${now.toISOString().slice(0, 16).replace("T", " ")} UTC. Sends are deleted 30 days after they were last renewed; **Expires Soon** means within 7 days, so run \`backstage creds renew\`.`,
|
|
659
674
|
""
|
|
660
675
|
];
|
|
661
|
-
|
|
662
|
-
|
|
676
|
+
const shared = items.filter((i) => i.recipients.length > 0 || i.send !== void 0);
|
|
677
|
+
if (shared.length === 0) {
|
|
678
|
+
lines.push("Nothing has been shared yet.");
|
|
663
679
|
return `${lines.join("\n")}\n`;
|
|
664
680
|
}
|
|
665
681
|
lines.push("| Credential | Account | Recipients | Owner | Status | Expires | Send |", "| --- | --- | --- | --- | --- | --- | --- |");
|
|
666
|
-
for (const item of
|
|
682
|
+
for (const item of shared) {
|
|
667
683
|
const recipients = item.recipients.length > 0 ? item.recipients.map((e) => describeRecipient(roster, e)).join(", ") : "—";
|
|
668
684
|
const link = item.send?.link ? `[Open](${item.send.link})` : "—";
|
|
669
685
|
lines.push(`| ${[
|
|
@@ -729,7 +745,7 @@ async function writeReport(token, documentId, content, fetchImpl = fetch) {
|
|
|
729
745
|
* shared as Bitwarden Sends that only the officers they name can open.
|
|
730
746
|
*
|
|
731
747
|
* send pick shared accounts and officers; create or update one Send each
|
|
732
|
-
* add save a new login
|
|
748
|
+
* add save a new login to the DevDogs organization, then send it
|
|
733
749
|
* renew push every Send's deletion 30 days out and re-sync its recipients
|
|
734
750
|
* list what is shared with whom, until when
|
|
735
751
|
* report regenerate the Shared Accounts Linear document
|
|
@@ -738,8 +754,8 @@ async function writeReport(token, documentId, content, fetchImpl = fetch) {
|
|
|
738
754
|
*
|
|
739
755
|
* ## Where things live
|
|
740
756
|
*
|
|
741
|
-
* The values live only in the DevDogs Bitwarden organization
|
|
742
|
-
*
|
|
757
|
+
* The values live only in the DevDogs Bitwarden organization: any login or
|
|
758
|
+
* secure note in it can be shared. Each item's custom fields record its recipients and its
|
|
743
759
|
* Send (`item.ts`); the Linear document is generated from them (`report.ts`);
|
|
744
760
|
* the roster of who may receive one is read live from production
|
|
745
761
|
* (`roster.ts`).
|
|
@@ -790,6 +806,7 @@ function parse(argv) {
|
|
|
790
806
|
url: { type: "string" },
|
|
791
807
|
username: { type: "string" },
|
|
792
808
|
owner: { type: "string" },
|
|
809
|
+
collection: { type: "string" },
|
|
793
810
|
"password-stdin": { type: "boolean" },
|
|
794
811
|
document: { type: "string" },
|
|
795
812
|
"dry-run": { type: "boolean" }
|
|
@@ -831,7 +848,7 @@ function findItem(items, typed) {
|
|
|
831
848
|
}
|
|
832
849
|
async function pickItems(items, typed, message) {
|
|
833
850
|
if (typed && typed.length > 0) return typed.map((t) => findItem(items, t));
|
|
834
|
-
if (items.length === 0) throw new UsageError("The
|
|
851
|
+
if (items.length === 0) throw new UsageError("The DevDogs organization has no logins you can see. Add one with `backstage creds add`.");
|
|
835
852
|
if (!interactive()) throw new UsageError("Name the accounts with --item <name>.");
|
|
836
853
|
const ids = unwrap(await multiselect({
|
|
837
854
|
message,
|
|
@@ -1023,17 +1040,40 @@ async function ask(given, message, required) {
|
|
|
1023
1040
|
validate: required ? (v) => v?.trim() ? void 0 : "Required." : void 0
|
|
1024
1041
|
}))?.trim() || void 0;
|
|
1025
1042
|
}
|
|
1043
|
+
/** `--collection`, the only one there is, or a choice. Every organization item needs one. */
|
|
1044
|
+
async function pickCollection(vault, typed) {
|
|
1045
|
+
const all = await vault.collections();
|
|
1046
|
+
if (typed !== void 0) {
|
|
1047
|
+
const needle = typed.trim().toLowerCase();
|
|
1048
|
+
const match = all.find((c) => c.name.toLowerCase() === needle);
|
|
1049
|
+
if (!match) throw new UsageError(`No collection called "${typed}" that you can see. There are: ${all.map((c) => c.name).join(", ") || "none"}.`);
|
|
1050
|
+
return match;
|
|
1051
|
+
}
|
|
1052
|
+
if (all.length === 0) throw new UsageError("You cannot see any collection in the DevDogs organization to save to. Ask an organization admin for access to one.");
|
|
1053
|
+
if (all.length === 1) return all[0];
|
|
1054
|
+
if (!interactive()) throw new UsageError(`Name the collection with --collection: ${all.map((c) => c.name).join(", ")}.`);
|
|
1055
|
+
const id = unwrap(await select({
|
|
1056
|
+
message: "Which collection?",
|
|
1057
|
+
options: all.map((c) => ({
|
|
1058
|
+
value: c.id,
|
|
1059
|
+
label: c.name
|
|
1060
|
+
}))
|
|
1061
|
+
}));
|
|
1062
|
+
return all.find((c) => c.id === id);
|
|
1063
|
+
}
|
|
1026
1064
|
async function runAdd(vault, values, deps) {
|
|
1027
1065
|
const existing = await vault.listItems();
|
|
1028
1066
|
const name = await ask(values.name, "Name? (e.g. Instagram)", true);
|
|
1029
|
-
if (existing.some((i) => i.name.toLowerCase() === name.toLowerCase())) throw new UsageError(`"${name}" is already in the
|
|
1067
|
+
if (existing.some((i) => i.name.toLowerCase() === name.toLowerCase())) throw new UsageError(`"${name}" is already in the organization. Use \`backstage creds send --item "${name}"\`.`);
|
|
1030
1068
|
const url = await ask(values.url, "Login page URL? (optional)", false);
|
|
1031
1069
|
const username = await ask(values.username, "Username or email? (optional)", false);
|
|
1032
1070
|
const password = await readPassword(values, deps);
|
|
1033
1071
|
registerSecret(password);
|
|
1034
1072
|
const notes = interactive() ? await ask(void 0, "Notes for recipients? (optional, never printed)", false) : void 0;
|
|
1035
1073
|
const owner = await ask(values.owner, "Owner? (the officer responsible)", false);
|
|
1074
|
+
const collection = await pickCollection(vault, values.collection);
|
|
1036
1075
|
const item = await vault.createLogin({
|
|
1076
|
+
collection,
|
|
1037
1077
|
name,
|
|
1038
1078
|
url,
|
|
1039
1079
|
username,
|
|
@@ -1041,7 +1081,7 @@ async function runAdd(vault, values, deps) {
|
|
|
1041
1081
|
notes,
|
|
1042
1082
|
owner
|
|
1043
1083
|
});
|
|
1044
|
-
say(`Saved ${item.name} to the
|
|
1084
|
+
say(`Saved ${item.name} to the ${collection.name} collection.`, "success");
|
|
1045
1085
|
await sendItems(vault, [item], values, deps);
|
|
1046
1086
|
}
|
|
1047
1087
|
async function runRenew(vault, values, deps) {
|