@devdogsuga/devtools 0.1.0 → 0.1.2
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/dist/cli.js +50 -23
- package/dist/commands.js +63 -23
- package/dist/db/new-migration.d.ts +8 -1
- package/dist/db/new-migration.js +60 -2
- package/dist/db/pooler.d.ts +23 -0
- package/dist/db/pooler.js +55 -0
- package/dist/environment-doctor.d.ts +36 -0
- package/dist/environment-doctor.js +395 -0
- package/dist/oauth/wizard.d.ts +8 -1
- package/dist/oauth/wizard.js +20 -4
- package/dist/setup.js +178 -50
- package/package.json +6 -6
- package/dist/newsletter/commands.d.ts +0 -16
- package/dist/newsletter/commands.js +0 -311
- package/dist/newsletter/imap.d.ts +0 -17
- package/dist/newsletter/imap.js +0 -127
- package/dist/newsletter/loopback.d.ts +0 -24
- package/dist/newsletter/loopback.js +0 -100
- package/dist/newsletter/oauth.d.ts +0 -26
- package/dist/newsletter/oauth.js +0 -130
- package/dist/newsletter/smtp.d.ts +0 -23
- package/dist/newsletter/smtp.js +0 -133
- package/dist/tsconfig.typecheck.tsbuildinfo +0 -1
- /package/dist/{doctor.d.ts → moderation.d.ts} +0 -0
- /package/dist/{doctor.js → moderation.js} +0 -0
package/dist/setup.js
CHANGED
|
@@ -1,30 +1,53 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* One-command onboarding, the first thing a new contributor runs.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Hosted Supabase (a student's own free-tier project) is the DEFAULT path:
|
|
5
|
+
* pick app(s) -> hosted or local -> prerequisite checks -> for hosted, a
|
|
6
|
+
* short wizard collects the project's URL and keys, validates the DB_URL,
|
|
7
|
+
* writes `.env`, runs the migrations, and chains straight into `devtools
|
|
8
|
+
* oauth` against that same project. Local keeps today's `db start` flow
|
|
9
|
+
* unchanged.
|
|
8
10
|
*
|
|
9
11
|
* Was `scripts/setup.ts` at the repo root, resolving paths from
|
|
10
12
|
* `process.cwd()`, so it only worked when invoked from the root. Same class of
|
|
11
13
|
* bug that made the Supabase CLI look for `supabase_db_platform`. It resolves
|
|
12
14
|
* from `findRepoRoot()` now and works from anywhere.
|
|
13
15
|
*/
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { execFileSync } from "node:child_process";
|
|
16
18
|
import { join } from "node:path";
|
|
17
|
-
import { log, note } from "@clack/prompts";
|
|
19
|
+
import { confirm, log, note, password, select, spinner, text } from "@clack/prompts";
|
|
18
20
|
import { loadRegistry } from "./env/discovery.js";
|
|
19
21
|
import { renderInit, resolveSections } from "./env/example.js";
|
|
20
22
|
import { discoverRepoRoot } from "./repo/root.js";
|
|
21
|
-
|
|
23
|
+
import { EnvDocument } from "./env/document.js";
|
|
24
|
+
import { dbPush } from "./db/run.js";
|
|
25
|
+
import { validateSessionPoolerUrl } from "./db/pooler.js";
|
|
26
|
+
import { runOAuthSetup } from "./oauth/wizard.js";
|
|
27
|
+
import { runEnvironmentDoctor } from "./environment-doctor.js";
|
|
28
|
+
import { unwrap } from "./ui.js";
|
|
29
|
+
function has(cmd, args = ["--version"]) {
|
|
22
30
|
try {
|
|
23
|
-
|
|
24
|
-
|
|
31
|
+
return execFileSync(cmd, args, {
|
|
32
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
33
|
+
encoding: "utf8",
|
|
34
|
+
}).trim();
|
|
25
35
|
}
|
|
26
36
|
catch {
|
|
27
|
-
return
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Reads `packageManager` out of a checkout's root `package.json`, when one
|
|
41
|
+
* is reachable — for the "does the installed pnpm match the pin" line. */
|
|
42
|
+
function readPnpmPin(repoRoot) {
|
|
43
|
+
if (!repoRoot)
|
|
44
|
+
return null;
|
|
45
|
+
try {
|
|
46
|
+
const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"));
|
|
47
|
+
return pkg.packageManager?.replace(/^pnpm@/, "").split("+")[0] ?? null;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
28
51
|
}
|
|
29
52
|
}
|
|
30
53
|
export async function runSetup() {
|
|
@@ -42,13 +65,22 @@ export async function runSetup() {
|
|
|
42
65
|
checks.push(major > 22 || (major === 22 && minor >= 12)
|
|
43
66
|
? `OK Node ${node}`
|
|
44
67
|
: `WARN Node ${node} — this repo needs >= 22.12 for pnpm 11 (see .nvmrc)`);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
68
|
+
// Never corepack — this repo does not use it. Node via fnm, then `npm
|
|
69
|
+
// install -g pnpm`; pnpm self-switches to the pinned version on its first
|
|
70
|
+
// run here (pmOnFail=download).
|
|
71
|
+
const pnpmVersion = has("pnpm");
|
|
72
|
+
const repoRootForPin = discoverRepoRoot();
|
|
73
|
+
const pin = readPnpmPin(repoRootForPin);
|
|
74
|
+
if (!pnpmVersion) {
|
|
75
|
+
checks.push("WARN pnpm not found — run `npm install -g pnpm` (never `corepack enable`)");
|
|
76
|
+
}
|
|
77
|
+
else if (pin && pnpmVersion !== pin) {
|
|
78
|
+
checks.push(`WARN pnpm ${pnpmVersion} — this repo is pinned to ${pin}; any pnpm command here self-switches`);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
checks.push(`OK pnpm ${pnpmVersion}`);
|
|
82
|
+
}
|
|
83
|
+
checks.push(has("flutter", ["--version"])
|
|
52
84
|
? "OK Flutter installed (study-group-finder buildable)"
|
|
53
85
|
: "INFO Flutter not installed — only needed for apps/study-group-finder");
|
|
54
86
|
// ── No repo yet? ───────────────────────────────────────────────────────────
|
|
@@ -65,39 +97,23 @@ export async function runSetup() {
|
|
|
65
97
|
note([
|
|
66
98
|
"No DevDogsUGA checkout found in this directory or its parents.",
|
|
67
99
|
"",
|
|
68
|
-
"1. git clone https://github.com/
|
|
100
|
+
"1. git clone https://github.com/DevDogsUGA/DevDogsUGA.git",
|
|
69
101
|
"2. cd DevDogsUGA && pnpm install",
|
|
70
102
|
"3. pnpm devtools setup — run again from inside the clone to seed .env",
|
|
71
103
|
].join("\n"), "Next steps");
|
|
72
104
|
return;
|
|
73
105
|
}
|
|
106
|
+
note(checks.join("\n"), "Prerequisites");
|
|
74
107
|
// ── Env ────────────────────────────────────────────────────────────────────
|
|
75
|
-
// Seeded from the SAME renderer as `env init` and the generated
|
|
76
|
-
// .env.example, rather than by copying the example file: a copy would carry
|
|
77
|
-
// the example's "GENERATED ... Do not edit this file" header into the one
|
|
78
|
-
// file that is meant to be edited. Like init, an existing file is untouched.
|
|
79
108
|
const env = join(repoRoot, ".env");
|
|
80
|
-
let seededEnv = false;
|
|
81
|
-
// The apps the picker chose, so "Next steps" boots what this contributor is
|
|
82
|
-
// actually here for rather than a hardcoded default. Null when the .env
|
|
83
|
-
// already existed (no picker ran) — the generic `pnpm dev` picker covers it.
|
|
84
109
|
let chosenApps = null;
|
|
85
110
|
if (existsSync(env)) {
|
|
86
|
-
|
|
111
|
+
log.info("OK .env already exists (left untouched)");
|
|
87
112
|
}
|
|
88
113
|
else {
|
|
89
114
|
await loadRegistry();
|
|
90
|
-
// The project picker: a contributor's .env carries the sections for the
|
|
91
|
-
// projects they picked (plus the shared Supabase stack), not all six.
|
|
92
|
-
// The operator tooling is offered as a ROLE in the same picker, off by
|
|
93
|
-
// default: no app reads those keys, and every command that needs one
|
|
94
|
-
// refuses by name with the recovery step. Re-running `env init` later
|
|
95
|
-
// APPENDS the sections for a new project without touching filled values.
|
|
96
115
|
const sections = await resolveSections();
|
|
97
116
|
if (sections) {
|
|
98
|
-
// Lead with the two projects most contributors join; platform is shared
|
|
99
|
-
// auth infrastructure you depend on but rarely edit, and sandbox is
|
|
100
|
-
// dormant, trailing last as the one almost nobody touches.
|
|
101
117
|
const order = [
|
|
102
118
|
"schedule-builder",
|
|
103
119
|
"study-group-finder",
|
|
@@ -107,17 +123,40 @@ export async function runSetup() {
|
|
|
107
123
|
chosenApps = order.filter((app) => sections.has(app));
|
|
108
124
|
}
|
|
109
125
|
writeFileSync(env, renderInit("development", new Date().toISOString().slice(0, 10), sections), { flag: "wx" });
|
|
110
|
-
|
|
111
|
-
seededEnv = true;
|
|
112
|
-
}
|
|
113
|
-
note(checks.join("\n"), "Prerequisites");
|
|
114
|
-
if (seededEnv) {
|
|
126
|
+
log.success("Created .env (same as `pnpm devtools env init`)");
|
|
115
127
|
log.info(".env starts blank. Supabase on this machine fills the connection block " +
|
|
116
128
|
"for you; only a hosted project needs values typed in.");
|
|
117
129
|
}
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
130
|
+
// ── Local or hosted? ────────────────────────────────────────────────────────
|
|
131
|
+
const target = unwrap(await select({
|
|
132
|
+
message: "Which Supabase should this checkout use?",
|
|
133
|
+
options: [
|
|
134
|
+
{
|
|
135
|
+
value: "hosted",
|
|
136
|
+
label: "Hosted (your own free-tier project)",
|
|
137
|
+
hint: "recommended — set one up at supabase.com in a couple of minutes",
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
value: "local",
|
|
141
|
+
label: "Local (Docker)",
|
|
142
|
+
hint: "needs Docker Desktop, OrbStack, or Docker Engine in WSL2",
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
initialValue: "hosted",
|
|
146
|
+
}));
|
|
147
|
+
if (target === "hosted") {
|
|
148
|
+
await runHostedWizard(repoRoot);
|
|
149
|
+
// A read-only check of what just happened.
|
|
150
|
+
await runEnvironmentDoctor({ app: chosenApps?.[0] });
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
// .env stays blank until `db start`, so checking now would only report
|
|
154
|
+
// the expected gap. Point at doctor instead.
|
|
155
|
+
printLocalNextSteps(chosenApps);
|
|
156
|
+
log.info("After `pnpm devtools db start`, run `pnpm devtools doctor` to check your setup.");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function printLocalNextSteps(chosenApps) {
|
|
121
160
|
const startSteps = chosenApps && chosenApps.length > 0
|
|
122
161
|
? chosenApps.map((app) => app === "study-group-finder"
|
|
123
162
|
? ` pnpm dev --filter study-group-finder (Flutter — needs the SDK)`
|
|
@@ -143,10 +182,99 @@ export async function runSetup() {
|
|
|
143
182
|
" pnpm devtools workflows run --app schedule-builder --tier development",
|
|
144
183
|
]
|
|
145
184
|
: []),
|
|
146
|
-
"",
|
|
147
|
-
"Working against a hosted Supabase project instead? Fill in .env",
|
|
148
|
-
"(dashboard → Project Settings), then:",
|
|
149
|
-
" pnpm devtools db connect <project-ref>",
|
|
150
|
-
" pnpm devtools db types",
|
|
151
185
|
].join("\n"), "Next steps");
|
|
152
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* The hosted onboarding wizard: create a project on supabase.com by hand
|
|
189
|
+
* (this only asks for what comes out of it), then API URL, publishable key,
|
|
190
|
+
* secret key, and the Session pooler DB_URL — validated before anything is
|
|
191
|
+
* written. Non-clobbering: an already-filled key is left alone unless the
|
|
192
|
+
* contributor confirms overwriting it.
|
|
193
|
+
*/
|
|
194
|
+
async function runHostedWizard(repoRoot) {
|
|
195
|
+
note([
|
|
196
|
+
"1. Create a free project at https://supabase.com/dashboard",
|
|
197
|
+
"2. Project Settings → API: copy the Project URL, the publishable key,",
|
|
198
|
+
" and the secret key.",
|
|
199
|
+
"3. Project Settings → Database → Connection string → Session pooler:",
|
|
200
|
+
" copy that string (port 5432, not 6543).",
|
|
201
|
+
].join("\n"), "Create a hosted Supabase project");
|
|
202
|
+
const envPath = join(repoRoot, ".env");
|
|
203
|
+
const existing = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
|
|
204
|
+
const doc = EnvDocument.parse(existing);
|
|
205
|
+
const apiUrl = await promptEnvValue(doc, "API_URL", "Project URL", "https://<ref>.supabase.co");
|
|
206
|
+
const publishableKey = await promptEnvValue(doc, "PUBLISHABLE_KEY", "Publishable key", "sb_publishable_...");
|
|
207
|
+
const secretKey = await promptSecretValue(doc, "SECRET_KEY", "Secret key");
|
|
208
|
+
let dbUrl;
|
|
209
|
+
for (;;) {
|
|
210
|
+
const candidate = await promptSecretValue(doc, "DB_URL", "Session pooler DB_URL");
|
|
211
|
+
const validation = validateSessionPoolerUrl(candidate);
|
|
212
|
+
if (validation.ok) {
|
|
213
|
+
dbUrl = candidate;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
log.error(validation.message ?? "That DB_URL is not usable.");
|
|
217
|
+
if (validation.faqId) {
|
|
218
|
+
log.info(`https://devdogsuga.org/docs/platform/getting-started/troubleshooting#${validation.faqId}`);
|
|
219
|
+
}
|
|
220
|
+
doc.set("DB_URL", ""); // clear so the next loop iteration re-prompts
|
|
221
|
+
}
|
|
222
|
+
doc.set("API_URL", apiUrl);
|
|
223
|
+
doc.set("PUBLISHABLE_KEY", publishableKey);
|
|
224
|
+
doc.set("SECRET_KEY", secretKey);
|
|
225
|
+
doc.set("DB_URL", dbUrl);
|
|
226
|
+
writeFileSync(envPath, doc.toString());
|
|
227
|
+
log.success("Wrote .env");
|
|
228
|
+
const s = spinner();
|
|
229
|
+
s.start("Running migrations against your project");
|
|
230
|
+
const code = await dbPush(dbUrl, { yes: true });
|
|
231
|
+
if (code === 0) {
|
|
232
|
+
s.stop("Migrations applied");
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
s.stop("Migrations failed — see the Supabase CLI output above");
|
|
236
|
+
log.warn("Fix the problem above, then re-run `pnpm devtools setup` (your .env answers are kept), or run `pnpm devtools db migrate` and `pnpm devtools oauth` yourself.");
|
|
237
|
+
process.exitCode = 1;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
log.info('Now configuring "Sign in with DevDogs" against the same project.');
|
|
241
|
+
const hostedTarget = {
|
|
242
|
+
kind: "hosted",
|
|
243
|
+
apiUrl,
|
|
244
|
+
serviceRoleKey: secretKey,
|
|
245
|
+
};
|
|
246
|
+
await runOAuthSetup(undefined, undefined, undefined, hostedTarget);
|
|
247
|
+
// runOAuthSetup already printed the dashboard redirect-URL step.
|
|
248
|
+
note("pnpm dev --filter <your app>", "Then run your app");
|
|
249
|
+
}
|
|
250
|
+
async function promptEnvValue(doc, key, label, placeholder) {
|
|
251
|
+
const current = doc.get(key);
|
|
252
|
+
if (current) {
|
|
253
|
+
const overwrite = unwrap(await confirm({
|
|
254
|
+
message: `${label} is already set in .env. Overwrite it?`,
|
|
255
|
+
initialValue: false,
|
|
256
|
+
}));
|
|
257
|
+
if (!overwrite)
|
|
258
|
+
return current;
|
|
259
|
+
}
|
|
260
|
+
return unwrap(await text({
|
|
261
|
+
message: label,
|
|
262
|
+
placeholder,
|
|
263
|
+
validate: (v) => (v?.trim() ? undefined : "Required"),
|
|
264
|
+
})).trim();
|
|
265
|
+
}
|
|
266
|
+
async function promptSecretValue(doc, key, label) {
|
|
267
|
+
const current = doc.get(key);
|
|
268
|
+
if (current) {
|
|
269
|
+
const overwrite = unwrap(await confirm({
|
|
270
|
+
message: `${label} is already set in .env. Overwrite it?`,
|
|
271
|
+
initialValue: false,
|
|
272
|
+
}));
|
|
273
|
+
if (!overwrite)
|
|
274
|
+
return current;
|
|
275
|
+
}
|
|
276
|
+
return unwrap(await password({
|
|
277
|
+
message: label,
|
|
278
|
+
validate: (v) => (v?.trim() ? undefined : "Required"),
|
|
279
|
+
})).trim();
|
|
280
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devdogsuga/devtools",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Contributor CLI for a DevDogsUGA checkout: database, env, images, emails, cron, deploy. Ships built JS; run via `pnpm dlx @devdogsuga/devtools` (or the `devtools`/`devtools-ci` bins once installed) from inside a DevDogsUGA clone.",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
|
-
"url": "https://github.com/
|
|
10
|
+
"url": "https://github.com/DevDogsUGA/Backstage.git",
|
|
11
11
|
"directory": "packages/devtools"
|
|
12
12
|
},
|
|
13
13
|
"publishConfig": {
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"tsx": "^4.23.12",
|
|
39
39
|
"typescript": "^6.0.3",
|
|
40
40
|
"zod": "4.4.3",
|
|
41
|
-
"@devdogsuga/telemetry": "0.1.
|
|
41
|
+
"@devdogsuga/telemetry": "0.1.1"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
44
|
"@devdogsuga/brand": "*",
|
|
@@ -62,9 +62,9 @@
|
|
|
62
62
|
"@vitest/coverage-v8": "^4.1.11",
|
|
63
63
|
"react": "^19.2.8",
|
|
64
64
|
"vitest": "^4.1.11",
|
|
65
|
-
"@devdogsuga/
|
|
66
|
-
"@devdogsuga/db": "0.1.
|
|
67
|
-
"@devdogsuga/
|
|
65
|
+
"@devdogsuga/config": "0.1.1",
|
|
66
|
+
"@devdogsuga/db": "0.1.1",
|
|
67
|
+
"@devdogsuga/brand": "0.1.1",
|
|
68
68
|
"@devdogsuga/env": "0.1.0"
|
|
69
69
|
},
|
|
70
70
|
"scripts": {
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
export type NewsletterFormat = "eml" | "html";
|
|
2
|
-
export declare const NEWSLETTER_FORMATS: readonly ["eml", "html"];
|
|
3
|
-
export interface NewsletterOptions {
|
|
4
|
-
versions: string[];
|
|
5
|
-
formats: NewsletterFormat[];
|
|
6
|
-
out: string;
|
|
7
|
-
/** Append each issue as a draft in the club mailbox. */
|
|
8
|
-
push: boolean;
|
|
9
|
-
/** Send each issue over SMTP, to `to`, byte-for-byte as authored. */
|
|
10
|
-
send: boolean;
|
|
11
|
-
to: string[];
|
|
12
|
-
mailbox: string;
|
|
13
|
-
}
|
|
14
|
-
export declare function parseNewsletterArgs(argv: readonly string[], cwd: string): NewsletterOptions | Error;
|
|
15
|
-
export declare function destination(out: string, version: string, format: NewsletterFormat): string;
|
|
16
|
-
export declare function runNewsletter(argv: string[]): Promise<void>;
|
|
@@ -1,311 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `pnpm devtools newsletter [version…] [--format eml,html] [--out dir]
|
|
3
|
-
* [--push] [--send --to addresses] [--mailbox address]`.
|
|
4
|
-
*/
|
|
5
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
6
|
-
import { homedir } from "node:os";
|
|
7
|
-
import { dirname, resolve } from "node:path";
|
|
8
|
-
import { randomBytes } from "node:crypto";
|
|
9
|
-
import { isTTY, log, multiselect, spinner, text as askText, } from "@clack/prompts";
|
|
10
|
-
import { Resvg } from "@resvg/resvg-js";
|
|
11
|
-
import { positionals } from "../args.js";
|
|
12
|
-
import { errorMessage, explain, unwrap } from "../ui.js";
|
|
13
|
-
import { loadNewsletter, loadNewsletterExport } from "../repo/peers.js";
|
|
14
|
-
import { appendDraft } from "./imap.js";
|
|
15
|
-
import { originationHeaders, submitMessage } from "./smtp.js";
|
|
16
|
-
import { openInBrowser, startLoopback } from "./loopback.js";
|
|
17
|
-
import { authorizeUrl, codeFromRedirect, grantPath, PASTE_REDIRECT_URI, readGrant, redeemCode, refreshTokens, writeGrant, } from "./oauth.js";
|
|
18
|
-
/** The account the newsletter goes out from; `--mailbox` overrides. */
|
|
19
|
-
const CLUB_MAILBOX = "devdogs@uga.edu";
|
|
20
|
-
export const NEWSLETTER_FORMATS = ["eml", "html"];
|
|
21
|
-
function flagValue(argv, flag) {
|
|
22
|
-
const index = argv.indexOf(flag);
|
|
23
|
-
if (index === -1)
|
|
24
|
-
return undefined;
|
|
25
|
-
const value = argv[index + 1];
|
|
26
|
-
return value && !value.startsWith("--") ? value : undefined;
|
|
27
|
-
}
|
|
28
|
-
function expandHome(value) {
|
|
29
|
-
return value === "~" || value.startsWith("~/")
|
|
30
|
-
? resolve(homedir(), value.slice(2))
|
|
31
|
-
: value;
|
|
32
|
-
}
|
|
33
|
-
export function parseNewsletterArgs(argv, cwd) {
|
|
34
|
-
const push = argv.includes("--push");
|
|
35
|
-
const send = argv.includes("--send");
|
|
36
|
-
const to = (flagValue(argv, "--to") ?? "")
|
|
37
|
-
.split(",")
|
|
38
|
-
.map((value) => value.trim())
|
|
39
|
-
.filter(Boolean);
|
|
40
|
-
// A send is deliberate twice over: the flag names the act and --to names
|
|
41
|
-
// who receives it. Neither is inferred and neither is prompted for.
|
|
42
|
-
if (send && !to.length) {
|
|
43
|
-
return new Error("--send needs --to, a comma-separated recipient list.");
|
|
44
|
-
}
|
|
45
|
-
if (to.length && !send) {
|
|
46
|
-
return new Error("--to only means something with --send.");
|
|
47
|
-
}
|
|
48
|
-
// Both formats by default: the .eml is the point of the command, and the
|
|
49
|
-
// .html is how it gets proofread before an officer opens Outlook. Except
|
|
50
|
-
// under --push or --send, where a caller who wanted files too would have
|
|
51
|
-
// said so — the draft in the mailbox, or the send, IS the output.
|
|
52
|
-
const rawFormats = (flagValue(argv, "--format") ?? (push || send ? "" : "eml,html"))
|
|
53
|
-
.split(",")
|
|
54
|
-
.map((value) => value.trim())
|
|
55
|
-
.filter(Boolean);
|
|
56
|
-
const badFormats = rawFormats.filter((value) => !NEWSLETTER_FORMATS.includes(value));
|
|
57
|
-
if (badFormats.length)
|
|
58
|
-
return new Error(`Unknown format ${badFormats.join(", ")}. Try eml or html.`);
|
|
59
|
-
return {
|
|
60
|
-
// Versions can only be validated against the built package, which loads
|
|
61
|
-
// lazily inside the run — so they pass through here unchecked.
|
|
62
|
-
versions: positionals(argv),
|
|
63
|
-
formats: [...new Set(rawFormats)],
|
|
64
|
-
out: resolve(cwd, expandHome(flagValue(argv, "--out") ?? "changelog-exports")),
|
|
65
|
-
push,
|
|
66
|
-
send,
|
|
67
|
-
to,
|
|
68
|
-
mailbox: flagValue(argv, "--mailbox") ?? CLUB_MAILBOX,
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
export function destination(out, version, format) {
|
|
72
|
-
return resolve(out, `changelog-v${version}.${format}`);
|
|
73
|
-
}
|
|
74
|
-
async function interactive(options, all) {
|
|
75
|
-
if (options.versions.length)
|
|
76
|
-
return options;
|
|
77
|
-
if (!isTTY(process.stdout)) {
|
|
78
|
-
throw new Error("No terminal to choose issues. Name one, or pass * for all.");
|
|
79
|
-
}
|
|
80
|
-
const versions = unwrap(await multiselect({
|
|
81
|
-
message: "Which issues?",
|
|
82
|
-
options: all.map((issue) => ({
|
|
83
|
-
value: issue.version,
|
|
84
|
-
label: `v${issue.version}`,
|
|
85
|
-
hint: issue.tagline,
|
|
86
|
-
})),
|
|
87
|
-
initialValues: [all[all.length - 1]?.version ?? ""].filter(Boolean),
|
|
88
|
-
required: true,
|
|
89
|
-
}));
|
|
90
|
-
const outputs = unwrap(await multiselect({
|
|
91
|
-
message: "Which outputs?",
|
|
92
|
-
options: [
|
|
93
|
-
{
|
|
94
|
-
value: "push",
|
|
95
|
-
label: "Draft in the club mailbox",
|
|
96
|
-
hint: `lands in Drafts of ${options.mailbox}; review in any Outlook`,
|
|
97
|
-
},
|
|
98
|
-
{
|
|
99
|
-
value: "eml",
|
|
100
|
-
label: "Outlook draft (.eml)",
|
|
101
|
-
hint: "classic Outlook opens it for review",
|
|
102
|
-
},
|
|
103
|
-
{ value: "html", label: "HTML preview", hint: "open in a browser" },
|
|
104
|
-
],
|
|
105
|
-
initialValues: [...NEWSLETTER_FORMATS],
|
|
106
|
-
required: true,
|
|
107
|
-
}));
|
|
108
|
-
const formats = outputs.filter((output) => output !== "push");
|
|
109
|
-
let out = options.out;
|
|
110
|
-
if (formats.length) {
|
|
111
|
-
const entered = unwrap(await askText({
|
|
112
|
-
message: "Where should the files go?",
|
|
113
|
-
placeholder: "./changelog-exports",
|
|
114
|
-
defaultValue: "./changelog-exports",
|
|
115
|
-
})).trim();
|
|
116
|
-
out = resolve(process.cwd(), expandHome(entered || "changelog-exports"));
|
|
117
|
-
}
|
|
118
|
-
return {
|
|
119
|
-
...options,
|
|
120
|
-
versions,
|
|
121
|
-
formats,
|
|
122
|
-
out,
|
|
123
|
-
push: options.push || outputs.includes("push"),
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
/**
|
|
127
|
-
* The browser sign-in, redirect caught by a loopback server: open the URL,
|
|
128
|
-
* wait for the code to knock, exchange it. Five minutes is the patience —
|
|
129
|
-
* authorization codes do not live much longer anyway.
|
|
130
|
-
*/
|
|
131
|
-
async function signInViaLoopback(mailbox, server, state) {
|
|
132
|
-
const redirectUri = `http://localhost:${server.port}/`;
|
|
133
|
-
const url = authorizeUrl(mailbox, redirectUri, state);
|
|
134
|
-
log.step(`Sign in as ${mailbox} in the browser window that just opened:`);
|
|
135
|
-
log.message(url);
|
|
136
|
-
openInBrowser(url);
|
|
137
|
-
const wait = spinner();
|
|
138
|
-
wait.start("Waiting for the sign-in to come back");
|
|
139
|
-
try {
|
|
140
|
-
const timeout = new Promise((_, rejectLate) => {
|
|
141
|
-
setTimeout(() => rejectLate(new Error("Five minutes passed with no sign-in. Run it again.")), 5 * 60_000).unref();
|
|
142
|
-
});
|
|
143
|
-
const code = await Promise.race([server.code, timeout]);
|
|
144
|
-
wait.stop("The sign-in came back.");
|
|
145
|
-
return await redeemCode(code, redirectUri);
|
|
146
|
-
}
|
|
147
|
-
catch (err) {
|
|
148
|
-
wait.stop("No sign-in.");
|
|
149
|
-
throw err;
|
|
150
|
-
}
|
|
151
|
-
finally {
|
|
152
|
-
server.close();
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
/** The pasted fallback for a machine where no local port would bind. */
|
|
156
|
-
async function signInViaPaste(mailbox) {
|
|
157
|
-
log.step(`Open this and sign in as ${mailbox}:`);
|
|
158
|
-
log.message(authorizeUrl(mailbox, PASTE_REDIRECT_URI));
|
|
159
|
-
const pasted = unwrap(await askText({
|
|
160
|
-
message: "The browser will land on a dead localhost page. Paste its full address:",
|
|
161
|
-
validate: (value) => {
|
|
162
|
-
const code = codeFromRedirect(value ?? "");
|
|
163
|
-
return code instanceof Error ? code.message : undefined;
|
|
164
|
-
},
|
|
165
|
-
}));
|
|
166
|
-
const code = codeFromRedirect(pasted);
|
|
167
|
-
if (code instanceof Error)
|
|
168
|
-
throw code;
|
|
169
|
-
return redeemCode(code, PASTE_REDIRECT_URI);
|
|
170
|
-
}
|
|
171
|
-
/**
|
|
172
|
-
* An access token for the club mailbox: the stored grant refreshed when there
|
|
173
|
-
* is one, a browser sign-in when there is not.
|
|
174
|
-
*/
|
|
175
|
-
async function mailboxAccessToken(mailbox) {
|
|
176
|
-
const stored = await readGrant();
|
|
177
|
-
if (stored && stored.mailbox === mailbox) {
|
|
178
|
-
try {
|
|
179
|
-
const tokens = await refreshTokens(stored.refreshToken);
|
|
180
|
-
// Microsoft rotates refresh tokens; keeping the old one means the next
|
|
181
|
-
// run signs in from scratch.
|
|
182
|
-
await writeGrant({ mailbox, refreshToken: tokens.refreshToken });
|
|
183
|
-
return tokens.accessToken;
|
|
184
|
-
}
|
|
185
|
-
catch (err) {
|
|
186
|
-
log.warn(`The stored sign-in was refused (${errorMessage(err)}).`);
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
if (!isTTY(process.stdout)) {
|
|
190
|
-
throw new Error(`No terminal to sign in as ${mailbox}. Run \`pnpm devtools newsletter --push\` interactively once; after that this works anywhere.`);
|
|
191
|
-
}
|
|
192
|
-
const state = randomBytes(16).toString("hex");
|
|
193
|
-
const server = await startLoopback(state).catch(() => null);
|
|
194
|
-
const tokens = server
|
|
195
|
-
? await signInViaLoopback(mailbox, server, state)
|
|
196
|
-
: await signInViaPaste(mailbox);
|
|
197
|
-
await writeGrant({ mailbox, refreshToken: tokens.refreshToken });
|
|
198
|
-
log.info(`Signed in. The grant lives in ${grantPath()} — mode 600, keep it that way.`);
|
|
199
|
-
return tokens.accessToken;
|
|
200
|
-
}
|
|
201
|
-
export async function runNewsletter(argv) {
|
|
202
|
-
const parsed = parseNewsletterArgs(argv, process.cwd());
|
|
203
|
-
if (parsed instanceof Error) {
|
|
204
|
-
explain("Could not read that.", parsed.message, [
|
|
205
|
-
"pnpm devtools newsletter",
|
|
206
|
-
"pnpm devtools newsletter '*' --format eml,html --out ~/changelog",
|
|
207
|
-
]);
|
|
208
|
-
process.exitCode = 1;
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
try {
|
|
212
|
-
// Dynamic so every unrelated devtools command can still run before the
|
|
213
|
-
// newsletter package has been built on a fresh checkout.
|
|
214
|
-
const { ISSUES, issueByVersion } = await loadNewsletter();
|
|
215
|
-
const { buildEml, emailImages, previewRenderContext, renderIssueDocument } = await loadNewsletterExport();
|
|
216
|
-
const unknown = parsed.versions.filter((version) => version !== "*" && !issueByVersion(version));
|
|
217
|
-
if (unknown.length) {
|
|
218
|
-
explain("Could not read that.", `No issue called ${unknown.join(", ")}. Try ${ISSUES.map((issue) => issue.version).join(", ")}, or *.`, ["pnpm devtools newsletter", "pnpm devtools newsletter '*'"]);
|
|
219
|
-
process.exitCode = 1;
|
|
220
|
-
return;
|
|
221
|
-
}
|
|
222
|
-
const options = await interactive(parsed, ISSUES);
|
|
223
|
-
const versions = options.versions.includes("*")
|
|
224
|
-
? ISSUES.map((issue) => issue.version)
|
|
225
|
-
: [...new Set(options.versions)];
|
|
226
|
-
// Rasterised once: the marks are the same in every issue. 2x the display
|
|
227
|
-
// size the components declare, so the email stays sharp on dense screens.
|
|
228
|
-
const images = options.formats.includes("eml") || options.push || options.send
|
|
229
|
-
? emailImages().map((image) => ({
|
|
230
|
-
cid: image.cid,
|
|
231
|
-
filename: image.filename,
|
|
232
|
-
contentType: "image/png",
|
|
233
|
-
base64: Buffer.from(new Resvg(image.svg, {
|
|
234
|
-
fitTo: { mode: "width", value: image.rasterWidth },
|
|
235
|
-
// The marks are pure paths; nothing here needs a host font.
|
|
236
|
-
font: { loadSystemFonts: false },
|
|
237
|
-
})
|
|
238
|
-
.render()
|
|
239
|
-
.asPng()).toString("base64"),
|
|
240
|
-
}))
|
|
241
|
-
: [];
|
|
242
|
-
const written = [];
|
|
243
|
-
for (const version of versions) {
|
|
244
|
-
const issue = issueByVersion(version);
|
|
245
|
-
for (const format of options.formats) {
|
|
246
|
-
const file = destination(options.out, version, format);
|
|
247
|
-
await mkdir(dirname(file), { recursive: true });
|
|
248
|
-
await writeFile(file, format === "eml"
|
|
249
|
-
? buildEml({
|
|
250
|
-
subject: issue.title,
|
|
251
|
-
html: renderIssueDocument(issue),
|
|
252
|
-
images,
|
|
253
|
-
})
|
|
254
|
-
: renderIssueDocument(issue, previewRenderContext()));
|
|
255
|
-
written.push(file);
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
for (const file of written)
|
|
259
|
-
log.success(file);
|
|
260
|
-
if (written.length) {
|
|
261
|
-
log.info(`${written.length} file${written.length === 1 ? "" : "s"} written. An .eml opens in classic Outlook for review; send with --send.`);
|
|
262
|
-
}
|
|
263
|
-
if (options.push || options.send) {
|
|
264
|
-
const accessToken = await mailboxAccessToken(options.mailbox);
|
|
265
|
-
if (options.push) {
|
|
266
|
-
for (const version of versions) {
|
|
267
|
-
const issue = issueByVersion(version);
|
|
268
|
-
await appendDraft({
|
|
269
|
-
user: options.mailbox,
|
|
270
|
-
accessToken,
|
|
271
|
-
message: buildEml({
|
|
272
|
-
subject: issue.title,
|
|
273
|
-
html: renderIssueDocument(issue),
|
|
274
|
-
images,
|
|
275
|
-
unsent: false,
|
|
276
|
-
}),
|
|
277
|
-
});
|
|
278
|
-
log.success(`v${version} → Drafts of ${options.mailbox}`);
|
|
279
|
-
}
|
|
280
|
-
log.info("Open any Outlook as the club account to review — but send with " +
|
|
281
|
-
"`--send`, not from Outlook: its composers rewrite the HTML.");
|
|
282
|
-
}
|
|
283
|
-
if (options.send) {
|
|
284
|
-
for (const version of versions) {
|
|
285
|
-
const issue = issueByVersion(version);
|
|
286
|
-
await submitMessage({
|
|
287
|
-
user: options.mailbox,
|
|
288
|
-
accessToken,
|
|
289
|
-
from: options.mailbox,
|
|
290
|
-
recipients: options.to,
|
|
291
|
-
message: originationHeaders(options.mailbox, options.to) +
|
|
292
|
-
buildEml({
|
|
293
|
-
subject: issue.title,
|
|
294
|
-
html: renderIssueDocument(issue),
|
|
295
|
-
images,
|
|
296
|
-
unsent: false,
|
|
297
|
-
}),
|
|
298
|
-
});
|
|
299
|
-
log.success(`v${version} → sent to ${options.to.join(", ")}`);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
catch (err) {
|
|
305
|
-
explain("Could not export the changelog.", errorMessage(err), [
|
|
306
|
-
"Build it with `pnpm --filter @devdogsuga/newsletter build`.",
|
|
307
|
-
"Then try `pnpm devtools newsletter '*' --out ~/changelog`.",
|
|
308
|
-
]);
|
|
309
|
-
process.exitCode = 1;
|
|
310
|
-
}
|
|
311
|
-
}
|