@devdogsuga/devtools 0.1.0 → 0.1.1

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.
@@ -0,0 +1,395 @@
1
+ /**
2
+ * `pnpm devtools doctor [--app <slug>] [--report]`
3
+ *
4
+ * The new environment checker. Entirely read-only: it inspects this machine
5
+ * and this checkout's `.env`, and for a hosted Supabase project, makes a
6
+ * couple of network calls to say whether it is reachable — never writes
7
+ * anything. `setup.ts`'s hosted wizard runs the same checks at the end of
8
+ * its own run (see its last step), so a contributor who set up through the
9
+ * wizard and one who ran `doctor` cold see the same report.
10
+ *
11
+ * Every check carries a stable FAQ id from the docs contract's list (see
12
+ * `docs/_shared/getting-started/troubleshooting.md` once written) so a
13
+ * failure message can point at
14
+ * `https://devdogsuga.org/docs/<app>/getting-started/troubleshooting#<id>`
15
+ * rather than explaining itself twice.
16
+ *
17
+ * Pure classification lives in small exported functions so this file's own
18
+ * tests do not have to fake `child_process`/`fs` to prove the logic; the
19
+ * orchestrator (`runEnvironmentDoctor`) is the only part that actually
20
+ * touches the machine.
21
+ */
22
+ import { execFileSync } from "node:child_process";
23
+ import { existsSync, readFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { log, note } from "@clack/prompts";
26
+ import { parse as parseDotenv } from "dotenv";
27
+ import { discoverRepoRoot } from "./repo/root.js";
28
+ import { validateSessionPoolerUrl } from "./db/pooler.js";
29
+ const TROUBLESHOOTING_APPS = ["schedule-builder", "study-group-finder", "platform"];
30
+ function faqUrl(app, id) {
31
+ return `https://devdogsuga.org/docs/${app}/getting-started/troubleshooting#${id}`;
32
+ }
33
+ // ── Pure checks ──────────────────────────────────────────────────────────────
34
+ /** The floor is 22.12: pnpm 11 needs `node:sqlite`, stable there. `.nvmrc`
35
+ * pins 24, which is what a `fnm use` picks up; a machine below the floor is
36
+ * a WARN, not an error — plenty of contributors' shells still resolve an
37
+ * old global `node` first. */
38
+ export function checkNodeVersion(nodeVersion, app) {
39
+ const [major = 0, minor = 0] = nodeVersion.split(".").map(Number);
40
+ const ok = major > 22 || (major === 22 && minor >= 12);
41
+ return {
42
+ id: "node-version",
43
+ status: ok ? "ok" : "warn",
44
+ summary: ok
45
+ ? `Node ${nodeVersion}`
46
+ : `Node ${nodeVersion} is below the floor (>= 22.12)`,
47
+ fix: ok
48
+ ? undefined
49
+ : "Install fnm, then `fnm use` in the repo (reads .nvmrc).",
50
+ faqId: ok ? undefined : "node-version",
51
+ };
52
+ }
53
+ export function checkFnmHookPresent(shellProfile, app) {
54
+ const present = Boolean(shellProfile?.includes("fnm env") || shellProfile?.includes("fnm_env"));
55
+ return {
56
+ id: "fnm-not-found",
57
+ status: present ? "ok" : "warn",
58
+ summary: present
59
+ ? "fnm hook found in shell profile"
60
+ : "No fnm hook found in your shell profile",
61
+ fix: present
62
+ ? undefined
63
+ : "Add fnm's shell hook (`fnm env --use-on-cd --shell <bash|zsh|powershell> | ...`) and restart your terminal.",
64
+ faqId: present ? undefined : "fnm-not-found",
65
+ };
66
+ }
67
+ export function checkPnpmVersion(pnpmVersion, pinned, app) {
68
+ if (!pnpmVersion) {
69
+ return {
70
+ id: "pnpm-missing",
71
+ status: "warn",
72
+ summary: "pnpm is not on PATH",
73
+ fix: '`npm install -g pnpm` (never `corepack enable` — this repo does not use corepack).',
74
+ faqId: "pnpm-missing",
75
+ };
76
+ }
77
+ const pinnedVersion = pinned.replace(/^pnpm@/, "").split("+")[0];
78
+ const ok = pnpmVersion === pinnedVersion;
79
+ return {
80
+ id: "pnpm-version",
81
+ status: ok ? "ok" : "warn",
82
+ summary: ok
83
+ ? `pnpm ${pnpmVersion}`
84
+ : `pnpm ${pnpmVersion} does not match the pinned ${pinnedVersion}`,
85
+ fix: ok
86
+ ? undefined
87
+ : "pnpm self-switches to the pinned version on its first run in the repo (pmOnFail=download). Run any pnpm command here again.",
88
+ faqId: ok ? undefined : "pnpm-version",
89
+ };
90
+ }
91
+ /** `git config core.autocrlf` — `input` or `false` is correct on every
92
+ * platform this repo documents (WSL2, native Windows, macOS, Linux); `true`
93
+ * rewrites LF to CRLF on checkout and breaks anything that shells out to a
94
+ * script with a shebang. */
95
+ export function checkAutocrlf(value) {
96
+ const ok = value === "input" || value === "false" || value === null;
97
+ return {
98
+ id: "crlf-line-endings",
99
+ status: ok ? "ok" : "warn",
100
+ summary: ok
101
+ ? "core.autocrlf is not rewriting line endings"
102
+ : `core.autocrlf=${value} rewrites LF to CRLF on checkout`,
103
+ fix: ok
104
+ ? undefined
105
+ : "`git config --global core.autocrlf input` (or `false`), then re-clone or `git rm --cached -r . && git reset --hard`.",
106
+ faqId: ok ? undefined : "crlf-line-endings",
107
+ };
108
+ }
109
+ /** WSL2's own filesystem, never `/mnt/c/...` — a Windows-drive checkout
110
+ * makes every file operation cross the 9p boundary and Docker bind mounts
111
+ * silently fail to see edits. */
112
+ export function checkRepoUnderMntC(repoRoot) {
113
+ const ok = !repoRoot.startsWith("/mnt/");
114
+ return {
115
+ id: "repo-under-mnt-c",
116
+ status: ok ? "ok" : "warn",
117
+ summary: ok
118
+ ? "Repo is on the Linux filesystem"
119
+ : `Repo is under ${repoRoot} — a Windows drive mounted into WSL2`,
120
+ fix: ok
121
+ ? undefined
122
+ : "Clone into your Linux home (~), never /mnt/c/..., and reopen with the VS Code WSL extension.",
123
+ faqId: ok ? undefined : "repo-under-mnt-c",
124
+ };
125
+ }
126
+ /** Windows only. `RemoteSigned` (or looser) is what lets fnm's PowerShell
127
+ * hook and pnpm's shims actually run. */
128
+ export function checkPowerShellExecutionPolicy(policy) {
129
+ const restricted = policy === "Restricted" || policy === "AllSigned";
130
+ return {
131
+ id: "powershell-execution-policy",
132
+ status: restricted ? "warn" : "ok",
133
+ summary: restricted
134
+ ? `Execution policy is ${policy}, which blocks locally-created scripts`
135
+ : `Execution policy is ${policy ?? "unrestricted"}`,
136
+ fix: restricted
137
+ ? "`Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`, then restart your terminal."
138
+ : undefined,
139
+ faqId: restricted ? "powershell-execution-policy" : undefined,
140
+ };
141
+ }
142
+ export function checkEnvKeysPresent(env, keys) {
143
+ const missing = keys.filter((k) => !env[k]);
144
+ return {
145
+ id: "env-incomplete",
146
+ status: missing.length === 0 ? "ok" : "warn",
147
+ summary: missing.length === 0
148
+ ? ".env has every key this run needs"
149
+ : `.env is missing: ${missing.join(", ")}`,
150
+ fix: missing.length === 0
151
+ ? undefined
152
+ : "`pnpm devtools setup` (or `pnpm devtools env init`) fills in the blanks.",
153
+ faqId: missing.length === 0 ? undefined : "env-incomplete",
154
+ };
155
+ }
156
+ export { validateSessionPoolerUrl };
157
+ // ── Orchestrator ─────────────────────────────────────────────────────────────
158
+ function has(cmd, args) {
159
+ try {
160
+ return execFileSync(cmd, args, {
161
+ stdio: ["ignore", "pipe", "ignore"],
162
+ encoding: "utf8",
163
+ }).trim();
164
+ }
165
+ catch {
166
+ return null;
167
+ }
168
+ }
169
+ function gitConfig(key) {
170
+ return has("git", ["config", "--get", key]);
171
+ }
172
+ function readNvmrc(repoRoot) {
173
+ try {
174
+ return readFileSync(join(repoRoot, ".nvmrc"), "utf8").trim();
175
+ }
176
+ catch {
177
+ return null;
178
+ }
179
+ }
180
+ function readPackageManagerPin(repoRoot) {
181
+ try {
182
+ const pkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"));
183
+ return pkg.packageManager ?? null;
184
+ }
185
+ catch {
186
+ return null;
187
+ }
188
+ }
189
+ function readShellProfile() {
190
+ const home = process.env.HOME;
191
+ if (!home)
192
+ return null;
193
+ for (const file of [".bashrc", ".zshrc", ".config/fish/config.fish", ".bash_profile"]) {
194
+ const path = join(home, file);
195
+ if (existsSync(path)) {
196
+ try {
197
+ return readFileSync(path, "utf8");
198
+ }
199
+ catch {
200
+ // try the next one
201
+ }
202
+ }
203
+ }
204
+ return null;
205
+ }
206
+ function renderCheck(app, check) {
207
+ const icon = check.status === "ok" ? "OK " : check.status === "skip" ? "SKIP" : "WARN";
208
+ const lines = [`${icon} ${check.summary}`];
209
+ if (check.status === "warn") {
210
+ if (check.fix)
211
+ lines.push(` fix: ${check.fix}`);
212
+ if (check.faqId)
213
+ lines.push(` ${faqUrl(app, check.faqId)}`);
214
+ }
215
+ return lines.join("\n");
216
+ }
217
+ export async function runEnvironmentDoctor(opts) {
218
+ const app = opts.app && TROUBLESHOOTING_APPS.includes(opts.app) ? opts.app : "platform";
219
+ const repoRoot = discoverRepoRoot();
220
+ const checks = [];
221
+ const nodeCheck = checkNodeVersion(process.versions.node, app);
222
+ checks.push(nodeCheck);
223
+ // Only worth raising when Node is wrong: nvm, Volta or a system Node that
224
+ // already satisfies the floor is fine, and flagging a missing fnm hook there
225
+ // is a false alarm.
226
+ if (nodeCheck.status !== "ok") {
227
+ checks.push(checkFnmHookPresent(readShellProfile(), app));
228
+ }
229
+ else {
230
+ const pinnedMajor = repoRoot ? readNvmrc(repoRoot)?.split(".")[0] : null;
231
+ const major = process.versions.node.split(".")[0];
232
+ if (pinnedMajor && /^\d+$/.test(pinnedMajor) && pinnedMajor !== major) {
233
+ checks.push({
234
+ id: "node-version",
235
+ status: "warn",
236
+ summary: `Node ${process.versions.node} works, but the repo pins Node ${pinnedMajor} (.nvmrc)`,
237
+ fix: "Run `fnm install && fnm use` in the repo.",
238
+ faqId: "node-version",
239
+ });
240
+ }
241
+ }
242
+ const pnpmVersion = has("pnpm", ["--version"]);
243
+ const pin = repoRoot ? readPackageManagerPin(repoRoot) : null;
244
+ checks.push(pin
245
+ ? checkPnpmVersion(pnpmVersion, pin, app)
246
+ : {
247
+ id: "pnpm-version",
248
+ status: pnpmVersion ? "ok" : "warn",
249
+ summary: pnpmVersion ? `pnpm ${pnpmVersion}` : "pnpm is not on PATH",
250
+ fix: pnpmVersion
251
+ ? undefined
252
+ : '`npm install -g pnpm` (never `corepack enable`).',
253
+ faqId: pnpmVersion ? undefined : "pnpm-missing",
254
+ });
255
+ checks.push(checkAutocrlf(gitConfig("core.autocrlf")));
256
+ if (repoRoot)
257
+ checks.push(checkRepoUnderMntC(repoRoot));
258
+ if (process.platform === "win32") {
259
+ checks.push(checkPowerShellExecutionPolicy(has("powershell", ["-NoProfile", "-Command", "Get-ExecutionPolicy"])));
260
+ }
261
+ // Local Docker stack — only relevant when the checkout has a local
262
+ // `.env.generated`, the signature of `db start` having run at least once.
263
+ if (repoRoot && existsSync(join(repoRoot, ".env.generated"))) {
264
+ const dockerUp = has("docker", ["info"]) !== null;
265
+ checks.push({
266
+ id: "docker-not-running",
267
+ status: dockerUp ? "ok" : "warn",
268
+ summary: dockerUp ? "Docker is running" : "Docker daemon is not answering",
269
+ fix: dockerUp
270
+ ? undefined
271
+ : "Start Docker Desktop or OrbStack (macOS: set DOCKER_HOST=unix://$HOME/.orbstack/run/docker.sock for OrbStack). Never Colima.",
272
+ faqId: dockerUp ? undefined : "docker-not-running",
273
+ });
274
+ }
275
+ if (!opts.app || opts.app === "study-group-finder") {
276
+ const flutter = has("flutter", ["--version"]) !== null;
277
+ checks.push({
278
+ id: "flutter-missing",
279
+ status: flutter ? "ok" : "skip",
280
+ summary: flutter ? "Flutter is installed" : "Flutter is not installed",
281
+ fix: flutter ? undefined : "Install the Flutter SDK for study-group-finder.",
282
+ faqId: flutter ? undefined : "flutter-missing",
283
+ });
284
+ if (flutter) {
285
+ const devices = has("adb", ["devices"]);
286
+ const hasDevice = Boolean(devices
287
+ ?.split("\n")
288
+ .slice(1)
289
+ .some((line) => line.trim().endsWith("device")));
290
+ checks.push({
291
+ id: "adb-no-devices",
292
+ status: hasDevice ? "ok" : "skip",
293
+ summary: hasDevice
294
+ ? "adb sees a device or emulator"
295
+ : "adb sees no device or emulator",
296
+ fix: hasDevice
297
+ ? undefined
298
+ : "Start an Android Studio emulator (or plug in a device). On WSL2 with a Windows emulator, run `adb kill-server` on Windows first.",
299
+ faqId: hasDevice ? undefined : "adb-no-devices",
300
+ });
301
+ }
302
+ }
303
+ let env = {};
304
+ if (repoRoot) {
305
+ const envPath = join(repoRoot, ".env");
306
+ if (existsSync(envPath)) {
307
+ env = parseDotenv(readFileSync(envPath, "utf8"));
308
+ }
309
+ checks.push(checkEnvKeysPresent(env, ["API_URL", "PUBLISHABLE_KEY"]));
310
+ }
311
+ // ── Hosted checks — only when .env looks like a hosted project ───────────
312
+ const isHosted = Boolean(env.API_URL && !env.API_URL.includes("127.0.0.1") && !env.API_URL.includes("localhost"));
313
+ if (isHosted && env.API_URL) {
314
+ let reachable = false;
315
+ let probeStatus = null;
316
+ try {
317
+ const res = await fetch(`${env.API_URL}/auth/v1/settings`, {
318
+ signal: AbortSignal.timeout(5000),
319
+ });
320
+ probeStatus = res.status;
321
+ reachable = res.status < 500;
322
+ }
323
+ catch {
324
+ reachable = false;
325
+ }
326
+ checks.push({
327
+ id: "supabase-unreachable",
328
+ status: reachable ? "ok" : "warn",
329
+ summary: reachable
330
+ ? "The hosted project answers"
331
+ : "The hosted project's API URL did not respond",
332
+ fix: reachable
333
+ ? undefined
334
+ : "Check the API URL in .env, and that the project is not paused.",
335
+ faqId: reachable ? undefined : "supabase-unreachable",
336
+ });
337
+ if (probeStatus === 503 || probeStatus === 522 || probeStatus === 540) {
338
+ checks.push({
339
+ id: "supabase-paused",
340
+ status: "warn",
341
+ summary: "The project looks paused",
342
+ fix: "Unpause it from the Supabase dashboard (free tier pauses after ~1 week idle).",
343
+ faqId: "supabase-paused",
344
+ });
345
+ }
346
+ if (reachable && env.SECRET_KEY) {
347
+ let keysValid = false;
348
+ try {
349
+ const res = await fetch(`${env.API_URL}/auth/v1/admin/users?page=1&per_page=1`, {
350
+ headers: { Authorization: `Bearer ${env.SECRET_KEY}`, apikey: env.SECRET_KEY },
351
+ signal: AbortSignal.timeout(5000),
352
+ });
353
+ keysValid = res.status !== 401 && res.status !== 403;
354
+ }
355
+ catch {
356
+ keysValid = false;
357
+ }
358
+ checks.push({
359
+ id: "supabase-keys-invalid",
360
+ status: keysValid ? "ok" : "warn",
361
+ summary: keysValid ? "SECRET_KEY is accepted" : "SECRET_KEY was rejected",
362
+ fix: keysValid
363
+ ? undefined
364
+ : "Copy the service_role secret key again from Project Settings -> API.",
365
+ faqId: keysValid ? undefined : "supabase-keys-invalid",
366
+ });
367
+ }
368
+ }
369
+ if (env.DB_URL) {
370
+ const validation = validateSessionPoolerUrl(env.DB_URL);
371
+ checks.push({
372
+ id: validation.faqId ?? "db-url-direct-connection",
373
+ status: validation.ok ? "ok" : "warn",
374
+ summary: validation.ok
375
+ ? "DB_URL is a Session pooler string"
376
+ : (validation.message ?? "DB_URL is not a Session pooler string"),
377
+ fix: validation.ok ? undefined : validation.message,
378
+ faqId: validation.faqId,
379
+ });
380
+ }
381
+ note(checks.map((c) => renderCheck(app, c)).join("\n\n"), opts.app ? `Doctor — ${opts.app}` : "Doctor");
382
+ const warnings = checks.filter((c) => c.status === "warn").length;
383
+ if (warnings === 0)
384
+ log.success("Everything checked out.");
385
+ else
386
+ log.warn(`${warnings} check${warnings === 1 ? "" : "s"} need attention.`);
387
+ if (opts.report) {
388
+ const redacted = [
389
+ `OS: ${process.platform} ${process.arch}`,
390
+ `Node: ${process.versions.node}`,
391
+ ...checks.map((c) => `${c.status.toUpperCase().padEnd(4)} ${c.id}: ${c.summary}`),
392
+ ].join("\n");
393
+ note(redacted, "Paste this in Discord");
394
+ }
395
+ }
@@ -30,4 +30,11 @@ export declare function connectViaOneClick(target: ConnectTarget, platformUrl: s
30
30
  * Intro and outro are the caller's, so this composes into the devtools menu
31
31
  * without drawing a second box inside the first.
32
32
  */
33
- export declare function runOAuthSetup(baseUrlOverride?: string, platformUrlOverride?: string, transportOverride?: ConnectTransport): Promise<void>;
33
+ export declare function runOAuthSetup(baseUrlOverride?: string, platformUrlOverride?: string, transportOverride?: ConnectTransport,
34
+ /**
35
+ * A target already resolved by a caller — `setup.ts`'s hosted wizard,
36
+ * chaining in with the same project/service-role key a contributor just
37
+ * typed, so they are not asked to pick "hosted" and paste the key a
38
+ * second time. Skips Step 1 entirely when given.
39
+ */
40
+ presetTarget?: ConnectTarget): Promise<void>;
@@ -424,11 +424,18 @@ async function connectByPasting(baseUrlOverride) {
424
424
  * Intro and outro are the caller's, so this composes into the devtools menu
425
425
  * without drawing a second box inside the first.
426
426
  */
427
- export async function runOAuthSetup(baseUrlOverride, platformUrlOverride, transportOverride) {
427
+ export async function runOAuthSetup(baseUrlOverride, platformUrlOverride, transportOverride,
428
+ /**
429
+ * A target already resolved by a caller — `setup.ts`'s hosted wizard,
430
+ * chaining in with the same project/service-role key a contributor just
431
+ * typed, so they are not asked to pick "hosted" and paste the key a
432
+ * second time. Skips Step 1 entirely when given.
433
+ */
434
+ presetTarget) {
428
435
  const cwd = process.cwd();
429
436
  log.info('Configures a Supabase project to support "Sign in with DevDogs".');
430
437
  // ── Step 1: Choose target ────────────────────────────────────────────────
431
- const target = await chooseTarget(cwd);
438
+ const target = presetTarget ?? (await chooseTarget(cwd));
432
439
  // ── Step 2: Connect ──────────────────────────────────────────────────────
433
440
  const providerName = unwrap(await text({
434
441
  message: "Provider display name",
@@ -548,9 +555,18 @@ export async function runOAuthSetup(baseUrlOverride, platformUrlOverride, transp
548
555
  ` ${callbackUri}`, "One more step");
549
556
  }
550
557
  }
558
+ const redirectStep = target.kind === "hosted"
559
+ ? [
560
+ `Add your app's localhost redirect URL in the dashboard:`,
561
+ ` Auth -> URL Configuration -> Redirect URLs -> add http://localhost:<port>/**`,
562
+ ` (schedule-builder is 3001, platform is 3000)`,
563
+ ]
564
+ : [
565
+ `Make sure your project's supabase/config.toml allows your app callback:`,
566
+ ` additional_redirect_urls = ["http://localhost:<port>/auth/callback"]`,
567
+ ];
551
568
  const nextSteps = [
552
- `Make sure your project's supabase/config.toml allows your app callback:`,
553
- ` additional_redirect_urls = ["http://localhost:<port>/auth/callback"]`,
569
+ ...redirectStep,
554
570
  ``,
555
571
  `Trigger sign-in from your app:`,
556
572
  ``,