@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.
Files changed (253) hide show
  1. package/README.md +44 -0
  2. package/bin/devtools-ci-bare.mjs +48 -0
  3. package/bin/devtools-ci.mjs +17 -0
  4. package/bin/devtools.mjs +27 -0
  5. package/dist/args.d.ts +26 -0
  6. package/dist/args.js +78 -0
  7. package/dist/bws/bw.d.ts +9 -0
  8. package/dist/bws/bw.js +49 -0
  9. package/dist/bws/client.d.ts +43 -0
  10. package/dist/bws/client.js +363 -0
  11. package/dist/bws/environments.d.ts +96 -0
  12. package/dist/bws/environments.js +111 -0
  13. package/dist/bws/pace.d.ts +27 -0
  14. package/dist/bws/pace.js +43 -0
  15. package/dist/bws/retry.d.ts +12 -0
  16. package/dist/bws/retry.js +39 -0
  17. package/dist/bws/store.d.ts +1 -0
  18. package/dist/bws/store.js +32 -0
  19. package/dist/bws/token.d.ts +31 -0
  20. package/dist/bws/token.js +88 -0
  21. package/dist/bws/vault.d.ts +29 -0
  22. package/dist/bws/vault.js +200 -0
  23. package/dist/catalog.d.ts +55 -0
  24. package/dist/catalog.js +81 -0
  25. package/dist/cf/commands.d.ts +1 -0
  26. package/dist/cf/commands.js +182 -0
  27. package/dist/cf/local-env.d.ts +44 -0
  28. package/dist/cf/local-env.js +148 -0
  29. package/dist/ci.d.ts +1 -0
  30. package/dist/ci.js +319 -0
  31. package/dist/cli.d.ts +11 -0
  32. package/dist/cli.js +1077 -0
  33. package/dist/commands.d.ts +255 -0
  34. package/dist/commands.js +1279 -0
  35. package/dist/completions.d.ts +4 -0
  36. package/dist/completions.js +110 -0
  37. package/dist/cron/commands.d.ts +47 -0
  38. package/dist/cron/commands.js +372 -0
  39. package/dist/cron/discovery.d.ts +55 -0
  40. package/dist/cron/discovery.js +85 -0
  41. package/dist/cron/schema.d.ts +45 -0
  42. package/dist/cron/schema.js +35 -0
  43. package/dist/db/config-push.d.ts +8 -0
  44. package/dist/db/config-push.js +11 -0
  45. package/dist/db/connection.d.ts +79 -0
  46. package/dist/db/connection.js +114 -0
  47. package/dist/db/exec.d.ts +1 -0
  48. package/dist/db/exec.js +11 -0
  49. package/dist/db/generate-migration.d.ts +1 -0
  50. package/dist/db/generate-migration.js +67 -0
  51. package/dist/db/generate-types.d.ts +1 -0
  52. package/dist/db/generate-types.js +4 -0
  53. package/dist/db/introspect.d.ts +1 -0
  54. package/dist/db/introspect.js +113 -0
  55. package/dist/db/local-env.d.ts +65 -0
  56. package/dist/db/local-env.js +23 -0
  57. package/dist/db/new-migration.d.ts +1 -0
  58. package/dist/db/new-migration.js +8 -0
  59. package/dist/db/run.d.ts +70 -0
  60. package/dist/db/run.js +158 -0
  61. package/dist/db/seed-buckets.d.ts +2 -0
  62. package/dist/db/seed-buckets.js +4 -0
  63. package/dist/db/seed-production.d.ts +8 -0
  64. package/dist/db/seed-production.js +73 -0
  65. package/dist/db/seed-roles.d.ts +7 -0
  66. package/dist/db/seed-roles.js +31 -0
  67. package/dist/db/session-refresh.d.ts +43 -0
  68. package/dist/db/session-refresh.js +40 -0
  69. package/dist/deploy/migrations.d.ts +20 -0
  70. package/dist/deploy/migrations.js +68 -0
  71. package/dist/deploy/orphans.d.ts +20 -0
  72. package/dist/deploy/orphans.js +177 -0
  73. package/dist/deploy/preflight.d.ts +53 -0
  74. package/dist/deploy/preflight.js +189 -0
  75. package/dist/deploy/report.d.ts +26 -0
  76. package/dist/deploy/report.js +70 -0
  77. package/dist/deploy/require-planner.d.ts +2 -0
  78. package/dist/deploy/require-planner.js +60 -0
  79. package/dist/deploy/require-token.d.ts +15 -0
  80. package/dist/deploy/require-token.js +78 -0
  81. package/dist/deploy/secrets-file.d.ts +17 -0
  82. package/dist/deploy/secrets-file.js +167 -0
  83. package/dist/deploy/write-env.d.ts +40 -0
  84. package/dist/deploy/write-env.js +393 -0
  85. package/dist/docs/index-pages.d.ts +36 -0
  86. package/dist/docs/index-pages.js +184 -0
  87. package/dist/doctor.d.ts +62 -0
  88. package/dist/doctor.js +225 -0
  89. package/dist/emails/commands.d.ts +15 -0
  90. package/dist/emails/commands.js +130 -0
  91. package/dist/emails/email-types.d.ts +13 -0
  92. package/dist/emails/email-types.js +1 -0
  93. package/dist/emails/fixtures.d.ts +20 -0
  94. package/dist/emails/fixtures.js +20 -0
  95. package/dist/env/audit.d.ts +206 -0
  96. package/dist/env/audit.js +436 -0
  97. package/dist/env/cloudflare.d.ts +21 -0
  98. package/dist/env/cloudflare.js +61 -0
  99. package/dist/env/commands.d.ts +99 -0
  100. package/dist/env/commands.js +612 -0
  101. package/dist/env/discovery.d.ts +15 -0
  102. package/dist/env/discovery.js +226 -0
  103. package/dist/env/document.d.ts +119 -0
  104. package/dist/env/document.js +323 -0
  105. package/dist/env/example.d.ts +68 -0
  106. package/dist/env/example.js +603 -0
  107. package/dist/env/selection.d.ts +105 -0
  108. package/dist/env/selection.js +226 -0
  109. package/dist/environment.d.ts +92 -0
  110. package/dist/environment.js +206 -0
  111. package/dist/fingerprint.d.ts +10 -0
  112. package/dist/fingerprint.js +18 -0
  113. package/dist/gen/campus-map.d.ts +1 -0
  114. package/dist/gen/campus-map.js +473 -0
  115. package/dist/gen/commands.d.ts +1 -0
  116. package/dist/gen/commands.js +25 -0
  117. package/dist/gen/hypno.d.ts +1 -0
  118. package/dist/gen/hypno.js +134 -0
  119. package/dist/gh/client.d.ts +80 -0
  120. package/dist/gh/client.js +258 -0
  121. package/dist/gh/environments.d.ts +83 -0
  122. package/dist/gh/environments.js +178 -0
  123. package/dist/gh/rulesets/actors.d.ts +14 -0
  124. package/dist/gh/rulesets/actors.js +68 -0
  125. package/dist/gh/rulesets/api.d.ts +15 -0
  126. package/dist/gh/rulesets/api.js +99 -0
  127. package/dist/gh/rulesets/commands.d.ts +3 -0
  128. package/dist/gh/rulesets/commands.js +203 -0
  129. package/dist/gh/rulesets/desired.d.ts +112 -0
  130. package/dist/gh/rulesets/desired.js +247 -0
  131. package/dist/gh/rulesets/diff.d.ts +65 -0
  132. package/dist/gh/rulesets/diff.js +134 -0
  133. package/dist/gh/rulesets/types.d.ts +76 -0
  134. package/dist/gh/rulesets/types.js +13 -0
  135. package/dist/gh/settings/api.d.ts +40 -0
  136. package/dist/gh/settings/api.js +172 -0
  137. package/dist/gh/settings/commands.d.ts +1 -0
  138. package/dist/gh/settings/commands.js +245 -0
  139. package/dist/gh/settings/desired.d.ts +53 -0
  140. package/dist/gh/settings/desired.js +39 -0
  141. package/dist/gh/settings/diff.d.ts +57 -0
  142. package/dist/gh/settings/diff.js +156 -0
  143. package/dist/gh/settings/types.d.ts +92 -0
  144. package/dist/gh/settings/types.js +11 -0
  145. package/dist/gh/settings/workflows.d.ts +41 -0
  146. package/dist/gh/settings/workflows.js +124 -0
  147. package/dist/grantRoot.d.ts +42 -0
  148. package/dist/grantRoot.js +89 -0
  149. package/dist/help.d.ts +18 -0
  150. package/dist/help.js +169 -0
  151. package/dist/images/commands.d.ts +35 -0
  152. package/dist/images/commands.js +233 -0
  153. package/dist/images/events.d.ts +36 -0
  154. package/dist/images/events.js +152 -0
  155. package/dist/images/graphics.d.ts +70 -0
  156. package/dist/images/graphics.js +233 -0
  157. package/dist/images/open-graph-types.d.ts +75 -0
  158. package/dist/images/open-graph-types.js +1 -0
  159. package/dist/images/prompts.d.ts +34 -0
  160. package/dist/images/prompts.js +100 -0
  161. package/dist/images/render.d.ts +21 -0
  162. package/dist/images/render.js +57 -0
  163. package/dist/images/select.d.ts +64 -0
  164. package/dist/images/select.js +96 -0
  165. package/dist/instance.d.ts +67 -0
  166. package/dist/instance.js +129 -0
  167. package/dist/invocation.d.ts +49 -0
  168. package/dist/invocation.js +78 -0
  169. package/dist/launch-ci.d.ts +1 -0
  170. package/dist/launch-ci.js +86 -0
  171. package/dist/launch.d.ts +29 -0
  172. package/dist/launch.js +371 -0
  173. package/dist/menu.d.ts +31 -0
  174. package/dist/menu.js +277 -0
  175. package/dist/newsletter/commands.d.ts +16 -0
  176. package/dist/newsletter/commands.js +311 -0
  177. package/dist/newsletter/imap.d.ts +17 -0
  178. package/dist/newsletter/imap.js +127 -0
  179. package/dist/newsletter/loopback.d.ts +24 -0
  180. package/dist/newsletter/loopback.js +100 -0
  181. package/dist/newsletter/oauth.d.ts +26 -0
  182. package/dist/newsletter/oauth.js +130 -0
  183. package/dist/newsletter/smtp.d.ts +23 -0
  184. package/dist/newsletter/smtp.js +133 -0
  185. package/dist/oauth/browser.d.ts +1 -0
  186. package/dist/oauth/browser.js +13 -0
  187. package/dist/oauth/config.d.ts +24 -0
  188. package/dist/oauth/config.js +24 -0
  189. package/dist/oauth/connect-url.d.ts +17 -0
  190. package/dist/oauth/connect-url.js +23 -0
  191. package/dist/oauth/db.d.ts +37 -0
  192. package/dist/oauth/db.js +108 -0
  193. package/dist/oauth/device.d.ts +93 -0
  194. package/dist/oauth/device.js +190 -0
  195. package/dist/oauth/discovery.d.ts +28 -0
  196. package/dist/oauth/discovery.js +54 -0
  197. package/dist/oauth/env-file.d.ts +6 -0
  198. package/dist/oauth/env-file.js +35 -0
  199. package/dist/oauth/exchange.d.ts +43 -0
  200. package/dist/oauth/exchange.js +87 -0
  201. package/dist/oauth/label.d.ts +1 -0
  202. package/dist/oauth/label.js +25 -0
  203. package/dist/oauth/loopback.d.ts +52 -0
  204. package/dist/oauth/loopback.js +162 -0
  205. package/dist/oauth/pkce.d.ts +6 -0
  206. package/dist/oauth/pkce.js +34 -0
  207. package/dist/oauth/target.d.ts +54 -0
  208. package/dist/oauth/target.js +102 -0
  209. package/dist/oauth/transport.d.ts +41 -0
  210. package/dist/oauth/transport.js +32 -0
  211. package/dist/oauth/wizard.d.ts +33 -0
  212. package/dist/oauth/wizard.js +571 -0
  213. package/dist/pick.d.ts +6 -0
  214. package/dist/pick.js +64 -0
  215. package/dist/planner/checks.d.ts +26 -0
  216. package/dist/planner/checks.js +104 -0
  217. package/dist/planner/commands.d.ts +10 -0
  218. package/dist/planner/commands.js +367 -0
  219. package/dist/planner/db.d.ts +15 -0
  220. package/dist/planner/db.js +33 -0
  221. package/dist/planner/role.d.ts +33 -0
  222. package/dist/planner/role.js +68 -0
  223. package/dist/repo/peers.d.ts +24 -0
  224. package/dist/repo/peers.js +114 -0
  225. package/dist/repo/preflight.d.ts +109 -0
  226. package/dist/repo/preflight.js +266 -0
  227. package/dist/repo/resolve.d.ts +41 -0
  228. package/dist/repo/resolve.js +152 -0
  229. package/dist/repo/root.d.ts +23 -0
  230. package/dist/repo/root.js +91 -0
  231. package/dist/repo/source.d.ts +14 -0
  232. package/dist/repo/source.js +54 -0
  233. package/dist/repo/tsx-loader.d.ts +6 -0
  234. package/dist/repo/tsx-loader.js +41 -0
  235. package/dist/run/pick.d.ts +60 -0
  236. package/dist/run/pick.js +559 -0
  237. package/dist/setup.d.ts +1 -0
  238. package/dist/setup.js +152 -0
  239. package/dist/stack.d.ts +48 -0
  240. package/dist/stack.js +290 -0
  241. package/dist/telemetry.d.ts +34 -0
  242. package/dist/telemetry.js +93 -0
  243. package/dist/tier.d.ts +40 -0
  244. package/dist/tier.js +93 -0
  245. package/dist/tsconfig.typecheck.tsbuildinfo +1 -0
  246. package/dist/ui.d.ts +25 -0
  247. package/dist/ui.js +51 -0
  248. package/dist/workers.d.ts +5 -0
  249. package/dist/workers.js +35 -0
  250. package/dist/workflows/commands.d.ts +44 -0
  251. package/dist/workflows/commands.js +668 -0
  252. package/env.ts +158 -0
  253. package/package.json +80 -0
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Sign-in for the club mailbox, borrowing Thunderbird's app registration.
3
+ *
4
+ * UGA's tenant blocks user consent for every Microsoft Graph mail scope (the
5
+ * Microsoft-managed default policy), so the clean path — a Graph-created
6
+ * draft — needs an EITS-approved app registration. The same policy, though,
7
+ * allowlists a handful of mail clients by application ID for the legacy
8
+ * IMAP/SMTP scopes, Thunderbird among them. This module runs the standard
9
+ * authorization-code flow as that client: the officer signs in as the mailbox
10
+ * in a browser, and the refresh token is theirs — the same grant Thunderbird
11
+ * itself would hold. Presenting another client's ID is a documented
12
+ * convention in open-source mail tooling (mbsync, OfflineIMAP, DavMail), but
13
+ * it is Microsoft's allowlist, and this stops working the day they prune it.
14
+ *
15
+ * The redirect comes back two ways. Entra ignores the port when matching a
16
+ * localhost redirect URI, so the ordinary path is `loopback.ts`: a throwaway
17
+ * server on an ephemeral port that catches the code itself. When that server
18
+ * cannot bind, the flow falls back to Thunderbird's registered
19
+ * `https://localhost` — a dead page whose address bar holds the code, pasted
20
+ * back by hand.
21
+ *
22
+ * No PKCE and no client secret: Thunderbird is registered as a public client,
23
+ * and the code exchange happens entirely on this machine.
24
+ */
25
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
26
+ import { homedir } from "node:os";
27
+ import { dirname, join } from "node:path";
28
+ const CLIENT_ID = "9e5f94bc-e8a4-4e73-b8be-63364c29d753";
29
+ /** The fallback redirect when no loopback server could bind. */
30
+ export const PASTE_REDIRECT_URI = "https://localhost";
31
+ // IMAP places drafts; SMTP.Send is `--send`. Both are on the same consent
32
+ // allowlist for the borrowed client, so one grant covers both, and a stored
33
+ // IMAP-only grant upgrades silently on its next refresh.
34
+ const SCOPE = "https://outlook.office365.com/IMAP.AccessAsUser.All https://outlook.office365.com/SMTP.Send offline_access";
35
+ const AUTHORITY = "https://login.microsoftonline.com/organizations/oauth2/v2.0";
36
+ export function authorizeUrl(mailbox, redirectUri, state) {
37
+ const query = new URLSearchParams({
38
+ client_id: CLIENT_ID,
39
+ response_type: "code",
40
+ redirect_uri: redirectUri,
41
+ scope: SCOPE,
42
+ login_hint: mailbox,
43
+ ...(state ? { state } : {}),
44
+ });
45
+ return `${AUTHORITY}/authorize?${query}`;
46
+ }
47
+ /**
48
+ * The code out of whatever the officer pastes: the whole `https://localhost/?
49
+ * code=…` address (the intended gesture — the browser shows a dead page and
50
+ * they copy its location) or the bare code itself.
51
+ */
52
+ export function codeFromRedirect(pasted) {
53
+ const input = pasted.trim();
54
+ const error = /[?&#]error=([^&\s]+)/.exec(input);
55
+ if (error) {
56
+ const description = /[?&#]error_description=([^&\s]+)/.exec(input);
57
+ return new Error(description
58
+ ? decodeURIComponent(description[1].replace(/\+/g, " "))
59
+ : decodeURIComponent(error[1]));
60
+ }
61
+ const inUrl = /[?&#]code=([^&\s]+)/.exec(input);
62
+ if (inUrl)
63
+ return decodeURIComponent(inUrl[1]);
64
+ // Bare codes are opaque but never contain URL structure or whitespace.
65
+ if (input && !/[\s/?&=]/.test(input))
66
+ return input;
67
+ return new Error("That has no ?code= in it. Paste the whole address bar of the localhost page.");
68
+ }
69
+ async function tokenRequest(grant) {
70
+ const response = await fetch(`${AUTHORITY}/token`, {
71
+ method: "POST",
72
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
73
+ body: new URLSearchParams({
74
+ client_id: CLIENT_ID,
75
+ scope: SCOPE,
76
+ ...grant,
77
+ }),
78
+ });
79
+ const body = (await response.json());
80
+ if (!response.ok || !body.access_token || !body.refresh_token) {
81
+ throw new Error(body.error_description ??
82
+ body.error ??
83
+ `Token endpoint answered ${response.status}.`);
84
+ }
85
+ return { accessToken: body.access_token, refreshToken: body.refresh_token };
86
+ }
87
+ export function redeemCode(code, redirectUri) {
88
+ return tokenRequest({
89
+ grant_type: "authorization_code",
90
+ code,
91
+ // Must be byte-for-byte the redirect the code was issued against.
92
+ redirect_uri: redirectUri,
93
+ });
94
+ }
95
+ export function refreshTokens(refreshToken) {
96
+ return tokenRequest({
97
+ grant_type: "refresh_token",
98
+ refresh_token: refreshToken,
99
+ });
100
+ }
101
+ /**
102
+ * Outside the repo on purpose: the refresh token opens the club mailbox, and
103
+ * nothing that opens the club mailbox belongs anywhere `git add` can reach.
104
+ */
105
+ export function grantPath() {
106
+ const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
107
+ return join(configHome, "devdogsuga", "newsletter-mailbox.json");
108
+ }
109
+ export async function readGrant() {
110
+ try {
111
+ const parsed = JSON.parse(await readFile(grantPath(), "utf8"));
112
+ return typeof parsed.mailbox === "string" &&
113
+ typeof parsed.refreshToken === "string"
114
+ ? { mailbox: parsed.mailbox, refreshToken: parsed.refreshToken }
115
+ : null;
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
121
+ export async function writeGrant(grant) {
122
+ const path = grantPath();
123
+ await mkdir(dirname(path), { recursive: true });
124
+ await writeFile(path, `${JSON.stringify(grant, null, 2)}\n`, {
125
+ mode: 0o600,
126
+ });
127
+ // writeFile's mode only applies on create; an existing file keeps its old
128
+ // bits, so tighten explicitly on the rotation path too.
129
+ await chmod(path, 0o600);
130
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * RFC 5322 origination headers for a message built as a draft. `buildEml`
3
+ * deliberately emits none of these — draft-ness is their absence — so the
4
+ * send path prepends them. The submission server stamps `Message-ID`.
5
+ */
6
+ export declare function originationHeaders(from: string, to: readonly string[], date?: Date): string;
7
+ /** RFC 5321 §4.5.2 transparency: a line-leading `.` doubles inside DATA. */
8
+ export declare function dotStuff(message: string): string;
9
+ /**
10
+ * `true` when an SMTP reply line ends its reply: `250 done` rather than the
11
+ * `250-more coming` continuation form (RFC 5321 §4.2.1).
12
+ */
13
+ export declare function endsReply(line: string): boolean;
14
+ export declare function submitMessage(options: {
15
+ user: string;
16
+ accessToken: string;
17
+ /** The envelope sender — must be the authenticated mailbox on Exchange. */
18
+ from: string;
19
+ recipients: readonly string[];
20
+ /** The full MIME message, origination headers included, CRLF endings. */
21
+ message: string;
22
+ host?: string;
23
+ }): Promise<void>;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * One-shot SMTP submission: send a finished MIME message byte-for-byte.
3
+ *
4
+ * This exists because a draft cannot survive being sent from Outlook. The
5
+ * web Outlook composer is a rich-text editor with a style whitelist, and
6
+ * sending a pushed draft re-serializes whatever that editor kept: forensics
7
+ * on a received copy showed the head `<style>` replaced with Outlook's own,
8
+ * every `background-image` and `bgcolor` gone, and most class attributes
9
+ * dropped — all of the newsletter's dark-mode defenses, stripped in transit.
10
+ * (Classic Outlook mangles differently, into Word HTML, but just as surely.)
11
+ * Submitting over SMTP bypasses every composer: recipients get the authored
12
+ * document, stylesheet and all. Like `imap.ts`, this speaks just the sliver
13
+ * of the protocol the workflow needs — EHLO, STARTTLS, AUTH, one message.
14
+ */
15
+ import { createConnection } from "node:net";
16
+ import { connect as connectTls } from "node:tls";
17
+ import { xoauth2 } from "./imap.js";
18
+ const DEFAULT_HOST = "smtp.office365.com";
19
+ const TIMEOUT_MS = 30_000;
20
+ /**
21
+ * RFC 5322 origination headers for a message built as a draft. `buildEml`
22
+ * deliberately emits none of these — draft-ness is their absence — so the
23
+ * send path prepends them. The submission server stamps `Message-ID`.
24
+ */
25
+ export function originationHeaders(from, to, date = new Date()) {
26
+ return [
27
+ `From: <${from}>`,
28
+ `To: ${to.map((address) => `<${address}>`).join(", ")}`,
29
+ // toUTCString is RFC 5322's fixed-length date form, save the zone name.
30
+ `Date: ${date.toUTCString().replace(/GMT$/, "+0000")}`,
31
+ "",
32
+ ].join("\r\n");
33
+ }
34
+ /** RFC 5321 §4.5.2 transparency: a line-leading `.` doubles inside DATA. */
35
+ export function dotStuff(message) {
36
+ return message.replace(/(^|\r\n)\./g, "$1..");
37
+ }
38
+ /**
39
+ * `true` when an SMTP reply line ends its reply: `250 done` rather than the
40
+ * `250-more coming` continuation form (RFC 5321 §4.2.1).
41
+ */
42
+ export function endsReply(line) {
43
+ return /^\d{3}(?: |$)/.test(line);
44
+ }
45
+ export async function submitMessage(options) {
46
+ const host = options.host ?? DEFAULT_HOST;
47
+ if (!options.recipients.length)
48
+ throw new Error("No recipients.");
49
+ let buffer = "";
50
+ const pending = [];
51
+ let wake = null;
52
+ let failure = null;
53
+ const fail = (err) => {
54
+ failure ??= err;
55
+ socket.destroy();
56
+ wake?.();
57
+ };
58
+ /** (Re)binds the line reader — once to the TCP socket, again after TLS. */
59
+ const adopt = (next) => {
60
+ next.setTimeout(TIMEOUT_MS);
61
+ next.on("data", (chunk) => {
62
+ buffer += chunk.toString("utf8");
63
+ let index;
64
+ while ((index = buffer.indexOf("\r\n")) !== -1) {
65
+ pending.push(buffer.slice(0, index));
66
+ buffer = buffer.slice(index + 2);
67
+ }
68
+ wake?.();
69
+ });
70
+ next.on("error", (err) => fail(err));
71
+ next.on("timeout", () => fail(new Error(`${host} stopped answering.`)));
72
+ next.on("close", () => fail(new Error(`${host} closed the connection.`)));
73
+ return next;
74
+ };
75
+ let socket = adopt(createConnection({ host, port: 587 }));
76
+ const nextLine = async () => {
77
+ for (;;) {
78
+ if (pending.length)
79
+ return pending.shift();
80
+ if (failure)
81
+ throw failure;
82
+ await new Promise((resolvePromise) => {
83
+ wake = resolvePromise;
84
+ });
85
+ wake = null;
86
+ }
87
+ };
88
+ /** Reads one full reply; throws unless its code is in `expect`. */
89
+ const reply = async (expect, doing) => {
90
+ let line;
91
+ do {
92
+ line = await nextLine();
93
+ } while (!endsReply(line));
94
+ const code = Number(line.slice(0, 3));
95
+ if (!expect.includes(code)) {
96
+ throw new Error(`${doing}: ${line}`);
97
+ }
98
+ return line;
99
+ };
100
+ const command = (line, expect, doing) => {
101
+ socket.write(`${line}\r\n`);
102
+ return reply(expect, doing);
103
+ };
104
+ try {
105
+ await reply([220], `${host} refused the connection`);
106
+ await command(`EHLO [127.0.0.1]`, [250], "EHLO was refused");
107
+ await command("STARTTLS", [220], "STARTTLS was refused");
108
+ // The server speaks next only after the handshake, so nothing is in
109
+ // flight — but the plaintext socket's reader has to come off before the
110
+ // wrap, or it would keep pushing what is now ciphertext into the line
111
+ // buffer (and its close fires only when the whole connection does).
112
+ for (const event of ["data", "timeout", "close"]) {
113
+ socket.removeAllListeners(event);
114
+ }
115
+ socket.setTimeout(0);
116
+ socket = adopt(connectTls({ socket, servername: host }));
117
+ await command(`EHLO [127.0.0.1]`, [250], "EHLO after STARTTLS was refused");
118
+ await command(`AUTH XOAUTH2 ${xoauth2(options.user, options.accessToken)}`, [235], `${options.user} was refused. Sign in again if the stored grant lacks ` +
119
+ "SMTP.Send — grants from before --send existed only asked for IMAP");
120
+ await command(`MAIL FROM:<${options.from}>`, [250], `The sender ${options.from} was refused`);
121
+ for (const recipient of options.recipients) {
122
+ await command(`RCPT TO:<${recipient}>`, [250, 251], `The recipient ${recipient} was refused`);
123
+ }
124
+ await command("DATA", [354], "DATA was refused");
125
+ const body = dotStuff(options.message);
126
+ socket.write(body.endsWith("\r\n") ? body : `${body}\r\n`);
127
+ await command(".", [250], "The message was refused");
128
+ socket.write("QUIT\r\n");
129
+ }
130
+ finally {
131
+ socket.destroy();
132
+ }
133
+ }
@@ -0,0 +1 @@
1
+ export declare function openBrowser(url: string): void;
@@ -0,0 +1,13 @@
1
+ /** Opens `url` in the user's default browser, cross-platform. */
2
+ import { execFile } from "node:child_process";
3
+ export function openBrowser(url) {
4
+ if (process.platform === "win32") {
5
+ execFile("cmd", ["/c", "start", "", url]);
6
+ }
7
+ else if (process.platform === "darwin") {
8
+ execFile("open", [url]);
9
+ }
10
+ else {
11
+ execFile("xdg-open", [url]);
12
+ }
13
+ }
@@ -0,0 +1,24 @@
1
+ /** Default Supabase project that hosts the "Sign in with DevDogs" OAuth server. */
2
+ export declare const DEFAULT_API_URL = "https://api.devdogsuga.org";
3
+ /**
4
+ * DevDogs website — human-facing links, and (since TASK-351) the origin
5
+ * that serves the one-click connect flow's `/tools/oauth/connect` and
6
+ * `/tools/oauth/connect/exchange` endpoints. Overridable via `--platform-url`
7
+ * / `DEVDOGS_PLATFORM_URL` for local testing against a platform dev server —
8
+ * see `commands.ts`'s `oauth` node and `wizard.ts`.
9
+ */
10
+ export declare const WEBSITE_URL = "https://devdogsuga.org";
11
+ /**
12
+ * Custom OAuth/OIDC provider identifier. Supabase requires custom provider
13
+ * identifiers to be prefixed with "custom:".
14
+ */
15
+ export declare const PROVIDER_IDENTIFIER = "custom:devdogsuga";
16
+ export declare const PROVIDER_NAME = "DevDogs";
17
+ export declare const PROVIDER_SCOPES: string[];
18
+ export declare const ENV_KEYS: {
19
+ readonly baseUrl: "OAUTH_BASE_URL";
20
+ readonly providerName: "OAUTH_PROVIDER_NAME";
21
+ readonly clientId: "OAUTH_CLIENT_ID";
22
+ readonly clientSecret: "OAUTH_CLIENT_SECRET";
23
+ readonly platformUrl: "DEVDOGS_PLATFORM_URL";
24
+ };
@@ -0,0 +1,24 @@
1
+ /** Default Supabase project that hosts the "Sign in with DevDogs" OAuth server. */
2
+ export const DEFAULT_API_URL = "https://api.devdogsuga.org";
3
+ /**
4
+ * DevDogs website — human-facing links, and (since TASK-351) the origin
5
+ * that serves the one-click connect flow's `/tools/oauth/connect` and
6
+ * `/tools/oauth/connect/exchange` endpoints. Overridable via `--platform-url`
7
+ * / `DEVDOGS_PLATFORM_URL` for local testing against a platform dev server —
8
+ * see `commands.ts`'s `oauth` node and `wizard.ts`.
9
+ */
10
+ export const WEBSITE_URL = "https://devdogsuga.org";
11
+ /**
12
+ * Custom OAuth/OIDC provider identifier. Supabase requires custom provider
13
+ * identifiers to be prefixed with "custom:".
14
+ */
15
+ export const PROVIDER_IDENTIFIER = "custom:devdogsuga";
16
+ export const PROVIDER_NAME = "DevDogs";
17
+ export const PROVIDER_SCOPES = ["openid", "email", "profile"];
18
+ export const ENV_KEYS = {
19
+ baseUrl: "OAUTH_BASE_URL",
20
+ providerName: "OAUTH_PROVIDER_NAME",
21
+ clientId: "OAUTH_CLIENT_ID",
22
+ clientSecret: "OAUTH_CLIENT_SECRET",
23
+ platformUrl: "DEVDOGS_PLATFORM_URL",
24
+ };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Builds the platform's `/tools/oauth/connect` URL — the page a browser
3
+ * opens to approve a `devtools oauth` connection. See this repo's launch
4
+ * instructions for the fixed wire contract: `redirect_uri`, `code_challenge`
5
+ * (base64url SHA-256 of the PKCE verifier, no padding — `pkce.ts`),
6
+ * `code_challenge_method=S256`, `state`, `label` (1–100 characters), and
7
+ * `callback_uri` (the TARGET Supabase project's own `/auth/v1/callback` —
8
+ * NOT this loopback listener's URL; that is `redirect_uri`).
9
+ */
10
+ export declare function buildConnectUrl({ platformUrl, redirectUri, codeChallenge, state, label, callbackUri, }: {
11
+ platformUrl: string;
12
+ redirectUri: string;
13
+ codeChallenge: string;
14
+ state: string;
15
+ label: string;
16
+ callbackUri: string;
17
+ }): string;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Builds the platform's `/tools/oauth/connect` URL — the page a browser
3
+ * opens to approve a `devtools oauth` connection. See this repo's launch
4
+ * instructions for the fixed wire contract: `redirect_uri`, `code_challenge`
5
+ * (base64url SHA-256 of the PKCE verifier, no padding — `pkce.ts`),
6
+ * `code_challenge_method=S256`, `state`, `label` (1–100 characters), and
7
+ * `callback_uri` (the TARGET Supabase project's own `/auth/v1/callback` —
8
+ * NOT this loopback listener's URL; that is `redirect_uri`).
9
+ */
10
+ export function buildConnectUrl({ platformUrl, redirectUri, codeChallenge, state, label, callbackUri, }) {
11
+ const url = new URL(`${platformUrl.replace(/\/+$/, "")}/tools/oauth/connect`);
12
+ url.searchParams.set("redirect_uri", redirectUri);
13
+ url.searchParams.set("code_challenge", codeChallenge);
14
+ url.searchParams.set("code_challenge_method", "S256");
15
+ url.searchParams.set("state", state);
16
+ // The wire contract caps this at 100 characters; truncated here rather
17
+ // than left for the platform to reject, since the label is a cosmetic
18
+ // default (project name / directory name — see `label.ts`), not something
19
+ // worth failing the whole connect flow over.
20
+ url.searchParams.set("label", label.slice(0, 100));
21
+ url.searchParams.set("callback_uri", callbackUri);
22
+ return url.toString();
23
+ }
@@ -0,0 +1,37 @@
1
+ export type LocalSupabaseConfig = {
2
+ apiUrl: string;
3
+ serviceRoleKey: string;
4
+ };
5
+ /**
6
+ * Runs `supabase status -o env` in `cwd` and extracts the local API URL and
7
+ * service role key. Throws a descriptive error if Supabase isn't running or
8
+ * the CLI isn't installed.
9
+ *
10
+ * Two invocations are tried, because this wizard runs in two different worlds:
11
+ * a sibling project, where the Supabase CLI is normally installed globally, and
12
+ * this monorepo, where it is a workspace devDependency and not on PATH at all.
13
+ * A global install wins, since `pnpm exec` in a non-pnpm project would fail for
14
+ * reasons that have nothing to do with Supabase.
15
+ */
16
+ export declare function detectLocalSupabase(cwd: string): LocalSupabaseConfig;
17
+ /**
18
+ * Looks up a custom auth provider on a local Supabase instance through the
19
+ * Admin SDK, and reports whether it exists along with its name and issuer.
20
+ *
21
+ * A 404 means it does not exist yet. Any other error is real and rethrown.
22
+ */
23
+ export declare function checkProvider({ apiUrl, serviceRoleKey }: LocalSupabaseConfig, identifier: string): Promise<{
24
+ exists: boolean;
25
+ name?: string;
26
+ issuer?: string;
27
+ }>;
28
+ export declare function upsertDevDogsProvider({ apiUrl, serviceRoleKey }: LocalSupabaseConfig, { identifier, name, clientId, clientSecret, issuer, }: {
29
+ identifier: string;
30
+ name: string;
31
+ clientId: string;
32
+ clientSecret: string;
33
+ issuer: string;
34
+ }): Promise<{
35
+ identifier: string;
36
+ issuer: string;
37
+ }>;
@@ -0,0 +1,108 @@
1
+ import { createClient } from "@supabase/supabase-js";
2
+ import { execFileSync } from "node:child_process";
3
+ import { parse } from "dotenv";
4
+ import { PROVIDER_SCOPES } from "./config.js";
5
+ function runStatus(argv, cwd) {
6
+ return execFileSync(argv[0], argv.slice(1), {
7
+ cwd,
8
+ encoding: "utf-8",
9
+ stdio: ["pipe", "pipe", "pipe"],
10
+ });
11
+ }
12
+ /**
13
+ * Runs `supabase status -o env` in `cwd` and extracts the local API URL and
14
+ * service role key. Throws a descriptive error if Supabase isn't running or
15
+ * the CLI isn't installed.
16
+ *
17
+ * Two invocations are tried, because this wizard runs in two different worlds:
18
+ * a sibling project, where the Supabase CLI is normally installed globally, and
19
+ * this monorepo, where it is a workspace devDependency and not on PATH at all.
20
+ * A global install wins, since `pnpm exec` in a non-pnpm project would fail for
21
+ * reasons that have nothing to do with Supabase.
22
+ */
23
+ export function detectLocalSupabase(cwd) {
24
+ let output;
25
+ try {
26
+ output = runStatus(["supabase", "status", "-o", "env"], cwd);
27
+ }
28
+ catch (first) {
29
+ try {
30
+ output = runStatus(["pnpm", "exec", "supabase", "status", "-o", "env"], cwd);
31
+ }
32
+ catch {
33
+ // The first failure is the one worth reporting: it is the one that says
34
+ // "not installed" rather than "pnpm could not find a workspace".
35
+ if (first.code === "ENOENT") {
36
+ throw new Error("supabase CLI not found — install it from https://supabase.com/docs/guides/cli");
37
+ }
38
+ const stderr = first.stderr?.trim();
39
+ throw new Error(stderr || (first instanceof Error ? first.message : String(first)));
40
+ }
41
+ }
42
+ const env = parse(output);
43
+ const apiUrl = env["API_URL"];
44
+ const serviceRoleKey = env["SERVICE_ROLE_KEY"];
45
+ if (!apiUrl || !serviceRoleKey) {
46
+ throw new Error("Could not find API_URL or SERVICE_ROLE_KEY in `supabase status` output");
47
+ }
48
+ return { apiUrl, serviceRoleKey };
49
+ }
50
+ /**
51
+ * Looks up a custom auth provider on a local Supabase instance through the
52
+ * Admin SDK, and reports whether it exists along with its name and issuer.
53
+ *
54
+ * A 404 means it does not exist yet. Any other error is real and rethrown.
55
+ */
56
+ export async function checkProvider({ apiUrl, serviceRoleKey }, identifier) {
57
+ const supabase = createClient(apiUrl, serviceRoleKey, {
58
+ auth: { autoRefreshToken: false, persistSession: false },
59
+ });
60
+ const { data, error } = await supabase.auth.admin.customProviders.getProvider(identifier);
61
+ if (error && error.status !== 404)
62
+ throw error;
63
+ return {
64
+ exists: !!data,
65
+ name: data?.name ?? undefined,
66
+ issuer: data?.issuer ?? undefined,
67
+ };
68
+ }
69
+ export async function upsertDevDogsProvider({ apiUrl, serviceRoleKey }, { identifier, name, clientId, clientSecret, issuer, }) {
70
+ const supabase = createClient(apiUrl, serviceRoleKey, {
71
+ auth: { autoRefreshToken: false, persistSession: false },
72
+ });
73
+ const { data: existing, error: getError } = await supabase.auth.admin.customProviders.getProvider(identifier);
74
+ // 404 just means the provider doesn't exist yet. Any other error is real.
75
+ if (getError && getError.status !== 404) {
76
+ throw getError;
77
+ }
78
+ if (existing) {
79
+ const { data, error } = await supabase.auth.admin.customProviders.updateProvider(identifier, {
80
+ name,
81
+ client_id: clientId,
82
+ client_secret: clientSecret,
83
+ issuer,
84
+ scopes: PROVIDER_SCOPES,
85
+ enabled: true,
86
+ });
87
+ if (error)
88
+ throw error;
89
+ if (!data)
90
+ throw new Error("updateProvider returned no data");
91
+ return { identifier: data.identifier, issuer: data.issuer ?? issuer };
92
+ }
93
+ const { data, error } = await supabase.auth.admin.customProviders.createProvider({
94
+ provider_type: "oidc",
95
+ identifier,
96
+ name,
97
+ client_id: clientId,
98
+ client_secret: clientSecret,
99
+ issuer,
100
+ scopes: PROVIDER_SCOPES,
101
+ enabled: true,
102
+ });
103
+ if (error)
104
+ throw error;
105
+ if (!data)
106
+ throw new Error("createProvider returned no data");
107
+ return { identifier: data.identifier, issuer: data.issuer ?? issuer };
108
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The device-code fallback for the one-click connect flow (TASK-352): for a
3
+ * contributor whose terminal cannot open a browser tab that can complete a
4
+ * loopback redirect back to THIS machine — SSH, a Codespace, a dev container,
5
+ * or any environment where the loopback listener itself fails to start (see
6
+ * `transport.ts`) — this trades a short user code, approved in a browser
7
+ * running anywhere, for the same OAuth client credentials `exchange.ts`
8
+ * hands back. The server side of this (TASK-352) lives in the DevDogsUGA
9
+ * repo, at `apps/platform/src/app/(site)/tools/oauth/device/` — this is the
10
+ * fixed wire contract the two sides agreed on:
11
+ *
12
+ * POST <platform>/tools/oauth/device/code
13
+ * { "label", "callback_uri" }
14
+ *
15
+ * 200 { "device_code", "user_code", "verification_uri",
16
+ * "verification_uri_complete", "expires_in", "interval" }
17
+ * 400 { "error": "invalid_request", "error_description" }
18
+ * 429 { "error": "rate_limited" } (+ Retry-After header)
19
+ *
20
+ * POST <platform>/tools/oauth/device/token
21
+ * { "device_code" }
22
+ *
23
+ * 200 { "client_id", "client_secret", "issuer" }
24
+ * 400 { "error": "authorization_pending" | "slow_down" | "access_denied" |
25
+ * "expired_token" | "invalid_grant" | "invalid_request",
26
+ * "error_description" }
27
+ * 429 { "error": "rate_limited" } (+ Retry-After header)
28
+ *
29
+ * `requestDeviceCode` mints the code; `pollForToken` polls `.../token` on
30
+ * the server's own cadence until it settles one way or the other. Both take
31
+ * an injectable `fetchImpl` so tests never touch the network, and
32
+ * `pollForToken` additionally takes injectable `sleep`/`now` so tests never
33
+ * touch the wall clock either.
34
+ */
35
+ import type { ExchangeResult } from "./exchange.js";
36
+ export type DeviceErrorKind = "invalid_request" | "rate_limited" | "network" | "malformed";
37
+ export declare class DeviceError extends Error {
38
+ readonly kind: DeviceErrorKind;
39
+ readonly retryAfterSeconds?: number | undefined;
40
+ constructor(kind: DeviceErrorKind, message: string, retryAfterSeconds?: number | undefined);
41
+ }
42
+ export interface DeviceCodeResult {
43
+ deviceCode: string;
44
+ userCode: string;
45
+ verificationUri: string;
46
+ verificationUriComplete: string;
47
+ expiresIn: number;
48
+ interval: number;
49
+ }
50
+ /** The `/token` endpoint's terminal (non-retryable) outcomes, plus `network`/`malformed`. */
51
+ export type PollErrorKind = "access_denied" | "expired_token" | "invalid_grant" | "invalid_request" | "network" | "malformed";
52
+ export declare class PollError extends Error {
53
+ readonly kind: PollErrorKind;
54
+ constructor(kind: PollErrorKind, message: string);
55
+ }
56
+ /**
57
+ * Requests a device code. Mirrors `exchange.ts`'s `exchangeCode` in shape:
58
+ * every failure is a distinct `DeviceError` kind, a 429 carries
59
+ * `Retry-After` when the platform sent one, and a 200 whose body is not
60
+ * valid JSON or is missing a required field is `malformed` rather than
61
+ * handing the caller `undefined` fields.
62
+ */
63
+ export declare function requestDeviceCode({ platformUrl, label, callbackUri, fetchImpl, }: {
64
+ platformUrl: string;
65
+ label: string;
66
+ callbackUri: string;
67
+ fetchImpl?: typeof fetch;
68
+ }): Promise<DeviceCodeResult>;
69
+ /**
70
+ * Polls `.../tools/oauth/device/token` on the server's cadence until the
71
+ * user approves (or denies) the request in their browser, the device code
72
+ * expires, or the server rejects it outright.
73
+ *
74
+ * `authorization_pending` keeps polling at the current interval;
75
+ * `slow_down` adds 5 seconds to it and keeps polling (per the wire
76
+ * contract); a 429 waits out `Retry-After` (or the current interval, if the
77
+ * platform sent none) and keeps polling; every other error is terminal.
78
+ * Local expiry (`now() - start >= expiresInSeconds`) is reported the same
79
+ * way as the server's own `expired_token` — the caller does not need to
80
+ * tell the two apart.
81
+ *
82
+ * A network failure while polling retries with backoff up to
83
+ * `MAX_NETWORK_RETRIES` times before it is reported as `PollError("network")`.
84
+ */
85
+ export declare function pollForToken({ platformUrl, deviceCode, intervalSeconds, expiresInSeconds, fetchImpl, sleep, now, }: {
86
+ platformUrl: string;
87
+ deviceCode: string;
88
+ intervalSeconds: number;
89
+ expiresInSeconds: number;
90
+ fetchImpl?: typeof fetch;
91
+ sleep?: (ms: number) => Promise<void>;
92
+ now?: () => number;
93
+ }): Promise<ExchangeResult>;