@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.
package/dist/cli.js CHANGED
@@ -19,7 +19,8 @@
19
19
  */
20
20
  import { confirm, intro, log, note, outro, select, spinner, } from "@clack/prompts";
21
21
  import { assertMigrated, detectLocalInstance, PERSONAS, } from "./instance.js";
22
- import { conformance, listApps, quarantineRoundTrip } from "./doctor.js";
22
+ import { conformance, listApps, quarantineRoundTrip } from "./moderation.js";
23
+ import { runEnvironmentDoctor } from "./environment-doctor.js";
23
24
  import { currentRootHolder, grantRoot, listCandidates, transferRoot, } from "./grantRoot.js";
24
25
  import { connectRemoteProject, runStackCommand, } from "./stack.js";
25
26
  import { runConfigPush } from "./db/config-push.js";
@@ -62,14 +63,9 @@ import { runWorkflows } from "./workflows/commands.js";
62
63
  import { runCf } from "./cf/commands.js";
63
64
  import { captureDevtoolsError, initDevtoolsTelemetry } from "./telemetry.js";
64
65
  import { ownVersion } from "./repo/preflight.js";
65
- const DOCTOR_COMMANDS = [
66
- "doctor",
67
- "roundtrip",
68
- "catalog",
69
- "grant-root",
70
- ];
71
- function isDoctorCommand(value) {
72
- return DOCTOR_COMMANDS.includes(value);
66
+ const MODERATION_COMMANDS = ["moderation", "grant-root"];
67
+ function isModerationCommand(value) {
68
+ return MODERATION_COMMANDS.includes(value);
73
69
  }
74
70
  function flagValue(rest, flag) {
75
71
  const index = rest.indexOf(flag);
@@ -236,11 +232,11 @@ async function runStack(command, rest) {
236
232
  process.exitCode = 1;
237
233
  }
238
234
  }
239
- async function runCatalog(instance) {
235
+ async function runModerationCatalog(instance) {
240
236
  const catalog = await readCatalog(instance);
241
237
  note(renderCatalog(catalog), "Moderation catalog");
242
238
  }
243
- async function runDoctor(instance, appSlug) {
239
+ async function runModerationCheck(instance, appSlug) {
244
240
  let slug = appSlug;
245
241
  if (!slug) {
246
242
  const apps = await listApps(instance);
@@ -289,7 +285,7 @@ async function runDoctor(instance, appSlug) {
289
285
  "than trusting outright.");
290
286
  }
291
287
  }
292
- async function runRoundTrip(instance) {
288
+ async function runModerationRoundTrip(instance) {
293
289
  const s = spinner();
294
290
  s.start("Filing a report, quarantining it, and looking again");
295
291
  try {
@@ -605,7 +601,11 @@ async function runDbCommand(rest) {
605
601
  if (sub === "migration") {
606
602
  const [msub, ...mrest] = subRest;
607
603
  if (msub === "new") {
608
- const code = await runNewMigration(mrest.find((arg) => !arg.startsWith("-")));
604
+ // Skip `--app`'s own value, so `new --app <slug> "<description>"`
605
+ // doesn't take the slug for the description.
606
+ const appAt = mrest.indexOf("--app");
607
+ const positional = mrest.filter((arg, i) => !arg.startsWith("-") && !(appAt >= 0 && i === appAt + 1));
608
+ const code = await runNewMigration(flagValue(mrest, "--app"), positional[0]);
609
609
  process.exitCode = code === 0 ? 0 : 1;
610
610
  return;
611
611
  }
@@ -921,7 +921,25 @@ async function dispatch(argv) {
921
921
  process.exitCode = 1;
922
922
  return null;
923
923
  }
924
- if (!isDoctorCommand(first)) {
924
+ // The old moderation names, refused with the new namespace rather than
925
+ // falling into "Unknown command" — same rationale as `secrets` above.
926
+ // `doctor` itself is NOT here: that name now belongs to the environment
927
+ // checker below, a deliberate reuse rather than a collision.
928
+ if (first === "catalog" || first === "roundtrip") {
929
+ explain(`\`${first}\` is now \`devtools moderation ${first}\`.`, "", [
930
+ `pnpm devtools moderation ${first}`,
931
+ ]);
932
+ process.exitCode = 1;
933
+ return null;
934
+ }
935
+ if (first === "doctor") {
936
+ await runEnvironmentDoctor({
937
+ app: flagValue(rest, "--app"),
938
+ report: rest.includes("--report"),
939
+ });
940
+ return DONE;
941
+ }
942
+ if (!isModerationCommand(first)) {
925
943
  log.error(`Unknown command: ${first}`);
926
944
  // The top level only. The command is unknown, so there is no level below
927
945
  // it to describe, and reprinting the whole tree here is what made the old
@@ -935,17 +953,26 @@ async function dispatch(argv) {
935
953
  process.exitCode = 1;
936
954
  return null;
937
955
  }
938
- if (first === "catalog") {
939
- await runCatalog(instance);
940
- }
941
- else if (first === "doctor") {
942
- await runDoctor(instance, flagValue(rest, "--app"));
943
- }
944
- else if (first === "grant-root") {
945
- await runGrantRoot(instance, flagValue(rest, "--user"));
956
+ if (first === "moderation") {
957
+ const [msub, ...mrest] = rest;
958
+ if (msub === "check") {
959
+ await runModerationCheck(instance, flagValue(mrest, "--app"));
960
+ }
961
+ else if (msub === "catalog") {
962
+ await runModerationCatalog(instance);
963
+ }
964
+ else if (msub === "roundtrip") {
965
+ await runModerationRoundTrip(instance);
966
+ }
967
+ else {
968
+ log.error(msub
969
+ ? `devtools moderation: unknown subcommand "${msub}". Try ${subcommandList(["moderation"])}.`
970
+ : `devtools moderation: which of ${subcommandList(["moderation"])}?`);
971
+ process.exitCode = 1;
972
+ }
946
973
  }
947
974
  else {
948
- await runRoundTrip(instance);
975
+ await runGrantRoot(instance, flagValue(rest, "--user"));
949
976
  }
950
977
  return DONE;
951
978
  }
package/dist/commands.js CHANGED
@@ -337,32 +337,39 @@ const DECLARED_GROUPS = [
337
337
  title: "Moderation",
338
338
  commands: [
339
339
  {
340
- name: "catalog",
341
- summary: "List the report reasons and content types in the database.",
342
- hint: "what can be reported here",
343
- needs: "instance-running",
344
- options: [JSON_FLAG],
345
- },
346
- {
347
- name: "doctor",
348
- summary: "Check an app's moderation integration.",
349
- hint: "and whether the catalog holds up",
350
- needs: "instance-running",
351
- options: [
340
+ name: "moderation",
341
+ summary: "An app's moderation integration: the catalog and its wiring.",
342
+ hint: "check, catalog, roundtrip",
343
+ subcommands: [
352
344
  {
353
- flag: "--app",
354
- value: "<slug>",
355
- summary: "App to check. Asked for when absent.",
345
+ name: "check",
346
+ summary: "Check an app's moderation integration.",
347
+ hint: "and whether the catalog holds up",
348
+ needs: "instance-running",
349
+ options: [
350
+ {
351
+ flag: "--app",
352
+ value: "<slug>",
353
+ summary: "App to check. Asked for when absent.",
354
+ },
355
+ JSON_FLAG,
356
+ ],
357
+ },
358
+ {
359
+ name: "catalog",
360
+ summary: "List the report reasons and content types in the database.",
361
+ hint: "what can be reported here",
362
+ needs: "instance-running",
363
+ options: [JSON_FLAG],
364
+ },
365
+ {
366
+ name: "roundtrip",
367
+ summary: "File a report, quarantine it, and check the freeze.",
368
+ hint: "end to end, then cleans up",
369
+ needs: "instance-running",
356
370
  },
357
- JSON_FLAG,
358
371
  ],
359
372
  },
360
- {
361
- name: "roundtrip",
362
- summary: "File a report, quarantine it, and check the freeze.",
363
- hint: "end to end, then cleans up",
364
- needs: "instance-running",
365
- },
366
373
  {
367
374
  name: "grant-root",
368
375
  summary: "Give an account every permission on your own database.",
@@ -377,6 +384,27 @@ const DECLARED_GROUPS = [
377
384
  },
378
385
  ],
379
386
  },
387
+ {
388
+ title: "Environment",
389
+ commands: [
390
+ {
391
+ name: "doctor",
392
+ summary: "Check this machine's environment against what the repo needs.",
393
+ hint: "node, pnpm, Docker, .env, hosted Supabase, OAuth — read-only",
394
+ options: [
395
+ {
396
+ flag: "--app",
397
+ value: "<slug>",
398
+ summary: "Scope checks to one app. Defaults to every app you have env for.",
399
+ },
400
+ {
401
+ flag: "--report",
402
+ summary: "Print a redacted, paste-able block (versions, OS, results — no secrets).",
403
+ },
404
+ ],
405
+ },
406
+ ],
407
+ },
380
408
  {
381
409
  title: "Generate",
382
410
  commands: [
@@ -817,6 +845,14 @@ const DECLARED_GROUPS = [
817
845
  {
818
846
  name: "new",
819
847
  summary: "Create an empty timestamped migration file.",
848
+ hint: "<timestamp>_<schema>_<description>.sql",
849
+ options: [
850
+ {
851
+ flag: "--app",
852
+ value: "<slug>",
853
+ summary: "Whose schema. Asked for when absent.",
854
+ },
855
+ ],
820
856
  },
821
857
  {
822
858
  name: "generate",
@@ -1084,7 +1120,11 @@ export const GROUPS = [
1084
1120
  },
1085
1121
  {
1086
1122
  title: "Moderation",
1087
- commands: commands("catalog", "doctor", "roundtrip", "grant-root"),
1123
+ commands: commands("moderation", "grant-root"),
1124
+ },
1125
+ {
1126
+ title: "Environment",
1127
+ commands: commands("doctor"),
1088
1128
  },
1089
1129
  {
1090
1130
  title: "GitHub",
@@ -1 +1,8 @@
1
- export declare function runNewMigration(name: string | undefined): Promise<number>;
1
+ /** App slug -> Postgres schema name. Schedule Builder and Study Group
2
+ * Finder use underscores; their slugs use hyphens. */
3
+ export declare const APP_SCHEMAS: Record<string, string>;
4
+ /** Builds the `supabase migration new` name argument — `<schema>_<desc>` —
5
+ * given an app slug and a free-text description. Pure, so the naming rule
6
+ * is testable without spawning the CLI. */
7
+ export declare function migrationName(appSlug: string, description: string): string | null;
8
+ export declare function runNewMigration(appArg: string | undefined, descriptionArg: string | undefined): Promise<number>;
@@ -1,7 +1,65 @@
1
+ /**
2
+ * `db migration new --app <slug> <description>`
3
+ *
4
+ * One flat `supabase/migrations/` directory, shared by every app, so the
5
+ * filename is the only thing that says whose schema a migration belongs to.
6
+ * Contract: `<timestamp>_<schema>_<description>.sql`, where `schema` is
7
+ * `platform`, `schedule_builder`, or `study_group_finder` — never the
8
+ * app slug verbatim (schedule-builder's schema is `schedule_builder`, with
9
+ * an underscore, because it is a Postgres identifier).
10
+ *
11
+ * The supabase CLI already produces `<timestamp>_<name>.sql` from `migration
12
+ * new <name>` — its own clock, not ours, which is the part CI's
13
+ * newer-than-main check actually cares about — so this only has to build
14
+ * `name` as `<schema>_<description>` and hand it off unchanged.
15
+ */
16
+ import { select, text } from "@clack/prompts";
17
+ import { unwrap } from "../ui.js";
1
18
  import { supabase } from "./run.js";
2
- export async function runNewMigration(name) {
19
+ /** App slug -> Postgres schema name. Schedule Builder and Study Group
20
+ * Finder use underscores; their slugs use hyphens. */
21
+ export const APP_SCHEMAS = {
22
+ platform: "platform",
23
+ "schedule-builder": "schedule_builder",
24
+ "study-group-finder": "study_group_finder",
25
+ };
26
+ /** Builds the `supabase migration new` name argument — `<schema>_<desc>` —
27
+ * given an app slug and a free-text description. Pure, so the naming rule
28
+ * is testable without spawning the CLI. */
29
+ export function migrationName(appSlug, description) {
30
+ const schema = APP_SCHEMAS[appSlug];
31
+ if (!schema)
32
+ return null;
33
+ const slug = description
34
+ .trim()
35
+ .toLowerCase()
36
+ .replace(/[^a-z0-9]+/g, "_")
37
+ .replace(/^_+|_+$/g, "");
38
+ if (!slug)
39
+ return null;
40
+ return `${schema}_${slug}`;
41
+ }
42
+ async function pickApp() {
43
+ return unwrap(await select({
44
+ message: "Whose schema is this migration for?",
45
+ options: Object.keys(APP_SCHEMAS).map((value) => ({ value, label: value })),
46
+ }));
47
+ }
48
+ export async function runNewMigration(appArg, descriptionArg) {
49
+ const app = appArg ?? (await pickApp());
50
+ if (!APP_SCHEMAS[app]) {
51
+ process.stderr.write(`devtools db migration new: unknown app "${app}". Expected one of: ${Object.keys(APP_SCHEMAS).join(", ")}.\n`);
52
+ return 1;
53
+ }
54
+ const description = descriptionArg ??
55
+ unwrap(await text({
56
+ message: "Short description (used as the migration's filename)",
57
+ placeholder: "add_widgets_table",
58
+ validate: (v) => (v?.trim() ? undefined : "Required"),
59
+ }));
60
+ const name = migrationName(app, description);
3
61
  if (!name) {
4
- process.stderr.write("devtools new-migration: a migration name is required.\n");
62
+ process.stderr.write("devtools db migration new: the description left nothing usable in the filename.\n");
5
63
  return 1;
6
64
  }
7
65
  return supabase("migration", "new", name);
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Validates a hosted `DB_URL` against the one shape it must have: the
3
+ * Session pooler string, not the direct connection and not the Transaction
4
+ * pooler.
5
+ *
6
+ * Pure and synchronous on purpose — `setup.ts`'s hosted wizard calls this
7
+ * before writing anything to `.env`, and `doctor.ts`'s environment checker
8
+ * calls the same function against whatever is already there, so the two
9
+ * never drift into disagreeing about what a good `DB_URL` looks like.
10
+ *
11
+ * See the writing guide's collapsed note on poolers (docs/_shared or
12
+ * toolkit/guides) for the wording this explains to a contributor; this
13
+ * module only classifies, it does not print.
14
+ */
15
+ export type DbUrlProblem = "invalid" | "direct-connection" | "transaction-pooler";
16
+ export interface DbUrlValidation {
17
+ ok: boolean;
18
+ problem?: DbUrlProblem;
19
+ /** The FAQ anchor id for this problem, for a caller that wants to link. */
20
+ faqId?: "db-url-direct-connection" | "db-url-transaction-pooler";
21
+ message?: string;
22
+ }
23
+ export declare function validateSessionPoolerUrl(dbUrl: string): DbUrlValidation;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Validates a hosted `DB_URL` against the one shape it must have: the
3
+ * Session pooler string, not the direct connection and not the Transaction
4
+ * pooler.
5
+ *
6
+ * Pure and synchronous on purpose — `setup.ts`'s hosted wizard calls this
7
+ * before writing anything to `.env`, and `doctor.ts`'s environment checker
8
+ * calls the same function against whatever is already there, so the two
9
+ * never drift into disagreeing about what a good `DB_URL` looks like.
10
+ *
11
+ * See the writing guide's collapsed note on poolers (docs/_shared or
12
+ * toolkit/guides) for the wording this explains to a contributor; this
13
+ * module only classifies, it does not print.
14
+ */
15
+ /** `db.<ref>.supabase.co` — the direct connection host, IPv6-only on the
16
+ * free tier. */
17
+ const DIRECT_CONNECTION_HOST = /^db\.[a-z0-9]+\.supabase\.co$/i;
18
+ /** The Transaction pooler's port. Session pooler is 5432, same as plain
19
+ * Postgres. */
20
+ const TRANSACTION_POOLER_PORT = "6543";
21
+ export function validateSessionPoolerUrl(dbUrl) {
22
+ let url;
23
+ try {
24
+ url = new URL(dbUrl);
25
+ }
26
+ catch {
27
+ return {
28
+ ok: false,
29
+ problem: "invalid",
30
+ message: "That does not look like a connection string (expected postgresql://...).",
31
+ };
32
+ }
33
+ if (DIRECT_CONNECTION_HOST.test(url.hostname)) {
34
+ return {
35
+ ok: false,
36
+ problem: "direct-connection",
37
+ faqId: "db-url-direct-connection",
38
+ message: `${url.hostname} is the direct connection host. It only answers on ` +
39
+ "IPv6, and most campus/home networks are IPv4. Copy the Session " +
40
+ "pooler string instead — Database settings → Connection string → " +
41
+ "Session pooler.",
42
+ };
43
+ }
44
+ if (url.port === TRANSACTION_POOLER_PORT) {
45
+ return {
46
+ ok: false,
47
+ problem: "transaction-pooler",
48
+ faqId: "db-url-transaction-pooler",
49
+ message: "Port 6543 is the Transaction pooler, meant for serverless — it " +
50
+ "breaks prepared statements (drizzle-kit hangs). Use the Session " +
51
+ "pooler on port 5432 instead.",
52
+ };
53
+ }
54
+ return { ok: true };
55
+ }
@@ -0,0 +1,36 @@
1
+ import { validateSessionPoolerUrl } from "./db/pooler.js";
2
+ export type CheckStatus = "ok" | "warn" | "skip";
3
+ export interface DoctorCheck {
4
+ id: string;
5
+ status: CheckStatus;
6
+ summary: string;
7
+ /** How to fix it, when `status !== "ok"`. */
8
+ fix?: string;
9
+ faqId?: string;
10
+ }
11
+ /** The floor is 22.12: pnpm 11 needs `node:sqlite`, stable there. `.nvmrc`
12
+ * pins 24, which is what a `fnm use` picks up; a machine below the floor is
13
+ * a WARN, not an error — plenty of contributors' shells still resolve an
14
+ * old global `node` first. */
15
+ export declare function checkNodeVersion(nodeVersion: string, app: string): DoctorCheck;
16
+ export declare function checkFnmHookPresent(shellProfile: string | null, app: string): DoctorCheck;
17
+ export declare function checkPnpmVersion(pnpmVersion: string | null, pinned: string, app: string): DoctorCheck;
18
+ /** `git config core.autocrlf` — `input` or `false` is correct on every
19
+ * platform this repo documents (WSL2, native Windows, macOS, Linux); `true`
20
+ * rewrites LF to CRLF on checkout and breaks anything that shells out to a
21
+ * script with a shebang. */
22
+ export declare function checkAutocrlf(value: string | null): DoctorCheck;
23
+ /** WSL2's own filesystem, never `/mnt/c/...` — a Windows-drive checkout
24
+ * makes every file operation cross the 9p boundary and Docker bind mounts
25
+ * silently fail to see edits. */
26
+ export declare function checkRepoUnderMntC(repoRoot: string): DoctorCheck;
27
+ /** Windows only. `RemoteSigned` (or looser) is what lets fnm's PowerShell
28
+ * hook and pnpm's shims actually run. */
29
+ export declare function checkPowerShellExecutionPolicy(policy: string | null): DoctorCheck;
30
+ export declare function checkEnvKeysPresent(env: Record<string, string | undefined>, keys: string[]): DoctorCheck;
31
+ export { validateSessionPoolerUrl };
32
+ export interface RunDoctorOptions {
33
+ app?: string;
34
+ report?: boolean;
35
+ }
36
+ export declare function runEnvironmentDoctor(opts: RunDoctorOptions): Promise<void>;