@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/setup.js CHANGED
@@ -1,30 +1,53 @@
1
1
  /**
2
2
  * One-command onboarding, the first thing a new contributor runs.
3
3
  *
4
- * Checks prerequisites (warns, never hard-fails on optional tools), seeds the
5
- * root `.env`, and points at the database and OAuth steps. Deliberately does NOT run remote
6
- * Supabase commands: linking a remote project needs credentials that are not
7
- * in `.env` yet.
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 { execSync } from "node:child_process";
15
- import { existsSync, writeFileSync } from "node:fs";
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
- function has(cmd) {
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
- execSync(cmd, { stdio: ["ignore", "pipe", "ignore"] });
24
- return true;
31
+ return execFileSync(cmd, args, {
32
+ stdio: ["ignore", "pipe", "ignore"],
33
+ encoding: "utf8",
34
+ }).trim();
25
35
  }
26
36
  catch {
27
- return false;
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
- checks.push(has("corepack --version")
46
- ? "OK corepack available"
47
- : "WARN corepack not found — run `corepack enable` for the pinned pnpm");
48
- checks.push(has("docker info")
49
- ? "OK Docker running (local Supabase stack available)"
50
- : "INFO Docker not running — fine against a hosted project, needed to run Supabase on this machine");
51
- checks.push(has("flutter --version")
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/devdogsuga/DevDogsUGA.git",
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
- checks.push("OK .env already exists (left untouched)");
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
- checks.push("OK created .env (same as `pnpm devtools env init`)");
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
- // Boot what the contributor picked. study-group-finder still goes through
119
- // the same `pnpm dev --filter` picker path (pnpm runs its `flutter run`
120
- // task), so the shape is uniform; the SDK note is the only difference.
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.0",
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/devdogsuga/Backstage.git",
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.0"
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/brand": "0.1.0",
66
- "@devdogsuga/db": "0.1.0",
67
- "@devdogsuga/config": "0.1.0",
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
- }