@devdogsuga/devtools 0.1.0
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 +44 -0
- package/bin/devtools-ci-bare.mjs +48 -0
- package/bin/devtools-ci.mjs +17 -0
- package/bin/devtools.mjs +27 -0
- package/dist/args.d.ts +26 -0
- package/dist/args.js +78 -0
- package/dist/bws/bw.d.ts +9 -0
- package/dist/bws/bw.js +49 -0
- package/dist/bws/client.d.ts +43 -0
- package/dist/bws/client.js +363 -0
- package/dist/bws/environments.d.ts +96 -0
- package/dist/bws/environments.js +111 -0
- package/dist/bws/pace.d.ts +27 -0
- package/dist/bws/pace.js +43 -0
- package/dist/bws/retry.d.ts +12 -0
- package/dist/bws/retry.js +39 -0
- package/dist/bws/store.d.ts +1 -0
- package/dist/bws/store.js +32 -0
- package/dist/bws/token.d.ts +31 -0
- package/dist/bws/token.js +88 -0
- package/dist/bws/vault.d.ts +29 -0
- package/dist/bws/vault.js +200 -0
- package/dist/catalog.d.ts +55 -0
- package/dist/catalog.js +81 -0
- package/dist/cf/commands.d.ts +1 -0
- package/dist/cf/commands.js +182 -0
- package/dist/cf/local-env.d.ts +44 -0
- package/dist/cf/local-env.js +148 -0
- package/dist/ci.d.ts +1 -0
- package/dist/ci.js +319 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +1077 -0
- package/dist/commands.d.ts +255 -0
- package/dist/commands.js +1279 -0
- package/dist/completions.d.ts +4 -0
- package/dist/completions.js +110 -0
- package/dist/cron/commands.d.ts +47 -0
- package/dist/cron/commands.js +372 -0
- package/dist/cron/discovery.d.ts +55 -0
- package/dist/cron/discovery.js +85 -0
- package/dist/cron/schema.d.ts +45 -0
- package/dist/cron/schema.js +35 -0
- package/dist/db/config-push.d.ts +8 -0
- package/dist/db/config-push.js +11 -0
- package/dist/db/connection.d.ts +79 -0
- package/dist/db/connection.js +114 -0
- package/dist/db/exec.d.ts +1 -0
- package/dist/db/exec.js +11 -0
- package/dist/db/generate-migration.d.ts +1 -0
- package/dist/db/generate-migration.js +67 -0
- package/dist/db/generate-types.d.ts +1 -0
- package/dist/db/generate-types.js +4 -0
- package/dist/db/introspect.d.ts +1 -0
- package/dist/db/introspect.js +113 -0
- package/dist/db/local-env.d.ts +65 -0
- package/dist/db/local-env.js +23 -0
- package/dist/db/new-migration.d.ts +1 -0
- package/dist/db/new-migration.js +8 -0
- package/dist/db/run.d.ts +70 -0
- package/dist/db/run.js +158 -0
- package/dist/db/seed-buckets.d.ts +2 -0
- package/dist/db/seed-buckets.js +4 -0
- package/dist/db/seed-production.d.ts +8 -0
- package/dist/db/seed-production.js +73 -0
- package/dist/db/seed-roles.d.ts +7 -0
- package/dist/db/seed-roles.js +31 -0
- package/dist/db/session-refresh.d.ts +43 -0
- package/dist/db/session-refresh.js +40 -0
- package/dist/deploy/migrations.d.ts +20 -0
- package/dist/deploy/migrations.js +68 -0
- package/dist/deploy/orphans.d.ts +20 -0
- package/dist/deploy/orphans.js +177 -0
- package/dist/deploy/preflight.d.ts +53 -0
- package/dist/deploy/preflight.js +189 -0
- package/dist/deploy/report.d.ts +26 -0
- package/dist/deploy/report.js +70 -0
- package/dist/deploy/require-planner.d.ts +2 -0
- package/dist/deploy/require-planner.js +60 -0
- package/dist/deploy/require-token.d.ts +15 -0
- package/dist/deploy/require-token.js +78 -0
- package/dist/deploy/secrets-file.d.ts +17 -0
- package/dist/deploy/secrets-file.js +167 -0
- package/dist/deploy/write-env.d.ts +40 -0
- package/dist/deploy/write-env.js +393 -0
- package/dist/docs/index-pages.d.ts +36 -0
- package/dist/docs/index-pages.js +184 -0
- package/dist/doctor.d.ts +62 -0
- package/dist/doctor.js +225 -0
- package/dist/emails/commands.d.ts +15 -0
- package/dist/emails/commands.js +130 -0
- package/dist/emails/email-types.d.ts +13 -0
- package/dist/emails/email-types.js +1 -0
- package/dist/emails/fixtures.d.ts +20 -0
- package/dist/emails/fixtures.js +20 -0
- package/dist/env/audit.d.ts +206 -0
- package/dist/env/audit.js +436 -0
- package/dist/env/cloudflare.d.ts +21 -0
- package/dist/env/cloudflare.js +61 -0
- package/dist/env/commands.d.ts +99 -0
- package/dist/env/commands.js +612 -0
- package/dist/env/discovery.d.ts +15 -0
- package/dist/env/discovery.js +226 -0
- package/dist/env/document.d.ts +119 -0
- package/dist/env/document.js +323 -0
- package/dist/env/example.d.ts +68 -0
- package/dist/env/example.js +603 -0
- package/dist/env/selection.d.ts +105 -0
- package/dist/env/selection.js +226 -0
- package/dist/environment.d.ts +92 -0
- package/dist/environment.js +206 -0
- package/dist/fingerprint.d.ts +10 -0
- package/dist/fingerprint.js +18 -0
- package/dist/gen/campus-map.d.ts +1 -0
- package/dist/gen/campus-map.js +473 -0
- package/dist/gen/commands.d.ts +1 -0
- package/dist/gen/commands.js +25 -0
- package/dist/gen/hypno.d.ts +1 -0
- package/dist/gen/hypno.js +134 -0
- package/dist/gh/client.d.ts +80 -0
- package/dist/gh/client.js +258 -0
- package/dist/gh/environments.d.ts +83 -0
- package/dist/gh/environments.js +178 -0
- package/dist/gh/rulesets/actors.d.ts +14 -0
- package/dist/gh/rulesets/actors.js +68 -0
- package/dist/gh/rulesets/api.d.ts +15 -0
- package/dist/gh/rulesets/api.js +99 -0
- package/dist/gh/rulesets/commands.d.ts +3 -0
- package/dist/gh/rulesets/commands.js +203 -0
- package/dist/gh/rulesets/desired.d.ts +112 -0
- package/dist/gh/rulesets/desired.js +247 -0
- package/dist/gh/rulesets/diff.d.ts +65 -0
- package/dist/gh/rulesets/diff.js +134 -0
- package/dist/gh/rulesets/types.d.ts +76 -0
- package/dist/gh/rulesets/types.js +13 -0
- package/dist/gh/settings/api.d.ts +40 -0
- package/dist/gh/settings/api.js +172 -0
- package/dist/gh/settings/commands.d.ts +1 -0
- package/dist/gh/settings/commands.js +245 -0
- package/dist/gh/settings/desired.d.ts +53 -0
- package/dist/gh/settings/desired.js +39 -0
- package/dist/gh/settings/diff.d.ts +57 -0
- package/dist/gh/settings/diff.js +156 -0
- package/dist/gh/settings/types.d.ts +92 -0
- package/dist/gh/settings/types.js +11 -0
- package/dist/gh/settings/workflows.d.ts +41 -0
- package/dist/gh/settings/workflows.js +124 -0
- package/dist/grantRoot.d.ts +42 -0
- package/dist/grantRoot.js +89 -0
- package/dist/help.d.ts +18 -0
- package/dist/help.js +169 -0
- package/dist/images/commands.d.ts +35 -0
- package/dist/images/commands.js +233 -0
- package/dist/images/events.d.ts +36 -0
- package/dist/images/events.js +152 -0
- package/dist/images/graphics.d.ts +70 -0
- package/dist/images/graphics.js +233 -0
- package/dist/images/open-graph-types.d.ts +75 -0
- package/dist/images/open-graph-types.js +1 -0
- package/dist/images/prompts.d.ts +34 -0
- package/dist/images/prompts.js +100 -0
- package/dist/images/render.d.ts +21 -0
- package/dist/images/render.js +57 -0
- package/dist/images/select.d.ts +64 -0
- package/dist/images/select.js +96 -0
- package/dist/instance.d.ts +67 -0
- package/dist/instance.js +129 -0
- package/dist/invocation.d.ts +49 -0
- package/dist/invocation.js +78 -0
- package/dist/launch-ci.d.ts +1 -0
- package/dist/launch-ci.js +86 -0
- package/dist/launch.d.ts +29 -0
- package/dist/launch.js +371 -0
- package/dist/menu.d.ts +31 -0
- package/dist/menu.js +277 -0
- package/dist/newsletter/commands.d.ts +16 -0
- package/dist/newsletter/commands.js +311 -0
- package/dist/newsletter/imap.d.ts +17 -0
- package/dist/newsletter/imap.js +127 -0
- package/dist/newsletter/loopback.d.ts +24 -0
- package/dist/newsletter/loopback.js +100 -0
- package/dist/newsletter/oauth.d.ts +26 -0
- package/dist/newsletter/oauth.js +130 -0
- package/dist/newsletter/smtp.d.ts +23 -0
- package/dist/newsletter/smtp.js +133 -0
- package/dist/oauth/browser.d.ts +1 -0
- package/dist/oauth/browser.js +13 -0
- package/dist/oauth/config.d.ts +24 -0
- package/dist/oauth/config.js +24 -0
- package/dist/oauth/connect-url.d.ts +17 -0
- package/dist/oauth/connect-url.js +23 -0
- package/dist/oauth/db.d.ts +37 -0
- package/dist/oauth/db.js +108 -0
- package/dist/oauth/device.d.ts +93 -0
- package/dist/oauth/device.js +190 -0
- package/dist/oauth/discovery.d.ts +28 -0
- package/dist/oauth/discovery.js +54 -0
- package/dist/oauth/env-file.d.ts +6 -0
- package/dist/oauth/env-file.js +35 -0
- package/dist/oauth/exchange.d.ts +43 -0
- package/dist/oauth/exchange.js +87 -0
- package/dist/oauth/label.d.ts +1 -0
- package/dist/oauth/label.js +25 -0
- package/dist/oauth/loopback.d.ts +52 -0
- package/dist/oauth/loopback.js +162 -0
- package/dist/oauth/pkce.d.ts +6 -0
- package/dist/oauth/pkce.js +34 -0
- package/dist/oauth/target.d.ts +54 -0
- package/dist/oauth/target.js +102 -0
- package/dist/oauth/transport.d.ts +41 -0
- package/dist/oauth/transport.js +32 -0
- package/dist/oauth/wizard.d.ts +33 -0
- package/dist/oauth/wizard.js +571 -0
- package/dist/pick.d.ts +6 -0
- package/dist/pick.js +64 -0
- package/dist/planner/checks.d.ts +26 -0
- package/dist/planner/checks.js +104 -0
- package/dist/planner/commands.d.ts +10 -0
- package/dist/planner/commands.js +367 -0
- package/dist/planner/db.d.ts +15 -0
- package/dist/planner/db.js +33 -0
- package/dist/planner/role.d.ts +33 -0
- package/dist/planner/role.js +68 -0
- package/dist/repo/peers.d.ts +24 -0
- package/dist/repo/peers.js +114 -0
- package/dist/repo/preflight.d.ts +109 -0
- package/dist/repo/preflight.js +266 -0
- package/dist/repo/resolve.d.ts +41 -0
- package/dist/repo/resolve.js +152 -0
- package/dist/repo/root.d.ts +23 -0
- package/dist/repo/root.js +91 -0
- package/dist/repo/source.d.ts +14 -0
- package/dist/repo/source.js +54 -0
- package/dist/repo/tsx-loader.d.ts +6 -0
- package/dist/repo/tsx-loader.js +41 -0
- package/dist/run/pick.d.ts +60 -0
- package/dist/run/pick.js +559 -0
- package/dist/setup.d.ts +1 -0
- package/dist/setup.js +152 -0
- package/dist/stack.d.ts +48 -0
- package/dist/stack.js +290 -0
- package/dist/telemetry.d.ts +34 -0
- package/dist/telemetry.js +93 -0
- package/dist/tier.d.ts +40 -0
- package/dist/tier.js +93 -0
- package/dist/tsconfig.typecheck.tsbuildinfo +1 -0
- package/dist/ui.d.ts +25 -0
- package/dist/ui.js +51 -0
- package/dist/workers.d.ts +5 -0
- package/dist/workers.js +35 -0
- package/dist/workflows/commands.d.ts +44 -0
- package/dist/workflows/commands.js +668 -0
- package/env.ts +158 -0
- package/package.json +80 -0
|
@@ -0,0 +1,16 @@
|
|
|
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>;
|
|
@@ -0,0 +1,311 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** RFC 7628's SASL initial response: user and bearer token, ^A-delimited. */
|
|
2
|
+
export declare function xoauth2(user: string, accessToken: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* The Drafts folder's real name out of `LIST` output, for mailboxes whose
|
|
5
|
+
* folders are not in English. The `\Drafts` attribute is the label Exchange
|
|
6
|
+
* puts on the folder regardless of what it is called.
|
|
7
|
+
*/
|
|
8
|
+
export declare function draftsFromList(lines: readonly string[]): string | null;
|
|
9
|
+
/** Folder names go back out quoted, so quote-significant bytes get escaped. */
|
|
10
|
+
export declare function quoteMailbox(name: string): string;
|
|
11
|
+
export declare function appendDraft(options: {
|
|
12
|
+
user: string;
|
|
13
|
+
accessToken: string;
|
|
14
|
+
/** The full MIME message, CRLF line endings. */
|
|
15
|
+
message: string;
|
|
16
|
+
host?: string;
|
|
17
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-shot IMAP APPEND: place a finished MIME message in a mailbox's Drafts
|
|
3
|
+
* folder, where every Outlook — new, web, Mac, classic — offers it as an
|
|
4
|
+
* editable draft. This is the whole reason for speaking IMAP at all: the
|
|
5
|
+
* mailbox is the one place all the clients agree on, and APPEND is the one
|
|
6
|
+
* verb this workflow needs, so a dependency-free sliver of the protocol beats
|
|
7
|
+
* a client library that models the rest of it.
|
|
8
|
+
*/
|
|
9
|
+
import { connect } from "node:tls";
|
|
10
|
+
const DEFAULT_HOST = "outlook.office365.com";
|
|
11
|
+
const TIMEOUT_MS = 30_000;
|
|
12
|
+
/** RFC 7628's SASL initial response: user and bearer token, ^A-delimited. */
|
|
13
|
+
export function xoauth2(user, accessToken) {
|
|
14
|
+
return Buffer.from(`user=${user}\x01auth=Bearer ${accessToken}\x01\x01`, "utf8").toString("base64");
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The Drafts folder's real name out of `LIST` output, for mailboxes whose
|
|
18
|
+
* folders are not in English. The `\Drafts` attribute is the label Exchange
|
|
19
|
+
* puts on the folder regardless of what it is called.
|
|
20
|
+
*/
|
|
21
|
+
export function draftsFromList(lines) {
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
const match = /^\* LIST \(([^)]*)\) (?:"[^"]*"|NIL) (.+)$/.exec(line);
|
|
24
|
+
if (!match || !/\\Drafts\b/i.test(match[1]))
|
|
25
|
+
continue;
|
|
26
|
+
const raw = match[2].trim();
|
|
27
|
+
return raw.startsWith('"') && raw.endsWith('"')
|
|
28
|
+
? raw.slice(1, -1).replace(/\\(["\\])/g, "$1")
|
|
29
|
+
: raw;
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
/** Folder names go back out quoted, so quote-significant bytes get escaped. */
|
|
34
|
+
export function quoteMailbox(name) {
|
|
35
|
+
return `"${name.replace(/([\\"])/g, "\\$1")}"`;
|
|
36
|
+
}
|
|
37
|
+
export async function appendDraft(options) {
|
|
38
|
+
const host = options.host ?? DEFAULT_HOST;
|
|
39
|
+
const socket = connect({ host, port: 993, servername: host });
|
|
40
|
+
socket.setTimeout(TIMEOUT_MS);
|
|
41
|
+
let buffer = "";
|
|
42
|
+
let sequence = 0;
|
|
43
|
+
const pending = [];
|
|
44
|
+
let wake = null;
|
|
45
|
+
let failure = null;
|
|
46
|
+
socket.on("data", (chunk) => {
|
|
47
|
+
buffer += chunk.toString("utf8");
|
|
48
|
+
let index;
|
|
49
|
+
while ((index = buffer.indexOf("\r\n")) !== -1) {
|
|
50
|
+
pending.push(buffer.slice(0, index));
|
|
51
|
+
buffer = buffer.slice(index + 2);
|
|
52
|
+
}
|
|
53
|
+
wake?.();
|
|
54
|
+
});
|
|
55
|
+
const fail = (err) => {
|
|
56
|
+
failure ??= err;
|
|
57
|
+
socket.destroy();
|
|
58
|
+
wake?.();
|
|
59
|
+
};
|
|
60
|
+
socket.on("error", (err) => fail(err));
|
|
61
|
+
socket.on("timeout", () => fail(new Error(`${host} stopped answering.`)));
|
|
62
|
+
socket.on("close", () => fail(new Error(`${host} closed the connection.`)));
|
|
63
|
+
const nextLine = async () => {
|
|
64
|
+
for (;;) {
|
|
65
|
+
if (pending.length)
|
|
66
|
+
return pending.shift();
|
|
67
|
+
if (failure)
|
|
68
|
+
throw failure;
|
|
69
|
+
await new Promise((resolvePromise) => {
|
|
70
|
+
wake = resolvePromise;
|
|
71
|
+
});
|
|
72
|
+
wake = null;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Sends one command and reads to its tagged reply. A `+ ` continuation
|
|
77
|
+
* hands control back via `onContinue` — APPEND pushes the literal there,
|
|
78
|
+
* and a failed AUTHENTICATE wants a bare CRLF to surface its tagged NO.
|
|
79
|
+
*/
|
|
80
|
+
const command = async (line, onContinue) => {
|
|
81
|
+
const tag = `T${++sequence}`;
|
|
82
|
+
socket.write(`${tag} ${line}\r\n`);
|
|
83
|
+
const untagged = [];
|
|
84
|
+
for (;;) {
|
|
85
|
+
const reply = await nextLine();
|
|
86
|
+
if (reply.startsWith("+ ") || reply === "+") {
|
|
87
|
+
(onContinue ?? (() => socket.write("\r\n")))();
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (reply.startsWith(`${tag} `)) {
|
|
91
|
+
const [status = "", ...rest] = reply.slice(tag.length + 1).split(" ");
|
|
92
|
+
return { status: status.toUpperCase(), text: rest.join(" "), untagged };
|
|
93
|
+
}
|
|
94
|
+
if (reply.startsWith("* "))
|
|
95
|
+
untagged.push(reply);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
try {
|
|
99
|
+
await nextLine(); // The server greets first; nothing in it matters here.
|
|
100
|
+
const auth = await command(`AUTHENTICATE XOAUTH2 ${xoauth2(options.user, options.accessToken)}`);
|
|
101
|
+
if (auth.status !== "OK") {
|
|
102
|
+
throw new Error(`${options.user} was refused: ${auth.text || auth.status}. ` +
|
|
103
|
+
"Sign in again if the stored grant has been revoked.");
|
|
104
|
+
}
|
|
105
|
+
const literal = Buffer.from(options.message, "utf8");
|
|
106
|
+
const append = (folder) => command(`APPEND ${quoteMailbox(folder)} (\\Draft \\Seen) {${literal.length}}`, () => {
|
|
107
|
+
socket.write(literal);
|
|
108
|
+
socket.write("\r\n");
|
|
109
|
+
});
|
|
110
|
+
let placed = await append("Drafts");
|
|
111
|
+
if (placed.status !== "OK") {
|
|
112
|
+
// Exchange names the folder in the mailbox's own language; the
|
|
113
|
+
// attribute in LIST is the stable way to find it.
|
|
114
|
+
const list = await command('LIST "" "*"');
|
|
115
|
+
const drafts = draftsFromList(list.untagged);
|
|
116
|
+
if (drafts && drafts !== "Drafts")
|
|
117
|
+
placed = await append(drafts);
|
|
118
|
+
if (placed.status !== "OK") {
|
|
119
|
+
throw new Error(`APPEND was refused: ${placed.text || placed.status}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
await command("LOGOUT");
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
socket.destroy();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What one request to the listener means. `null` is a stray — a favicon
|
|
3
|
+
* probe, a health check — and the server keeps waiting; a wrong or missing
|
|
4
|
+
* state is also a stray, because rejecting the whole sign-in over a request
|
|
5
|
+
* an attacker could send unauthenticated would hand them a denial of
|
|
6
|
+
* service instead.
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseCallback(requestUrl: string, state: string): {
|
|
9
|
+
code: string;
|
|
10
|
+
} | Error | null;
|
|
11
|
+
export interface Loopback {
|
|
12
|
+
port: number;
|
|
13
|
+
/** Resolves with the code, or rejects with what the identity page said. */
|
|
14
|
+
code: Promise<string>;
|
|
15
|
+
close(): void;
|
|
16
|
+
}
|
|
17
|
+
/** Throws when it cannot bind, which is the caller's cue to fall back. */
|
|
18
|
+
export declare function startLoopback(state: string): Promise<Loopback>;
|
|
19
|
+
/**
|
|
20
|
+
* Best effort only: the URL is always printed too, so a machine with no
|
|
21
|
+
* opener — SSH, a container, a WSL distro without wslview — costs one click
|
|
22
|
+
* on the printed line instead of a failure.
|
|
23
|
+
*/
|
|
24
|
+
export declare function openInBrowser(url: string): void;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A throwaway HTTP server that catches one OAuth redirect and dies.
|
|
3
|
+
*
|
|
4
|
+
* Entra ignores the port when matching a localhost redirect URI, so the
|
|
5
|
+
* sign-in can come home to `http://localhost:{ephemeral}/` even though
|
|
6
|
+
* Thunderbird registered no port. Bound to 127.0.0.1 only — the redirect
|
|
7
|
+
* never leaves the machine, and nothing off it should reach the listener.
|
|
8
|
+
* The `state` nonce ties the callback to this run: any other process that
|
|
9
|
+
* finds the port cannot feed the flow a code it did not mint.
|
|
10
|
+
*/
|
|
11
|
+
import { createServer } from "node:http";
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
/**
|
|
14
|
+
* What one request to the listener means. `null` is a stray — a favicon
|
|
15
|
+
* probe, a health check — and the server keeps waiting; a wrong or missing
|
|
16
|
+
* state is also a stray, because rejecting the whole sign-in over a request
|
|
17
|
+
* an attacker could send unauthenticated would hand them a denial of
|
|
18
|
+
* service instead.
|
|
19
|
+
*/
|
|
20
|
+
export function parseCallback(requestUrl, state) {
|
|
21
|
+
const url = new URL(requestUrl, "http://localhost");
|
|
22
|
+
if (url.pathname !== "/")
|
|
23
|
+
return null;
|
|
24
|
+
if (url.searchParams.get("state") !== state)
|
|
25
|
+
return null;
|
|
26
|
+
const error = url.searchParams.get("error");
|
|
27
|
+
if (error) {
|
|
28
|
+
return new Error(url.searchParams.get("error_description") ?? error);
|
|
29
|
+
}
|
|
30
|
+
const code = url.searchParams.get("code");
|
|
31
|
+
return code ? { code } : null;
|
|
32
|
+
}
|
|
33
|
+
const LANDING = `<!doctype html>
|
|
34
|
+
<meta charset="utf-8">
|
|
35
|
+
<title>DevDogs devtools</title>
|
|
36
|
+
<body style="font-family: system-ui; display: grid; place-items: center; min-height: 90vh; background: #13121b; color: #f3f1f6">
|
|
37
|
+
<p style="max-width: 36rem; text-align: center">%MESSAGE% You can close this tab; the rest happens in the terminal.</p>
|
|
38
|
+
`;
|
|
39
|
+
/** Throws when it cannot bind, which is the caller's cue to fall back. */
|
|
40
|
+
export function startLoopback(state) {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
// Assigned synchronously by the executor below, which TS cannot see.
|
|
43
|
+
let settle;
|
|
44
|
+
const code = new Promise((resolveCode, rejectCode) => {
|
|
45
|
+
settle = { resolve: resolveCode, reject: rejectCode };
|
|
46
|
+
});
|
|
47
|
+
// A rejection can land in the gap before the caller awaits; this extra
|
|
48
|
+
// no-op handler marks it handled without eating it for the real awaiter.
|
|
49
|
+
code.catch(() => { });
|
|
50
|
+
const server = createServer((request, response) => {
|
|
51
|
+
const result = parseCallback(request.url ?? "/", state);
|
|
52
|
+
if (result === null) {
|
|
53
|
+
response.writeHead(404).end();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const failed = result instanceof Error;
|
|
57
|
+
response
|
|
58
|
+
.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
|
59
|
+
.end(LANDING.replace("%MESSAGE%", failed ? "The sign-in did not go through." : "Signed in."));
|
|
60
|
+
if (failed)
|
|
61
|
+
settle.reject(result);
|
|
62
|
+
else
|
|
63
|
+
settle.resolve(result.code);
|
|
64
|
+
});
|
|
65
|
+
server.on("error", reject);
|
|
66
|
+
server.listen(0, "127.0.0.1", () => {
|
|
67
|
+
const address = server.address();
|
|
68
|
+
if (!address || typeof address === "string") {
|
|
69
|
+
server.close();
|
|
70
|
+
reject(new Error("The loopback server bound without a port."));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
resolve({
|
|
74
|
+
port: address.port,
|
|
75
|
+
code,
|
|
76
|
+
close: () => server.close(),
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Best effort only: the URL is always printed too, so a machine with no
|
|
83
|
+
* opener — SSH, a container, a WSL distro without wslview — costs one click
|
|
84
|
+
* on the printed line instead of a failure.
|
|
85
|
+
*/
|
|
86
|
+
export function openInBrowser(url) {
|
|
87
|
+
const [command, args] = process.platform === "win32"
|
|
88
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
89
|
+
: process.platform === "darwin"
|
|
90
|
+
? ["open", [url]]
|
|
91
|
+
: ["xdg-open", [url]];
|
|
92
|
+
try {
|
|
93
|
+
spawn(command, args, { stdio: "ignore", detached: true })
|
|
94
|
+
.on("error", () => { })
|
|
95
|
+
.unref();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// The printed URL is the real interface; this was only a convenience.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** The fallback redirect when no loopback server could bind. */
|
|
2
|
+
export declare const PASTE_REDIRECT_URI = "https://localhost";
|
|
3
|
+
export declare function authorizeUrl(mailbox: string, redirectUri: string, state?: string): string;
|
|
4
|
+
/**
|
|
5
|
+
* The code out of whatever the officer pastes: the whole `https://localhost/?
|
|
6
|
+
* code=…` address (the intended gesture — the browser shows a dead page and
|
|
7
|
+
* they copy its location) or the bare code itself.
|
|
8
|
+
*/
|
|
9
|
+
export declare function codeFromRedirect(pasted: string): string | Error;
|
|
10
|
+
export interface MailboxTokens {
|
|
11
|
+
accessToken: string;
|
|
12
|
+
refreshToken: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function redeemCode(code: string, redirectUri: string): Promise<MailboxTokens>;
|
|
15
|
+
export declare function refreshTokens(refreshToken: string): Promise<MailboxTokens>;
|
|
16
|
+
export interface StoredGrant {
|
|
17
|
+
mailbox: string;
|
|
18
|
+
refreshToken: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Outside the repo on purpose: the refresh token opens the club mailbox, and
|
|
22
|
+
* nothing that opens the club mailbox belongs anywhere `git add` can reach.
|
|
23
|
+
*/
|
|
24
|
+
export declare function grantPath(): string;
|
|
25
|
+
export declare function readGrant(): Promise<StoredGrant | null>;
|
|
26
|
+
export declare function writeGrant(grant: StoredGrant): Promise<void>;
|