@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,170 @@
|
|
|
1
|
+
//#region src/export/queries.ts
|
|
2
|
+
/**
|
|
3
|
+
* The three exports, moved from the platform's `server/export/` with their
|
|
4
|
+
* columns unchanged: anything downstream that read `/export/stars` reads
|
|
5
|
+
* `backstage export stars` the same way.
|
|
6
|
+
*
|
|
7
|
+
* Each query selects exactly its columns, aliased to the header names, in
|
|
8
|
+
* header order. Parameters: `$1` from (inclusive), `$2` to (exclusive), `$3`
|
|
9
|
+
* a meeting id; each null when not given.
|
|
10
|
+
*/
|
|
11
|
+
const EXPORT_KINDS = [
|
|
12
|
+
"stars",
|
|
13
|
+
"attendance",
|
|
14
|
+
"reflections"
|
|
15
|
+
];
|
|
16
|
+
/** The member columns every export leads with. */
|
|
17
|
+
const MEMBER = (userId) => `
|
|
18
|
+
${userId} as "user_id",
|
|
19
|
+
p."preferredName" as "preferred_name",
|
|
20
|
+
u."email" as "email",
|
|
21
|
+
(
|
|
22
|
+
select i."identity_data" ->> 'user_name'
|
|
23
|
+
from "auth"."identities" i
|
|
24
|
+
where i."user_id" = ${userId} and i."provider" = 'github'
|
|
25
|
+
limit 1
|
|
26
|
+
) as "github_login"`;
|
|
27
|
+
const MEMBER_COLUMNS = [
|
|
28
|
+
"user_id",
|
|
29
|
+
"preferred_name",
|
|
30
|
+
"email",
|
|
31
|
+
"github_login"
|
|
32
|
+
];
|
|
33
|
+
const MEMBER_JOINS = (userId) => `
|
|
34
|
+
left join "platform"."profile" p on p."userId" = ${userId}
|
|
35
|
+
left join "auth"."users" u on u."id" = ${userId}`;
|
|
36
|
+
const EXPORTS = {
|
|
37
|
+
/**
|
|
38
|
+
* One row per earned star: a meeting attended or a competition entered.
|
|
39
|
+
* Attendance is a meeting fact and participation a competition fact;
|
|
40
|
+
* neither is attributed to a workshop.
|
|
41
|
+
*/
|
|
42
|
+
stars: {
|
|
43
|
+
summary: "One row per earned meeting or competition star.",
|
|
44
|
+
columns: [
|
|
45
|
+
...MEMBER_COLUMNS,
|
|
46
|
+
"activity_type",
|
|
47
|
+
"activity_id",
|
|
48
|
+
"activity_starts_at",
|
|
49
|
+
"earned_at",
|
|
50
|
+
"meeting_id",
|
|
51
|
+
"competition_id",
|
|
52
|
+
"meeting_star",
|
|
53
|
+
"competition_star",
|
|
54
|
+
"won"
|
|
55
|
+
],
|
|
56
|
+
sql: `
|
|
57
|
+
select ${MEMBER("ms.\"userId\"")},
|
|
58
|
+
ms."activityType" as "activity_type",
|
|
59
|
+
ms."activityId" as "activity_id",
|
|
60
|
+
ms."startsAt" as "activity_starts_at",
|
|
61
|
+
ms."earnedAt" as "earned_at",
|
|
62
|
+
ms."meetingId" as "meeting_id",
|
|
63
|
+
ms."competitionId" as "competition_id",
|
|
64
|
+
ms."activityType" = 'meeting' as "meeting_star",
|
|
65
|
+
ms."activityType" = 'competition' as "competition_star",
|
|
66
|
+
ms."won" as "won"
|
|
67
|
+
from "platform"."memberStars" ms
|
|
68
|
+
${MEMBER_JOINS("ms.\"userId\"")}
|
|
69
|
+
where ($1::timestamptz is null or ms."startsAt" >= $1::timestamptz)
|
|
70
|
+
and ($2::timestamptz is null or ms."startsAt" < $2::timestamptz)
|
|
71
|
+
and $3::uuid is null
|
|
72
|
+
order by ms."startsAt", ms."userId", ms."activityType", ms."activityId"`
|
|
73
|
+
},
|
|
74
|
+
/**
|
|
75
|
+
* One row per check-in, the raw ledger under the stars: an officer
|
|
76
|
+
* reconciling a night sees every scan (and every imported row, method
|
|
77
|
+
* `import`) rather than a derived boolean.
|
|
78
|
+
*/
|
|
79
|
+
attendance: {
|
|
80
|
+
summary: "One row per check-in: who, which meeting, when, and how.",
|
|
81
|
+
columns: [
|
|
82
|
+
...MEMBER_COLUMNS,
|
|
83
|
+
"meeting_config_id",
|
|
84
|
+
"meeting_title",
|
|
85
|
+
"meeting_starts_at",
|
|
86
|
+
"checked_in_at",
|
|
87
|
+
"check_in_method",
|
|
88
|
+
"counts_for_credit"
|
|
89
|
+
],
|
|
90
|
+
sql: `
|
|
91
|
+
select ${MEMBER("a.\"userId\"")},
|
|
92
|
+
m."configId" as "meeting_config_id",
|
|
93
|
+
coalesce(m."nameOverride", m."kind", 'Meeting') as "meeting_title",
|
|
94
|
+
m."startsAt" as "meeting_starts_at",
|
|
95
|
+
a."recordedAt" as "checked_in_at",
|
|
96
|
+
a."method"::text as "check_in_method",
|
|
97
|
+
m."countsForCredit" as "counts_for_credit"
|
|
98
|
+
from "platform"."attendance" a
|
|
99
|
+
join "platform"."meetings" m on m."id" = a."meetingId"
|
|
100
|
+
${MEMBER_JOINS("a.\"userId\"")}
|
|
101
|
+
where ($1::timestamptz is null or m."startsAt" >= $1::timestamptz)
|
|
102
|
+
and ($2::timestamptz is null or m."startsAt" < $2::timestamptz)
|
|
103
|
+
and ($3::uuid is null or a."meetingId" = $3::uuid)
|
|
104
|
+
order by m."startsAt", a."userId", a."id"`
|
|
105
|
+
},
|
|
106
|
+
/**
|
|
107
|
+
* One row per reflection, carrying the member's current text. The revision
|
|
108
|
+
* history stays in `reflectionRevisions`; this is "what did people write",
|
|
109
|
+
* not "who edited what".
|
|
110
|
+
*/
|
|
111
|
+
reflections: {
|
|
112
|
+
summary: "One row per reflection, with the member's current text.",
|
|
113
|
+
columns: [
|
|
114
|
+
...MEMBER_COLUMNS,
|
|
115
|
+
"activity_type",
|
|
116
|
+
"activity_id",
|
|
117
|
+
"activity_title",
|
|
118
|
+
"content",
|
|
119
|
+
"submitted_at",
|
|
120
|
+
"created_at",
|
|
121
|
+
"updated_at",
|
|
122
|
+
"revision_count"
|
|
123
|
+
],
|
|
124
|
+
sql: `
|
|
125
|
+
select ${MEMBER("r.\"userId\"")},
|
|
126
|
+
case when r."meetingId" is not null then 'meeting' else 'competition' end as "activity_type",
|
|
127
|
+
coalesce(r."meetingId", r."competitionId") as "activity_id",
|
|
128
|
+
case
|
|
129
|
+
when r."meetingId" is not null then (
|
|
130
|
+
select coalesce(m."nameOverride", m."kind", 'Meeting')
|
|
131
|
+
from "platform"."meetings" m where m."id" = r."meetingId"
|
|
132
|
+
)
|
|
133
|
+
else (select c."title" from "platform"."competitions" c where c."id" = r."competitionId")
|
|
134
|
+
end as "activity_title",
|
|
135
|
+
r."content" as "content",
|
|
136
|
+
r."submittedAt" as "submitted_at",
|
|
137
|
+
r."createdAt" as "created_at",
|
|
138
|
+
r."updatedAt" as "updated_at",
|
|
139
|
+
(
|
|
140
|
+
select count(*)::int from "platform"."reflectionRevisions" rr
|
|
141
|
+
where rr."reflectionId" = r."id"
|
|
142
|
+
) as "revision_count"
|
|
143
|
+
from "platform"."reflections" r
|
|
144
|
+
${MEMBER_JOINS("r.\"userId\"")}
|
|
145
|
+
where ($1::timestamptz is null or r."createdAt" >= $1::timestamptz)
|
|
146
|
+
and ($2::timestamptz is null or r."createdAt" < $2::timestamptz)
|
|
147
|
+
and $3::uuid is null
|
|
148
|
+
order by r."createdAt", r."userId", r."id"`
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
function exportParams(filters) {
|
|
152
|
+
return [
|
|
153
|
+
filters.from?.toISOString() ?? null,
|
|
154
|
+
filters.to?.toISOString() ?? null,
|
|
155
|
+
filters.meetingId ?? null
|
|
156
|
+
];
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The filters as the audit row records them, in the platform's shape: an
|
|
160
|
+
* unfiltered export is an explicit `{}`, distinguishable from a slice.
|
|
161
|
+
*/
|
|
162
|
+
function auditFilters(filters) {
|
|
163
|
+
const out = {};
|
|
164
|
+
if (filters.from) out.from = filters.from.toISOString();
|
|
165
|
+
if (filters.to) out.to = filters.to.toISOString();
|
|
166
|
+
if (filters.meetingId) out.meetingId = filters.meetingId;
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
//#endregion
|
|
170
|
+
export { exportParams as i, EXPORT_KINDS as n, auditFilters as r, EXPORTS as t };
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { r as errorMessage } from "./ui-CdKo8mLw.js";
|
|
2
|
+
import { d as withConnection, s as ProductionError } from "./cli-Do2nWD7W.js";
|
|
3
|
+
//#region src/production/accounts.ts
|
|
4
|
+
/**
|
|
5
|
+
* Production accounts: reading them, finding one by email, and creating the
|
|
6
|
+
* ones a member list names but nobody has made yet.
|
|
7
|
+
*
|
|
8
|
+
* An email finds its account by the profile's `ugaEmail` first, then by the
|
|
9
|
+
* sign-in address. Officers often sign in with a personal address and have
|
|
10
|
+
* their MyID one recorded as `ugaEmail`; matching only the sign-in address
|
|
11
|
+
* would give them a second, empty account.
|
|
12
|
+
*
|
|
13
|
+
* Accounts are created through the Auth admin API, confirmed and with no
|
|
14
|
+
* invite email, so the member's first sign-in with that address lands on an
|
|
15
|
+
* account that already has their history. Nothing makes a profile when an
|
|
16
|
+
* account is created; each caller inserts its own, with what it knows.
|
|
17
|
+
*/
|
|
18
|
+
const ACCOUNTS_QUERY = `
|
|
19
|
+
select
|
|
20
|
+
u."id" as "userId",
|
|
21
|
+
lower(u."email") as "authEmail",
|
|
22
|
+
lower(p."ugaEmail") as "ugaEmail",
|
|
23
|
+
p."userId" is not null as "hasProfile",
|
|
24
|
+
p."preferredName" as "preferredName",
|
|
25
|
+
p."involvementFirstName" as "involvementFirstName",
|
|
26
|
+
p."involvementLastName" as "involvementLastName"
|
|
27
|
+
from "auth"."users" u
|
|
28
|
+
left join "platform"."profile" p on p."userId" = u."id"
|
|
29
|
+
order by u."created_at"
|
|
30
|
+
`;
|
|
31
|
+
/** {@link ACCOUNTS_QUERY} on an open connection. */
|
|
32
|
+
async function accountsWith(sql) {
|
|
33
|
+
return (await sql.unsafe(ACCOUNTS_QUERY)).map((row) => ({
|
|
34
|
+
userId: String(row.userId),
|
|
35
|
+
authEmail: row.authEmail ?? null,
|
|
36
|
+
ugaEmail: row.ugaEmail ?? null,
|
|
37
|
+
hasProfile: row.hasProfile === true,
|
|
38
|
+
preferredName: row.preferredName ?? null,
|
|
39
|
+
involvementFirstName: row.involvementFirstName ?? null,
|
|
40
|
+
involvementLastName: row.involvementLastName ?? null
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
async function readAccounts(url) {
|
|
44
|
+
return withConnection(url, "Could not read production's accounts", accountsWith);
|
|
45
|
+
}
|
|
46
|
+
/** Finds an account by a lowercased email: `ugaEmail` first, then sign-in. */
|
|
47
|
+
function accountFinder(accounts) {
|
|
48
|
+
const byUga = /* @__PURE__ */ new Map();
|
|
49
|
+
const byAuth = /* @__PURE__ */ new Map();
|
|
50
|
+
for (const a of accounts) {
|
|
51
|
+
if (a.ugaEmail) byUga.set(a.ugaEmail, [...byUga.get(a.ugaEmail) ?? [], a]);
|
|
52
|
+
if (a.authEmail && !byAuth.has(a.authEmail)) byAuth.set(a.authEmail, a);
|
|
53
|
+
}
|
|
54
|
+
return (email) => {
|
|
55
|
+
const viaUga = byUga.get(email);
|
|
56
|
+
return viaUga?.find((a) => a.authEmail === email) ?? viaUga?.[0] ?? byAuth.get(email);
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function createAccountFor(apiUrl, secretKey) {
|
|
60
|
+
let client = null;
|
|
61
|
+
return async (email) => {
|
|
62
|
+
client ??= import("@supabase/supabase-js").then(({ createClient }) => createClient(apiUrl, secretKey, { auth: {
|
|
63
|
+
persistSession: false,
|
|
64
|
+
autoRefreshToken: false,
|
|
65
|
+
detectSessionInUrl: false
|
|
66
|
+
} }));
|
|
67
|
+
const { data, error } = await (await client).auth.admin.createUser({
|
|
68
|
+
email,
|
|
69
|
+
email_confirm: true
|
|
70
|
+
});
|
|
71
|
+
if (error || !data.user) throw new ProductionError(error?.message ?? "no user returned");
|
|
72
|
+
return data.user.id;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** How many accounts are created at once. */
|
|
76
|
+
const CREATE_CONCURRENCY = 4;
|
|
77
|
+
/**
|
|
78
|
+
* Creates an account for each email, a few at a time. A failure is recorded
|
|
79
|
+
* and the rest carry on: one bad address must not cost everyone else.
|
|
80
|
+
*/
|
|
81
|
+
async function createAccounts(emails, create) {
|
|
82
|
+
const created = /* @__PURE__ */ new Map();
|
|
83
|
+
const failed = [];
|
|
84
|
+
const queue = [...emails];
|
|
85
|
+
const worker = async () => {
|
|
86
|
+
for (let email = queue.shift(); email; email = queue.shift()) try {
|
|
87
|
+
created.set(email, await create(email));
|
|
88
|
+
} catch (err) {
|
|
89
|
+
failed.push({
|
|
90
|
+
email,
|
|
91
|
+
reason: errorMessage(err)
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
await Promise.all(Array.from({ length: CREATE_CONCURRENCY }, () => worker()));
|
|
96
|
+
return {
|
|
97
|
+
created,
|
|
98
|
+
failed
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
//#endregion
|
|
102
|
+
//#region src/csv/read.ts
|
|
103
|
+
/**
|
|
104
|
+
* Reading CSV the way spreadsheets write it: RFC 4180 quoting, CRLF or LF, and
|
|
105
|
+
* the byte-order mark Excel puts in front of UTF-8.
|
|
106
|
+
*/
|
|
107
|
+
/** RFC 4180 rows: quoted fields may hold commas, quotes (`""`) and newlines. */
|
|
108
|
+
function parseCsv(text) {
|
|
109
|
+
const rows = [];
|
|
110
|
+
let row = [];
|
|
111
|
+
let field = "";
|
|
112
|
+
let quoted = false;
|
|
113
|
+
const src = text.replace(/^/, "");
|
|
114
|
+
for (let i = 0; i < src.length; i += 1) {
|
|
115
|
+
const ch = src[i];
|
|
116
|
+
if (quoted) {
|
|
117
|
+
if (ch === "\"" && src[i + 1] === "\"") {
|
|
118
|
+
field += "\"";
|
|
119
|
+
i += 1;
|
|
120
|
+
} else if (ch === "\"") quoted = false;
|
|
121
|
+
else field += ch;
|
|
122
|
+
} else if (ch === "\"") quoted = true;
|
|
123
|
+
else if (ch === ",") {
|
|
124
|
+
row.push(field);
|
|
125
|
+
field = "";
|
|
126
|
+
} else if (ch === "\n" || ch === "\r") {
|
|
127
|
+
if (ch === "\r" && src[i + 1] === "\n") i += 1;
|
|
128
|
+
row.push(field);
|
|
129
|
+
rows.push(row);
|
|
130
|
+
row = [];
|
|
131
|
+
field = "";
|
|
132
|
+
} else field += ch;
|
|
133
|
+
}
|
|
134
|
+
if (field !== "" || row.length > 0) {
|
|
135
|
+
row.push(field);
|
|
136
|
+
rows.push(row);
|
|
137
|
+
}
|
|
138
|
+
return rows;
|
|
139
|
+
}
|
|
140
|
+
//#endregion
|
|
141
|
+
export { createAccounts as a, createAccountFor as i, accountFinder as n, readAccounts as o, accountsWith as r, parseCsv as t };
|
|
@@ -4,292 +4,6 @@ import { dirname, join } from "node:path";
|
|
|
4
4
|
import { confirm, log, spinner } from "@clack/prompts";
|
|
5
5
|
import { execFile, spawn } from "node:child_process";
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
|
-
//#region ../cli-core/src/env/document.ts
|
|
8
|
-
/**
|
|
9
|
-
* An editable `.env` that survives being edited.
|
|
10
|
-
*
|
|
11
|
-
* The root `.env` is not a data file. It is 150 lines of hard-won commentary
|
|
12
|
-
* about which value breaks the Supabase CLI when empty, which one must stay
|
|
13
|
-
* commented out, and why. A writer that parses to a Map and serializes back
|
|
14
|
-
* destroys all of it on the first save.
|
|
15
|
-
*
|
|
16
|
-
* So this keeps the file as **lines** and edits in place. An untouched line is
|
|
17
|
-
* returned byte-for-byte, including its spacing, quoting style and trailing
|
|
18
|
-
* comment. Only the lines actually being changed are rewritten.
|
|
19
|
-
*
|
|
20
|
-
* Nothing is ever deleted. Removing a key comments it out, so the value stays
|
|
21
|
-
* recoverable from the file itself, and re-adding it later uncomments that line
|
|
22
|
-
* rather than appending a duplicate.
|
|
23
|
-
*/
|
|
24
|
-
/**
|
|
25
|
-
* `KEY=value`, active. Groups: indent, `export ` prefix, key, value.
|
|
26
|
-
*
|
|
27
|
-
* The prefix is CAPTURED rather than skipped so an update can put it back.
|
|
28
|
-
* Dropping it turns `export FOO=` into `FOO=`, which still parses here and
|
|
29
|
-
* stops being exported to child processes. That shows up as a missing variable
|
|
30
|
-
* three tools downstream.
|
|
31
|
-
*/
|
|
32
|
-
const ACTIVE = /^(\s*)((?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/;
|
|
33
|
-
/** `# KEY=value`, the commented form this tool writes and reads back. */
|
|
34
|
-
const COMMENTED = /^(\s*)#\s?((?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/;
|
|
35
|
-
/**
|
|
36
|
-
* Reads the value half of an assignment.
|
|
37
|
-
*
|
|
38
|
-
* Deliberately NOT a general dotenv parser: it does not expand `$VAR`, because
|
|
39
|
-
* a stored value that means one thing in the file and another in the process
|
|
40
|
-
* cannot be rotated with confidence.
|
|
41
|
-
*/
|
|
42
|
-
function parseValue(rest) {
|
|
43
|
-
const text = rest.trim();
|
|
44
|
-
if (text.startsWith("\"")) {
|
|
45
|
-
const end = findClosing(text, "\"");
|
|
46
|
-
if (end === -1) return unescape(text.slice(1));
|
|
47
|
-
return unescape(text.slice(1, end));
|
|
48
|
-
}
|
|
49
|
-
if (text.startsWith("'")) {
|
|
50
|
-
const end = findClosing(text, "'");
|
|
51
|
-
if (end === -1) return text.slice(1);
|
|
52
|
-
return text.slice(1, end);
|
|
53
|
-
}
|
|
54
|
-
const hash = text.search(/\s#/);
|
|
55
|
-
return (hash === -1 ? text : text.slice(0, hash)).trim();
|
|
56
|
-
}
|
|
57
|
-
function findClosing(text, quote) {
|
|
58
|
-
for (let i = 1; i < text.length; i += 1) {
|
|
59
|
-
if (text[i] === "\\") {
|
|
60
|
-
i += 1;
|
|
61
|
-
continue;
|
|
62
|
-
}
|
|
63
|
-
if (text[i] === quote) return i;
|
|
64
|
-
}
|
|
65
|
-
return -1;
|
|
66
|
-
}
|
|
67
|
-
function unescape(text) {
|
|
68
|
-
return text.replace(/\\([nrt\\"'])/g, (_, c) => c === "n" ? "\n" : c === "r" ? "\r" : c === "t" ? " " : c);
|
|
69
|
-
}
|
|
70
|
-
/**
|
|
71
|
-
* Splits everything after `=` into its value and its trailing comment.
|
|
72
|
-
*
|
|
73
|
-
* Quote-aware, because the naive "cut at the first `#`" truncates a generated
|
|
74
|
-
* password into something that still looks like one. A `#` inside quotes is
|
|
75
|
-
* part of the value; outside them it starts a comment only when whitespace
|
|
76
|
-
* precedes it.
|
|
77
|
-
*/
|
|
78
|
-
function splitComment(rest) {
|
|
79
|
-
const text = rest.trimStart();
|
|
80
|
-
let after;
|
|
81
|
-
if (text.startsWith("\"") || text.startsWith("'")) {
|
|
82
|
-
const end = findClosing(text, text[0]);
|
|
83
|
-
after = end === -1 ? text.length : end + 1;
|
|
84
|
-
} else {
|
|
85
|
-
const hash = text.search(/\s#/);
|
|
86
|
-
after = hash === -1 ? text.length : hash;
|
|
87
|
-
}
|
|
88
|
-
const tail = text.slice(after);
|
|
89
|
-
const hash = tail.indexOf("#");
|
|
90
|
-
return { comment: hash === -1 ? "" : tail.slice(hash).trimEnd() };
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* Recognises a stamp this tool wrote, so it is replaced rather than duplicated.
|
|
94
|
-
*
|
|
95
|
-
* Deliberately narrow: it matches the exact bracketed shape below and nothing
|
|
96
|
-
* else, because the cost of a false positive is deleting somebody's own note.
|
|
97
|
-
*/
|
|
98
|
-
const STAMP = /\s*#\s*\[[a-z][a-z0-9-]* (?:pushed|pulled) \d{4}-\d{2}-\d{2}\]\s*$/;
|
|
99
|
-
function stampText(stamp) {
|
|
100
|
-
return `# [${stamp.environment} ${stamp.action} ${stamp.date}]`;
|
|
101
|
-
}
|
|
102
|
-
function isStamp(comment) {
|
|
103
|
-
return STAMP.test(comment);
|
|
104
|
-
}
|
|
105
|
-
/** Always double-quoted, so a multi-line value round-trips through one line. */
|
|
106
|
-
function quote(value) {
|
|
107
|
-
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")}"`;
|
|
108
|
-
}
|
|
109
|
-
var EnvDocument = class EnvDocument {
|
|
110
|
-
lines;
|
|
111
|
-
/** Whether this session has already appended, so it separates only once. */
|
|
112
|
-
appended = false;
|
|
113
|
-
constructor(lines) {
|
|
114
|
-
this.lines = lines;
|
|
115
|
-
}
|
|
116
|
-
static parse(text) {
|
|
117
|
-
const lines = text.split("\n").map((raw) => {
|
|
118
|
-
const active = ACTIVE.exec(raw);
|
|
119
|
-
if (active) return {
|
|
120
|
-
raw,
|
|
121
|
-
key: active[3],
|
|
122
|
-
value: parseValue(active[4])
|
|
123
|
-
};
|
|
124
|
-
const commented = COMMENTED.exec(raw);
|
|
125
|
-
if (commented) return {
|
|
126
|
-
raw,
|
|
127
|
-
key: commented[3],
|
|
128
|
-
value: parseValue(commented[4]),
|
|
129
|
-
commented: true
|
|
130
|
-
};
|
|
131
|
-
return { raw };
|
|
132
|
-
});
|
|
133
|
-
return new EnvDocument(lines);
|
|
134
|
-
}
|
|
135
|
-
static empty() {
|
|
136
|
-
return new EnvDocument([]);
|
|
137
|
-
}
|
|
138
|
-
find(key, commented) {
|
|
139
|
-
return this.lines.findIndex((l) => l.key === key && Boolean(l.commented) === commented);
|
|
140
|
-
}
|
|
141
|
-
/** The active value, or undefined when absent or commented out. */
|
|
142
|
-
get(key) {
|
|
143
|
-
const i = this.find(key, false);
|
|
144
|
-
return i === -1 ? void 0 : this.lines[i].value;
|
|
145
|
-
}
|
|
146
|
-
/** Present and active. */
|
|
147
|
-
has(key) {
|
|
148
|
-
return this.find(key, false) !== -1;
|
|
149
|
-
}
|
|
150
|
-
/** Present, but commented out. */
|
|
151
|
-
isCommented(key) {
|
|
152
|
-
return this.find(key, false) === -1 && this.find(key, true) !== -1;
|
|
153
|
-
}
|
|
154
|
-
/** Every active assignment, in file order. */
|
|
155
|
-
entries() {
|
|
156
|
-
return this.lines.filter((l) => l.key !== void 0 && !l.commented).map((l) => [l.key, l.value]);
|
|
157
|
-
}
|
|
158
|
-
keys() {
|
|
159
|
-
return this.entries().map(([k]) => k);
|
|
160
|
-
}
|
|
161
|
-
/**
|
|
162
|
-
* Sets a value, preferring to revive a commented line over appending.
|
|
163
|
-
*
|
|
164
|
-
* Appending when a commented form already exists is how a file ends up with
|
|
165
|
-
* the same key twice: one stale, both plausible, and which one looks
|
|
166
|
-
* authoritative depends on where the reader scrolled to.
|
|
167
|
-
*/
|
|
168
|
-
set(key, value, stamp) {
|
|
169
|
-
const active = this.find(key, false);
|
|
170
|
-
if (active !== -1) {
|
|
171
|
-
const line = this.lines[active];
|
|
172
|
-
const match = ACTIVE.exec(line.raw);
|
|
173
|
-
const existing = splitComment(match[4]).comment;
|
|
174
|
-
if (stamp && existing !== "" && !isStamp(existing)) this.lines.splice(this.first(key), 0, { raw: existing });
|
|
175
|
-
const trailing = stamp ? ` ${stampText(stamp)}` : existing === "" ? "" : ` ${existing}`;
|
|
176
|
-
const at = this.find(key, false);
|
|
177
|
-
const target = this.lines[at];
|
|
178
|
-
target.raw = `${match[1]}${match[2]}${key}=${quote(value)}${trailing}`;
|
|
179
|
-
target.value = value;
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
const commented = this.find(key, true);
|
|
183
|
-
if (commented !== -1) {
|
|
184
|
-
const line = this.lines[commented];
|
|
185
|
-
line.raw = `${key}=${quote(value)}${stamp ? ` ${stampText(stamp)}` : ""}`;
|
|
186
|
-
line.value = value;
|
|
187
|
-
line.commented = false;
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
if (!this.appended && this.lines.length > 0 && this.lines.at(-1).raw !== "") this.lines.push({ raw: "" });
|
|
191
|
-
this.appended = true;
|
|
192
|
-
this.lines.push({
|
|
193
|
-
raw: `${key}=${quote(value)}${stamp ? ` ${stampText(stamp)}` : ""}`,
|
|
194
|
-
key,
|
|
195
|
-
value
|
|
196
|
-
});
|
|
197
|
-
}
|
|
198
|
-
/** Index of the first line mentioning a key, active or commented. */
|
|
199
|
-
first(key) {
|
|
200
|
-
return this.lines.findIndex((l) => l.key === key);
|
|
201
|
-
}
|
|
202
|
-
/**
|
|
203
|
-
* Moves every line for a key next to that key's first appearance.
|
|
204
|
-
*
|
|
205
|
-
* Files drift: a key gets commented out near the bottom, re-added at the top
|
|
206
|
-
* six weeks later, and now two lines claim the same name a hundred lines
|
|
207
|
-
* apart. Whichever one a reader scrolls to first looks authoritative.
|
|
208
|
-
*
|
|
209
|
-
* The FIRST occurrence keeps its position, so the standalone comment block
|
|
210
|
-
* documenting a key stays attached to it. Everything else moves up to join
|
|
211
|
-
* it, in the order it already had.
|
|
212
|
-
*/
|
|
213
|
-
group() {
|
|
214
|
-
const out = [];
|
|
215
|
-
const taken = /* @__PURE__ */ new Set();
|
|
216
|
-
let moved = false;
|
|
217
|
-
for (let i = 0; i < this.lines.length; i += 1) {
|
|
218
|
-
if (taken.has(i)) continue;
|
|
219
|
-
const line = this.lines[i];
|
|
220
|
-
out.push(line);
|
|
221
|
-
taken.add(i);
|
|
222
|
-
if (line.key === void 0) continue;
|
|
223
|
-
for (let j = i + 1; j < this.lines.length; j += 1) {
|
|
224
|
-
if (taken.has(j) || this.lines[j].key !== line.key) continue;
|
|
225
|
-
if (j !== i + 1 || taken.has(i + 1)) moved = true;
|
|
226
|
-
out.push(this.lines[j]);
|
|
227
|
-
taken.add(j);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
this.lines = out;
|
|
231
|
-
return moved;
|
|
232
|
-
}
|
|
233
|
-
/**
|
|
234
|
-
* Blanks every active value without losing one.
|
|
235
|
-
*
|
|
236
|
-
* Each becomes a commented line holding what it was, plus an empty active
|
|
237
|
-
* line under it. The file still declares every key it needs, which is what
|
|
238
|
-
* makes it a usable checklist, while holding nothing.
|
|
239
|
-
*
|
|
240
|
-
* Already-empty keys are skipped. Commenting out `FOO=""` to write `FOO=""`
|
|
241
|
-
* underneath is churn that makes the next diff harder to read.
|
|
242
|
-
*/
|
|
243
|
-
reset() {
|
|
244
|
-
const out = [];
|
|
245
|
-
const cleared = [];
|
|
246
|
-
for (const line of this.lines) {
|
|
247
|
-
if (line.key === void 0 || line.commented || line.value === "") {
|
|
248
|
-
out.push(line);
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
const exported = ACTIVE.exec(line.raw)?.[2] ?? "";
|
|
252
|
-
line.raw = `# ${line.raw.trimStart()}`;
|
|
253
|
-
line.commented = true;
|
|
254
|
-
out.push(line);
|
|
255
|
-
out.push({
|
|
256
|
-
raw: `${exported}${line.key}=""`,
|
|
257
|
-
key: line.key,
|
|
258
|
-
value: ""
|
|
259
|
-
});
|
|
260
|
-
cleared.push(line.key);
|
|
261
|
-
}
|
|
262
|
-
this.lines = out;
|
|
263
|
-
return cleared;
|
|
264
|
-
}
|
|
265
|
-
/**
|
|
266
|
-
* Comments a key out rather than deleting it.
|
|
267
|
-
*
|
|
268
|
-
* A no-op when it is already commented or absent, so running a sync twice
|
|
269
|
-
* cannot produce `## KEY=`.
|
|
270
|
-
*/
|
|
271
|
-
comment(key) {
|
|
272
|
-
const i = this.find(key, false);
|
|
273
|
-
if (i === -1) return false;
|
|
274
|
-
const line = this.lines[i];
|
|
275
|
-
line.raw = `# ${line.raw.trimStart()}`;
|
|
276
|
-
line.commented = true;
|
|
277
|
-
return true;
|
|
278
|
-
}
|
|
279
|
-
/** Restores a commented assignment. Returns its value, or undefined. */
|
|
280
|
-
uncomment(key) {
|
|
281
|
-
const i = this.find(key, true);
|
|
282
|
-
if (i === -1) return void 0;
|
|
283
|
-
const line = this.lines[i];
|
|
284
|
-
line.raw = line.raw.replace(/^(\s*)#\s?/, "$1");
|
|
285
|
-
line.commented = false;
|
|
286
|
-
return line.value;
|
|
287
|
-
}
|
|
288
|
-
toString() {
|
|
289
|
-
return this.lines.map((l) => l.raw).join("\n");
|
|
290
|
-
}
|
|
291
|
-
};
|
|
292
|
-
//#endregion
|
|
293
7
|
//#region src/bws/bw.ts
|
|
294
8
|
/**
|
|
295
9
|
* Where the bundled Bitwarden CLI (`bw`) is, for the vault code that signs in
|
|
@@ -560,4 +274,4 @@ function explainVault(status, envVar = "BWS_ACCESS_TOKEN") {
|
|
|
560
274
|
if (status === "unauthenticated") return `You are not signed in to Bitwarden, and with no terminal there is nobody to sign in. Set ${envVar} instead.`;
|
|
561
275
|
}
|
|
562
276
|
//#endregion
|
|
563
|
-
export { readTokenFromVault as a,
|
|
277
|
+
export { readTokenFromVault as a, readPasswordFromVault as i, bwArgs as n, saveTokenToVault as o, openVault as r, bwCommand as s, VAULT_ITEM_NAME as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devdogsuga/backstage",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Officer and production CLI for DevDogsUGA: deploys, env sync with Bitwarden and GitHub, the migration planner role, club graphics and QR codes, GitHub rulesets and settings, and the newsletter. Ships built JS; run it anywhere with `pnpm dlx @devdogsuga/backstage` (no checkout needed to start; commands that read a checkout say so).",
|
|
@@ -40,10 +40,10 @@
|
|
|
40
40
|
"tsx": "^4.23.12",
|
|
41
41
|
"typescript": "^6.0.3",
|
|
42
42
|
"zod": "4.4.3",
|
|
43
|
-
"@devdogsuga/telemetry": "0.1.3",
|
|
44
43
|
"@devdogsuga/brand": "0.1.5",
|
|
45
|
-
"@devdogsuga/
|
|
46
|
-
"@devdogsuga/
|
|
44
|
+
"@devdogsuga/newsletter": "0.1.10",
|
|
45
|
+
"@devdogsuga/telemetry": "0.1.3",
|
|
46
|
+
"@devdogsuga/events": "0.1.5"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"@devdogsuga/db": "*",
|
|
@@ -64,10 +64,10 @@
|
|
|
64
64
|
"eslint": "^9.39.5",
|
|
65
65
|
"tsdown": "^0.23.0",
|
|
66
66
|
"vitest": "^4.1.11",
|
|
67
|
-
"@devdogsuga/cli-core": "0.0.0",
|
|
68
|
-
"@devdogsuga/db": "0.1.3",
|
|
69
67
|
"@devdogsuga/config": "0.1.2",
|
|
70
|
-
"@devdogsuga/
|
|
68
|
+
"@devdogsuga/db": "0.1.3",
|
|
69
|
+
"@devdogsuga/env": "0.1.5",
|
|
70
|
+
"@devdogsuga/cli-core": "0.0.0"
|
|
71
71
|
},
|
|
72
72
|
"scripts": {
|
|
73
73
|
"build": "tsdown && node scripts/write-build-info.mjs && node ../../scripts/check-bundle-imports.mjs",
|
package/dist/client-DzXQHd58.js
DELETED