@devdogsuga/backstage 0.1.5 → 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 +79 -16
- package/dist/{catalog-BUOwCd2Q.js → catalog-Dke7bQVz.js} +124 -6
- package/dist/{cli-B2S6gw9o.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-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/package.json +6 -6
- 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,8 +1,9 @@
|
|
|
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
8
|
import { confirm, log, multiselect, note, password, select, text } from "@clack/prompts";
|
|
8
9
|
import { readFile } from "node:fs/promises";
|
|
@@ -631,7 +632,7 @@ async function readCheckoutDbUrl() {
|
|
|
631
632
|
}
|
|
632
633
|
}
|
|
633
634
|
async function readSecretsManagerDbUrl() {
|
|
634
|
-
const { listSecrets, projectIdFor } = await import("./client-
|
|
635
|
+
const { listSecrets, projectIdFor } = await import("./client-DwDGjalU.js");
|
|
635
636
|
const secrets = await listSecrets(await projectIdFor(PRODUCTION_PROJECT));
|
|
636
637
|
return nonEmpty(secrets.find((s) => s.key === "DB_URL")?.value);
|
|
637
638
|
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
//#region ../cli-core/src/env/document.ts
|
|
2
|
+
/**
|
|
3
|
+
* An editable `.env` that survives being edited.
|
|
4
|
+
*
|
|
5
|
+
* The root `.env` is not a data file. It is 150 lines of hard-won commentary
|
|
6
|
+
* about which value breaks the Supabase CLI when empty, which one must stay
|
|
7
|
+
* commented out, and why. A writer that parses to a Map and serializes back
|
|
8
|
+
* destroys all of it on the first save.
|
|
9
|
+
*
|
|
10
|
+
* So this keeps the file as **lines** and edits in place. An untouched line is
|
|
11
|
+
* returned byte-for-byte, including its spacing, quoting style and trailing
|
|
12
|
+
* comment. Only the lines actually being changed are rewritten.
|
|
13
|
+
*
|
|
14
|
+
* Nothing is ever deleted. Removing a key comments it out, so the value stays
|
|
15
|
+
* recoverable from the file itself, and re-adding it later uncomments that line
|
|
16
|
+
* rather than appending a duplicate.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* `KEY=value`, active. Groups: indent, `export ` prefix, key, value.
|
|
20
|
+
*
|
|
21
|
+
* The prefix is CAPTURED rather than skipped so an update can put it back.
|
|
22
|
+
* Dropping it turns `export FOO=` into `FOO=`, which still parses here and
|
|
23
|
+
* stops being exported to child processes. That shows up as a missing variable
|
|
24
|
+
* three tools downstream.
|
|
25
|
+
*/
|
|
26
|
+
const ACTIVE = /^(\s*)((?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/;
|
|
27
|
+
/** `# KEY=value`, the commented form this tool writes and reads back. */
|
|
28
|
+
const COMMENTED = /^(\s*)#\s?((?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/;
|
|
29
|
+
/**
|
|
30
|
+
* Reads the value half of an assignment.
|
|
31
|
+
*
|
|
32
|
+
* Deliberately NOT a general dotenv parser: it does not expand `$VAR`, because
|
|
33
|
+
* a stored value that means one thing in the file and another in the process
|
|
34
|
+
* cannot be rotated with confidence.
|
|
35
|
+
*/
|
|
36
|
+
function parseValue(rest) {
|
|
37
|
+
const text = rest.trim();
|
|
38
|
+
if (text.startsWith("\"")) {
|
|
39
|
+
const end = findClosing(text, "\"");
|
|
40
|
+
if (end === -1) return unescape(text.slice(1));
|
|
41
|
+
return unescape(text.slice(1, end));
|
|
42
|
+
}
|
|
43
|
+
if (text.startsWith("'")) {
|
|
44
|
+
const end = findClosing(text, "'");
|
|
45
|
+
if (end === -1) return text.slice(1);
|
|
46
|
+
return text.slice(1, end);
|
|
47
|
+
}
|
|
48
|
+
const hash = text.search(/\s#/);
|
|
49
|
+
return (hash === -1 ? text : text.slice(0, hash)).trim();
|
|
50
|
+
}
|
|
51
|
+
function findClosing(text, quote) {
|
|
52
|
+
for (let i = 1; i < text.length; i += 1) {
|
|
53
|
+
if (text[i] === "\\") {
|
|
54
|
+
i += 1;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (text[i] === quote) return i;
|
|
58
|
+
}
|
|
59
|
+
return -1;
|
|
60
|
+
}
|
|
61
|
+
function unescape(text) {
|
|
62
|
+
return text.replace(/\\([nrt\\"'])/g, (_, c) => c === "n" ? "\n" : c === "r" ? "\r" : c === "t" ? " " : c);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Splits everything after `=` into its value and its trailing comment.
|
|
66
|
+
*
|
|
67
|
+
* Quote-aware, because the naive "cut at the first `#`" truncates a generated
|
|
68
|
+
* password into something that still looks like one. A `#` inside quotes is
|
|
69
|
+
* part of the value; outside them it starts a comment only when whitespace
|
|
70
|
+
* precedes it.
|
|
71
|
+
*/
|
|
72
|
+
function splitComment(rest) {
|
|
73
|
+
const text = rest.trimStart();
|
|
74
|
+
let after;
|
|
75
|
+
if (text.startsWith("\"") || text.startsWith("'")) {
|
|
76
|
+
const end = findClosing(text, text[0]);
|
|
77
|
+
after = end === -1 ? text.length : end + 1;
|
|
78
|
+
} else {
|
|
79
|
+
const hash = text.search(/\s#/);
|
|
80
|
+
after = hash === -1 ? text.length : hash;
|
|
81
|
+
}
|
|
82
|
+
const tail = text.slice(after);
|
|
83
|
+
const hash = tail.indexOf("#");
|
|
84
|
+
return { comment: hash === -1 ? "" : tail.slice(hash).trimEnd() };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Recognises a stamp this tool wrote, so it is replaced rather than duplicated.
|
|
88
|
+
*
|
|
89
|
+
* Deliberately narrow: it matches the exact bracketed shape below and nothing
|
|
90
|
+
* else, because the cost of a false positive is deleting somebody's own note.
|
|
91
|
+
*/
|
|
92
|
+
const STAMP = /\s*#\s*\[[a-z][a-z0-9-]* (?:pushed|pulled) \d{4}-\d{2}-\d{2}\]\s*$/;
|
|
93
|
+
function stampText(stamp) {
|
|
94
|
+
return `# [${stamp.environment} ${stamp.action} ${stamp.date}]`;
|
|
95
|
+
}
|
|
96
|
+
function isStamp(comment) {
|
|
97
|
+
return STAMP.test(comment);
|
|
98
|
+
}
|
|
99
|
+
/** Always double-quoted, so a multi-line value round-trips through one line. */
|
|
100
|
+
function quote(value) {
|
|
101
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}"`;
|
|
102
|
+
}
|
|
103
|
+
var EnvDocument = class EnvDocument {
|
|
104
|
+
lines;
|
|
105
|
+
/** Whether this session has already appended, so it separates only once. */
|
|
106
|
+
appended = false;
|
|
107
|
+
constructor(lines) {
|
|
108
|
+
this.lines = lines;
|
|
109
|
+
}
|
|
110
|
+
static parse(text) {
|
|
111
|
+
const lines = text.split("\n").map((raw) => {
|
|
112
|
+
const active = ACTIVE.exec(raw);
|
|
113
|
+
if (active) return {
|
|
114
|
+
raw,
|
|
115
|
+
key: active[3],
|
|
116
|
+
value: parseValue(active[4])
|
|
117
|
+
};
|
|
118
|
+
const commented = COMMENTED.exec(raw);
|
|
119
|
+
if (commented) return {
|
|
120
|
+
raw,
|
|
121
|
+
key: commented[3],
|
|
122
|
+
value: parseValue(commented[4]),
|
|
123
|
+
commented: true
|
|
124
|
+
};
|
|
125
|
+
return { raw };
|
|
126
|
+
});
|
|
127
|
+
return new EnvDocument(lines);
|
|
128
|
+
}
|
|
129
|
+
static empty() {
|
|
130
|
+
return new EnvDocument([]);
|
|
131
|
+
}
|
|
132
|
+
find(key, commented) {
|
|
133
|
+
return this.lines.findIndex((l) => l.key === key && Boolean(l.commented) === commented);
|
|
134
|
+
}
|
|
135
|
+
/** The active value, or undefined when absent or commented out. */
|
|
136
|
+
get(key) {
|
|
137
|
+
const i = this.find(key, false);
|
|
138
|
+
return i === -1 ? void 0 : this.lines[i].value;
|
|
139
|
+
}
|
|
140
|
+
/** Present and active. */
|
|
141
|
+
has(key) {
|
|
142
|
+
return this.find(key, false) !== -1;
|
|
143
|
+
}
|
|
144
|
+
/** Present, but commented out. */
|
|
145
|
+
isCommented(key) {
|
|
146
|
+
return this.find(key, false) === -1 && this.find(key, true) !== -1;
|
|
147
|
+
}
|
|
148
|
+
/** Every active assignment, in file order. */
|
|
149
|
+
entries() {
|
|
150
|
+
return this.lines.filter((l) => l.key !== void 0 && !l.commented).map((l) => [l.key, l.value]);
|
|
151
|
+
}
|
|
152
|
+
keys() {
|
|
153
|
+
return this.entries().map(([k]) => k);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Sets a value, preferring to revive a commented line over appending.
|
|
157
|
+
*
|
|
158
|
+
* Appending when a commented form already exists is how a file ends up with
|
|
159
|
+
* the same key twice: one stale, both plausible, and which one looks
|
|
160
|
+
* authoritative depends on where the reader scrolled to.
|
|
161
|
+
*/
|
|
162
|
+
set(key, value, stamp) {
|
|
163
|
+
const active = this.find(key, false);
|
|
164
|
+
if (active !== -1) {
|
|
165
|
+
const line = this.lines[active];
|
|
166
|
+
const match = ACTIVE.exec(line.raw);
|
|
167
|
+
const existing = splitComment(match[4]).comment;
|
|
168
|
+
if (stamp && existing !== "" && !isStamp(existing)) this.lines.splice(this.first(key), 0, { raw: existing });
|
|
169
|
+
const trailing = stamp ? ` ${stampText(stamp)}` : existing === "" ? "" : ` ${existing}`;
|
|
170
|
+
const at = this.find(key, false);
|
|
171
|
+
const target = this.lines[at];
|
|
172
|
+
target.raw = `${match[1]}${match[2]}${key}=${quote(value)}${trailing}`;
|
|
173
|
+
target.value = value;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const commented = this.find(key, true);
|
|
177
|
+
if (commented !== -1) {
|
|
178
|
+
const line = this.lines[commented];
|
|
179
|
+
line.raw = `${key}=${quote(value)}${stamp ? ` ${stampText(stamp)}` : ""}`;
|
|
180
|
+
line.value = value;
|
|
181
|
+
line.commented = false;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (!this.appended && this.lines.length > 0 && this.lines.at(-1).raw !== "") this.lines.push({ raw: "" });
|
|
185
|
+
this.appended = true;
|
|
186
|
+
this.lines.push({
|
|
187
|
+
raw: `${key}=${quote(value)}${stamp ? ` ${stampText(stamp)}` : ""}`,
|
|
188
|
+
key,
|
|
189
|
+
value
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
/** Index of the first line mentioning a key, active or commented. */
|
|
193
|
+
first(key) {
|
|
194
|
+
return this.lines.findIndex((l) => l.key === key);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Moves every line for a key next to that key's first appearance.
|
|
198
|
+
*
|
|
199
|
+
* Files drift: a key gets commented out near the bottom, re-added at the top
|
|
200
|
+
* six weeks later, and now two lines claim the same name a hundred lines
|
|
201
|
+
* apart. Whichever one a reader scrolls to first looks authoritative.
|
|
202
|
+
*
|
|
203
|
+
* The FIRST occurrence keeps its position, so the standalone comment block
|
|
204
|
+
* documenting a key stays attached to it. Everything else moves up to join
|
|
205
|
+
* it, in the order it already had.
|
|
206
|
+
*/
|
|
207
|
+
group() {
|
|
208
|
+
const out = [];
|
|
209
|
+
const taken = /* @__PURE__ */ new Set();
|
|
210
|
+
let moved = false;
|
|
211
|
+
for (let i = 0; i < this.lines.length; i += 1) {
|
|
212
|
+
if (taken.has(i)) continue;
|
|
213
|
+
const line = this.lines[i];
|
|
214
|
+
out.push(line);
|
|
215
|
+
taken.add(i);
|
|
216
|
+
if (line.key === void 0) continue;
|
|
217
|
+
for (let j = i + 1; j < this.lines.length; j += 1) {
|
|
218
|
+
if (taken.has(j) || this.lines[j].key !== line.key) continue;
|
|
219
|
+
if (j !== i + 1 || taken.has(i + 1)) moved = true;
|
|
220
|
+
out.push(this.lines[j]);
|
|
221
|
+
taken.add(j);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
this.lines = out;
|
|
225
|
+
return moved;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Blanks every active value without losing one.
|
|
229
|
+
*
|
|
230
|
+
* Each becomes a commented line holding what it was, plus an empty active
|
|
231
|
+
* line under it. The file still declares every key it needs, which is what
|
|
232
|
+
* makes it a usable checklist, while holding nothing.
|
|
233
|
+
*
|
|
234
|
+
* Already-empty keys are skipped. Commenting out `FOO=""` to write `FOO=""`
|
|
235
|
+
* underneath is churn that makes the next diff harder to read.
|
|
236
|
+
*/
|
|
237
|
+
reset() {
|
|
238
|
+
const out = [];
|
|
239
|
+
const cleared = [];
|
|
240
|
+
for (const line of this.lines) {
|
|
241
|
+
if (line.key === void 0 || line.commented || line.value === "") {
|
|
242
|
+
out.push(line);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const exported = ACTIVE.exec(line.raw)?.[2] ?? "";
|
|
246
|
+
line.raw = `# ${line.raw.trimStart()}`;
|
|
247
|
+
line.commented = true;
|
|
248
|
+
out.push(line);
|
|
249
|
+
out.push({
|
|
250
|
+
raw: `${exported}${line.key}=""`,
|
|
251
|
+
key: line.key,
|
|
252
|
+
value: ""
|
|
253
|
+
});
|
|
254
|
+
cleared.push(line.key);
|
|
255
|
+
}
|
|
256
|
+
this.lines = out;
|
|
257
|
+
return cleared;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Comments a key out rather than deleting it.
|
|
261
|
+
*
|
|
262
|
+
* A no-op when it is already commented or absent, so running a sync twice
|
|
263
|
+
* cannot produce `## KEY=`.
|
|
264
|
+
*/
|
|
265
|
+
comment(key) {
|
|
266
|
+
const i = this.find(key, false);
|
|
267
|
+
if (i === -1) return false;
|
|
268
|
+
const line = this.lines[i];
|
|
269
|
+
line.raw = `# ${line.raw.trimStart()}`;
|
|
270
|
+
line.commented = true;
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
/** Restores a commented assignment. Returns its value, or undefined. */
|
|
274
|
+
uncomment(key) {
|
|
275
|
+
const i = this.find(key, true);
|
|
276
|
+
if (i === -1) return void 0;
|
|
277
|
+
const line = this.lines[i];
|
|
278
|
+
line.raw = line.raw.replace(/^(\s*)#\s?/, "$1");
|
|
279
|
+
line.commented = false;
|
|
280
|
+
return line.value;
|
|
281
|
+
}
|
|
282
|
+
toString() {
|
|
283
|
+
return this.lines.map((l) => l.raw).join("\n");
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
//#endregion
|
|
287
|
+
export { EnvDocument as t };
|