supabase-worker-previews 0.0.0 → 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 (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +141 -1
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +152 -0
  5. package/dist/cloudflare.d.ts +37 -0
  6. package/dist/cloudflare.js +95 -0
  7. package/dist/commands/branches.d.ts +62 -0
  8. package/dist/commands/branches.js +273 -0
  9. package/dist/commands/doctor.d.ts +24 -0
  10. package/dist/commands/doctor.js +289 -0
  11. package/dist/commands/init.d.ts +11 -0
  12. package/dist/commands/init.js +132 -0
  13. package/dist/commands/pr.d.ts +37 -0
  14. package/dist/commands/pr.js +78 -0
  15. package/dist/commands/prune.d.ts +32 -0
  16. package/dist/commands/prune.js +110 -0
  17. package/dist/config.d.ts +50 -0
  18. package/dist/config.js +105 -0
  19. package/dist/feedback.d.ts +64 -0
  20. package/dist/feedback.js +238 -0
  21. package/dist/github.d.ts +33 -0
  22. package/dist/github.js +78 -0
  23. package/dist/http.d.ts +7 -0
  24. package/dist/http.js +44 -0
  25. package/dist/jsonc.d.ts +2 -0
  26. package/dist/jsonc.js +56 -0
  27. package/dist/preview-name.d.ts +3 -0
  28. package/dist/preview-name.js +15 -0
  29. package/dist/run.d.ts +21 -0
  30. package/dist/run.js +59 -0
  31. package/dist/runtime.d.ts +56 -0
  32. package/dist/runtime.js +121 -0
  33. package/dist/style.d.ts +8 -0
  34. package/dist/style.js +21 -0
  35. package/dist/suggest.d.ts +2 -0
  36. package/dist/suggest.js +22 -0
  37. package/dist/supabase.d.ts +78 -0
  38. package/dist/supabase.js +168 -0
  39. package/dist/usage.d.ts +46 -0
  40. package/dist/usage.js +294 -0
  41. package/package.json +83 -9
  42. package/templates/default-privileges.sql +9 -0
  43. package/templates/supabase-previews-action.yml +29 -0
  44. package/templates/supabase-previews.yml +38 -0
@@ -0,0 +1,273 @@
1
+ import { previewWildcard } from "../preview-name.js";
2
+ import { projectUrl } from "../supabase.js";
3
+ import { IDENTITY_PATH, OVERRIDE_SECRET } from "../runtime.js";
4
+ const MIGRATION_COUNT_SQL = "select count(*)::int as n from supabase_migrations.schema_migrations";
5
+ const BRANCH_FAILED = ["MIGRATIONS_FAILED", "FUNCTIONS_FAILED"];
6
+ async function subdomainOf(deps) {
7
+ return deps.config.workersSubdomain ?? deps.cloudflare.workersSubdomain();
8
+ }
9
+ /** The non-default Supabase branch tied to a git branch, made by the GitHub integration or by `up`. */
10
+ export async function branchFor(gitBranch, deps) {
11
+ const branches = await deps.supabase.listBranches(deps.config.supabaseProjectRef);
12
+ return branches.find((b) => b.git_branch === gitBranch && !b.is_default);
13
+ }
14
+ /** The Preview a git branch deploys to: named after the branch, or `pr-<number>` when configured. */
15
+ export function previewKey(gitBranch, config, pr) {
16
+ if (config.previewName === "branch")
17
+ return gitBranch;
18
+ if (pr === undefined)
19
+ throw new Error(`previewName is "pr", so pass the PR number (--pr <n>)`);
20
+ return `pr-${pr}`;
21
+ }
22
+ /** A branch `swp` may delete: never production, the shared database, the trunk's or a persistent one. */
23
+ export function isDisposable(branch, config) {
24
+ return (!branch.is_default &&
25
+ !branch.persistent &&
26
+ branch.project_ref !== config.supabaseProjectRef &&
27
+ branch.name !== config.sharedBranch &&
28
+ branch.git_branch !== config.trunk);
29
+ }
30
+ /** Never repoint, write to or delete the production project through a branch record. */
31
+ export function assertIsolated(branch, parent) {
32
+ if (branch.is_default || branch.project_ref === parent) {
33
+ throw new Error(`Supabase branch "${branch.name}" is the production project ${parent} itself; refusing to touch it`);
34
+ }
35
+ }
36
+ export async function supabaseValues(ref, deps) {
37
+ const keys = await deps.supabase.keys(ref, deps.config.apiKeys);
38
+ return {
39
+ SUPABASE_URL: projectUrl(ref),
40
+ SUPABASE_PUBLISHABLE_KEY: keys.publishable,
41
+ SUPABASE_SERVICE_ROLE_KEY: keys.secret,
42
+ SUPABASE_PROJECT_REF: ref,
43
+ };
44
+ }
45
+ /**
46
+ * The shared Preview database: a persistent branch that tracks the trunk, so
47
+ * the GitHub integration migrates it on every trunk push. Its public values
48
+ * belong in wrangler `previews.vars`; only its secret key goes in the Preview
49
+ * base config.
50
+ */
51
+ export async function shared(deps) {
52
+ const { config, runner, supabase } = deps;
53
+ const parent = config.supabaseProjectRef;
54
+ runner.log(`Shared Preview database "${config.sharedBranch}" on ${parent}, tracking ${config.trunk}`);
55
+ const branches = await supabase.listBranches(parent);
56
+ if (runner.dryRun) {
57
+ runner.log(` ${branches.some((b) => b.name === config.sharedBranch) ? "update" : "create"} the persistent branch`);
58
+ return `<${config.sharedBranch}>`;
59
+ }
60
+ if (!branches.some((b) => b.is_default))
61
+ throw new Error(`Branching is not enabled on ${parent}. Connect the repo in the Supabase dashboard ` +
62
+ "(project settings, Integrations, GitHub, automatic branching on), then run swp shared again.");
63
+ let branch = branches.find((b) => b.name === config.sharedBranch);
64
+ if (!branch) {
65
+ branch = await supabase.createBranch(parent, {
66
+ name: config.sharedBranch,
67
+ gitBranch: config.trunk,
68
+ persistent: true,
69
+ });
70
+ }
71
+ else if (branch.git_branch !== config.trunk || !branch.persistent) {
72
+ await supabase.updateBranch(branch.project_ref, { gitBranch: config.trunk, persistent: true });
73
+ }
74
+ assertIsolated(branch, parent);
75
+ const ref = branch.project_ref;
76
+ await waitForMigrations(ref, deps);
77
+ const subdomain = await subdomainOf(deps);
78
+ await supabase.allowRedirects(ref, `https://${config.worker}.${subdomain}.workers.dev`, [
79
+ previewWildcard(config.worker, subdomain),
80
+ ]);
81
+ const values = await supabaseValues(ref, deps);
82
+ deps.cloudflare.putBaseSecrets({ SUPABASE_SERVICE_ROLE_KEY: values.SUPABASE_SERVICE_ROLE_KEY });
83
+ runner.log(`Done: ${ref}. Put these in the wrangler config under previews.vars:`);
84
+ runner.log(JSON.stringify({
85
+ SUPABASE_URL: values.SUPABASE_URL,
86
+ SUPABASE_PUBLISHABLE_KEY: values.SUPABASE_PUBLISHABLE_KEY,
87
+ SUPABASE_PROJECT_REF: ref,
88
+ }, null, 2));
89
+ return ref;
90
+ }
91
+ /**
92
+ * Gives a git branch's Preview its own database. The GitHub integration makes
93
+ * one for any PR that changes supabase/; for other branches this asks for one,
94
+ * which Supabase still migrates and seeds from that git branch.
95
+ */
96
+ export async function up(gitBranch, deps, pr) {
97
+ const { config, runner, supabase } = deps;
98
+ runner.log(`Isolated database for ${gitBranch}`);
99
+ if (runner.dryRun) {
100
+ runner.log(` find or create the Supabase branch for ${gitBranch}, wait for its migrations`);
101
+ runner.log(` set ${OVERRIDE_SECRET} on the Preview of ${gitBranch}`);
102
+ return `<${gitBranch}>`;
103
+ }
104
+ const branch = (await branchFor(gitBranch, deps)) ??
105
+ (await supabase.createBranch(config.supabaseProjectRef, { name: gitBranch, gitBranch }));
106
+ assertIsolated(branch, config.supabaseProjectRef);
107
+ const ref = branch.project_ref;
108
+ await waitForMigrations(ref, deps);
109
+ const preview = await waitForPreview(previewKey(gitBranch, config, pr), deps);
110
+ const url = previewUrlOf(preview);
111
+ await supabase.allowRedirects(ref, url, [`${url}/**`]);
112
+ deps.cloudflare.putPreviewSecrets(preview.name, {
113
+ [OVERRIDE_SECRET]: JSON.stringify(await supabaseValues(ref, deps)),
114
+ });
115
+ runner.log(`Done: ${url} now runs on ${ref}`);
116
+ return ref;
117
+ }
118
+ /**
119
+ * Puts a Preview back on the shared database once its PR no longer needs its
120
+ * own: drops the override, then deletes the branch `up` made for it. The
121
+ * integration only makes branches for PRs that change supabase/, which never
122
+ * get here.
123
+ */
124
+ export async function release(gitBranch, deps, pr) {
125
+ const { config, runner } = deps;
126
+ const found = await deps.cloudflare.findPreview(previewKey(gitBranch, config, pr));
127
+ // Workers Builds creates the record before the first deploy, and wrangler cannot list secrets until one exists.
128
+ const preview = found?.deployed_on ? found : undefined;
129
+ if (preview && runner.dryRun)
130
+ runner.log(` remove ${OVERRIDE_SECRET} from Preview ${preview.name} if set`);
131
+ else if (preview && deps.cloudflare.listPreviewSecrets(preview.name).includes(OVERRIDE_SECRET)) {
132
+ deps.cloudflare.deletePreviewSecret(preview.name, OVERRIDE_SECRET);
133
+ runner.log(` removed ${OVERRIDE_SECRET} from Preview ${preview.name}; it serves ${config.sharedBranch} again`);
134
+ }
135
+ const branch = await branchFor(gitBranch, deps);
136
+ if (!branch || !isDisposable(branch, config))
137
+ return;
138
+ if (runner.dryRun) {
139
+ runner.log(` DELETE Supabase branch ${branch.name} (${branch.project_ref})`);
140
+ return;
141
+ }
142
+ assertIsolated(branch, config.supabaseProjectRef);
143
+ await deps.supabase.deleteBranch(branch.project_ref);
144
+ runner.log(` Supabase branch ${branch.name} deleted; ${gitBranch} no longer needs its own database`);
145
+ }
146
+ /** Which database a deployed Preview serves, from the identity route or, failing that, its HTML. */
147
+ export async function servedRef(baseUrl, deps) {
148
+ const identity = await deps.fetchImpl(`${baseUrl}${IDENTITY_PATH}`).catch(() => null);
149
+ if (identity?.ok && (identity.headers.get("content-type") ?? "").includes("json")) {
150
+ const body = (await identity.json());
151
+ if (body.projectRef)
152
+ return { ref: body.projectRef, seen: body.projectRef };
153
+ }
154
+ const page = await deps.fetchImpl(`${baseUrl}${deps.config.checkPath}`).catch(() => null);
155
+ if (!page?.ok)
156
+ return { seen: `HTTP ${page?.status ?? "error"}` };
157
+ const refs = new Set([...(await page.text()).matchAll(/https:\/\/([a-z0-9]{20})\.supabase\.co/g)].map((m) => m[1]));
158
+ if (refs.size > 1)
159
+ return { seen: `several databases in the page: ${[...refs].join(", ")}` };
160
+ const [ref] = refs;
161
+ return ref ? { ref, seen: ref } : { seen: "no Supabase URL in the page" };
162
+ }
163
+ /**
164
+ * Fails unless the Preview serves the database it should: its own branch
165
+ * when isolated, else the shared one. Production fails at once; anything
166
+ * else is retried while Workers Builds and `up` finish.
167
+ */
168
+ export async function check(gitBranch, isolated, deps, pr) {
169
+ const { config, runner, supabase } = deps;
170
+ const parent = config.supabaseProjectRef;
171
+ const want = isolated ? `the branch for ${gitBranch}` : config.sharedBranch;
172
+ if (runner.dryRun) {
173
+ runner.log(` the Preview of ${gitBranch} must serve ${want}`);
174
+ return;
175
+ }
176
+ const expected = isolated
177
+ ? await branchFor(gitBranch, deps)
178
+ : (await supabase.listBranches(parent)).find((b) => b.name === config.sharedBranch);
179
+ if (!expected)
180
+ throw new Error(`No Supabase branch for ${want} on ${parent}`);
181
+ assertIsolated(expected, parent);
182
+ let seen = "nothing yet";
183
+ for (let attempt = 1; attempt <= 45; attempt += 1) {
184
+ const preview = await deps.cloudflare.findPreview(previewKey(gitBranch, config, pr));
185
+ if (!preview)
186
+ seen = "no Preview yet";
187
+ else if (!preview.deployed_on)
188
+ seen = `Preview ${preview.slug} exists but was never deployed`;
189
+ else {
190
+ const result = await servedRef(previewUrlOf(preview), deps);
191
+ if (result.ref === parent)
192
+ throw new Error(`Preview ${preview.slug} serves the production database ${parent}; refusing to pass`);
193
+ if (result.ref === expected.project_ref) {
194
+ runner.log(` Preview ${preview.slug} runs on ${expected.name} (${result.ref})`);
195
+ return;
196
+ }
197
+ seen = result.seen;
198
+ }
199
+ await deps.sleep(20_000);
200
+ }
201
+ throw new Error(`The Preview of ${gitBranch} never served ${want} (${expected.project_ref}); last saw ${seen}`);
202
+ }
203
+ /** Removes the Preview and, if the integration has not already, its own database. */
204
+ export async function down(gitBranch, deps, pr) {
205
+ const { config, runner } = deps;
206
+ runner.log(`Removing the Preview of ${gitBranch} and any database of its own`);
207
+ const branch = await branchFor(gitBranch, deps);
208
+ if (!branch || branch.persistent) {
209
+ runner.log(` no Supabase branch of its own`);
210
+ }
211
+ else if (runner.dryRun) {
212
+ runner.log(` DELETE Supabase branch ${branch.name} (${branch.project_ref})`);
213
+ }
214
+ else {
215
+ assertIsolated(branch, config.supabaseProjectRef);
216
+ await deps.supabase.deleteBranch(branch.project_ref);
217
+ runner.log(` Supabase branch ${branch.name} deleted`);
218
+ }
219
+ const preview = await deps.cloudflare.findPreview(previewKey(gitBranch, config, pr));
220
+ if (!preview)
221
+ runner.log(` no Preview to delete`);
222
+ else if (runner.dryRun)
223
+ runner.log(` delete Preview ${preview.name}`);
224
+ else
225
+ deps.cloudflare.deletePreview(preview.name);
226
+ }
227
+ /** On a PR's first push, Workers Builds may still be creating the Preview. */
228
+ async function waitForPreview(name, deps) {
229
+ for (let attempt = 1; attempt <= 30; attempt += 1) {
230
+ const preview = await deps.cloudflare.findPreview(name);
231
+ if (preview?.deployed_on)
232
+ return preview;
233
+ deps.runner.log(` waiting for the Preview ${name} to be deployed (${attempt})`);
234
+ await deps.sleep(20_000);
235
+ }
236
+ throw new Error(`No deployed Preview ${name} appeared; is a Preview being deployed for it?`);
237
+ }
238
+ function previewUrlOf(preview) {
239
+ const url = preview.urls[0];
240
+ if (!url)
241
+ throw new Error(`Preview ${preview.slug} has no URL`);
242
+ return url.replace(/\/+$/, "");
243
+ }
244
+ /**
245
+ * Ready when the branch holds every migration in this checkout. Branch status
246
+ * runs ahead of the migrations, so the status alone is not enough.
247
+ */
248
+ export async function waitForMigrations(ref, deps) {
249
+ const want = deps.migrationCount();
250
+ const now = deps.now ?? Date.now;
251
+ const started = now();
252
+ let last = "not created yet";
253
+ while (now() - started < 15 * 60_000) {
254
+ const detail = await deps.supabase.getBranch(ref).catch((err) => {
255
+ if (/\b404\b/.test(err.message))
256
+ return null;
257
+ throw err;
258
+ });
259
+ if (detail) {
260
+ const branch = (await deps.supabase.listBranches(deps.config.supabaseProjectRef)).find((b) => b.project_ref === ref);
261
+ const status = branch?.status ?? detail.status;
262
+ if (BRANCH_FAILED.includes(status))
263
+ throw new Error(`Supabase branch ${ref} ended ${status}`);
264
+ const rows = await deps.supabase.query(ref, MIGRATION_COUNT_SQL).catch(() => []);
265
+ const have = rows[0]?.n ?? 0;
266
+ if (have >= want)
267
+ return;
268
+ last = `${have} of ${want} migrations, ${status}`;
269
+ }
270
+ await deps.sleep(10_000);
271
+ }
272
+ throw new Error(`Supabase branch ${ref} not ready after 15 minutes: ${last}`);
273
+ }
@@ -0,0 +1,24 @@
1
+ import { type Config } from "../config.js";
2
+ import { type Style } from "../style.js";
3
+ import type { SupabaseApi } from "../supabase.js";
4
+ /** `fix` says what to do next, for a warning or an error. */
5
+ export type Finding = {
6
+ level: "ok" | "warn" | "error";
7
+ message: string;
8
+ fix?: string;
9
+ };
10
+ export declare function hasDefaultPrivileges(sql: string): boolean;
11
+ export declare function checkLocal(config: Config, cwd?: string): Finding[];
12
+ export declare function checkRemote(config: Config, supabase: SupabaseApi, cwd?: string): Promise<Finding[]>;
13
+ export declare function compareVersions(version: string, min: number[]): number;
14
+ export type Section = {
15
+ title: string;
16
+ findings: Finding[];
17
+ skipped?: string;
18
+ };
19
+ /** Local checks always; online checks when there is a token and a usable config. */
20
+ export declare function doctor(overrides: Partial<Config>, supabase: SupabaseApi | undefined, cwd?: string): Promise<Section[]>;
21
+ export declare function formatReport(sections: Section[], s?: Style): {
22
+ text: string;
23
+ pass: boolean;
24
+ };
@@ -0,0 +1,289 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CONFIG_DEFAULTS, defined, loadConfig, migrationFiles, migrationsDir, readWranglerConfig, } from "../config.js";
4
+ import { TOKENS_URL } from "../http.js";
5
+ import { projectRefOf } from "../runtime.js";
6
+ import { makeStyle } from "../style.js";
7
+ const MIN_WRANGLER = [4, 135, 0];
8
+ /** Binding keys a Preview does not inherit from the top-level config. */
9
+ const BINDING_KEYS = [
10
+ "kv_namespaces",
11
+ "d1_databases",
12
+ "r2_buckets",
13
+ "durable_objects",
14
+ "queues",
15
+ "services",
16
+ "ratelimits",
17
+ "vectorize",
18
+ "hyperdrive",
19
+ "ai",
20
+ "browser",
21
+ "images",
22
+ "analytics_engine_datasets",
23
+ "send_email",
24
+ "workflows",
25
+ "dispatch_namespaces",
26
+ "mtls_certificates",
27
+ "secrets_store_secrets",
28
+ "version_metadata",
29
+ ];
30
+ const SECRET_NAMES = ["SUPABASE_SERVICE_ROLE_KEY", "SUPABASE_OVERRIDE"];
31
+ export function hasDefaultPrivileges(sql) {
32
+ const s = sql.toLowerCase().replace(/\s+/g, " ");
33
+ return /alter default privileges/.test(s) && /grant all on tables to [^;]*\b(anon|authenticated)\b/.test(s);
34
+ }
35
+ export function checkLocal(config, cwd = process.cwd()) {
36
+ const out = [];
37
+ const ok = (message) => out.push({ level: "ok", message });
38
+ const warn = (message, fix) => out.push({ level: "warn", message, fix });
39
+ const error = (message, fix) => out.push({ level: "error", message, fix });
40
+ const shared = "Run `swp shared` and paste the values it prints into previews.vars.";
41
+ const version = installedWranglerVersion(cwd);
42
+ if (!version)
43
+ warn("wrangler is not installed in this project", "npm install --save-dev wrangler@latest (4.135.0 or later)");
44
+ else if (compareVersions(version, MIN_WRANGLER) < 0)
45
+ error(`wrangler ${version} predates Worker Previews`, "npm install --save-dev wrangler@latest (4.135.0 or later)");
46
+ else
47
+ ok(`wrangler ${version}`);
48
+ const wrangler = readWranglerConfig(cwd);
49
+ if (!wrangler)
50
+ error("no wrangler.jsonc, wrangler.json or wrangler.toml here", "Run swp doctor in the Worker's directory.");
51
+ else {
52
+ const previews = wrangler.json.previews;
53
+ if (!previews)
54
+ error(`${wrangler.file} has no "previews" block, so Previews get no Supabase settings`, 'Add "previews": { "vars": {} }, redeclaring every binding the Worker uses, then run `swp shared` for the vars.');
55
+ else {
56
+ const vars = (previews.vars ?? {});
57
+ const url = typeof vars.SUPABASE_URL === "string" ? vars.SUPABASE_URL : undefined;
58
+ if (!url || !vars.SUPABASE_PUBLISHABLE_KEY)
59
+ error("previews.vars needs SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY", shared);
60
+ else if (projectRefOf(url) === config.supabaseProjectRef)
61
+ error(`previews.vars points at the production project ${config.supabaseProjectRef}`, shared);
62
+ else
63
+ ok(`previews.vars uses ${projectRefOf(url) ?? url}`);
64
+ for (const name of SECRET_NAMES)
65
+ if (name in vars)
66
+ error(`previews.vars holds ${name}; secrets belong in the Preview base config`, `Remove ${name} from previews.vars (swp stores it as a secret), and rotate the key if it was committed.`);
67
+ for (const key of BINDING_KEYS) {
68
+ if (!(key in wrangler.json) || key in previews)
69
+ continue;
70
+ warn(`"${key}" is bound at the top level but not in "previews"; Previews do not inherit bindings`, `Redeclare "${key}" under "previews", pointing at Preview-safe resources.`);
71
+ }
72
+ const topVars = Object.keys(wrangler.json.vars ?? {}).filter((k) => !(k in vars));
73
+ if (topVars.length)
74
+ warn(`vars missing from previews.vars: ${topVars.join(", ")}`, "Copy them into previews.vars; Previews do not inherit vars.");
75
+ }
76
+ const assets = wrangler.json.assets;
77
+ const html = assets?.directory ? htmlFiles(join(cwd, assets.directory)) : [];
78
+ if (html.length && !assets?.run_worker_first)
79
+ warn(`${assets.directory} has ${html.join(", ")}; Cloudflare serves matching assets without running the Worker, ` +
80
+ "so those pages get no Supabase config and the browser falls back to build-time values", 'Set "run_worker_first": true under "assets" (or list the HTML routes).');
81
+ }
82
+ const migrations = migrationFiles(config, cwd);
83
+ if (!migrations.length)
84
+ warn(`no migrations in ${migrationsDir(config)}`, 'Commit your migrations there, or set "supabaseDir" in swp.config.json.');
85
+ else {
86
+ const first = readFileSync(join(cwd, migrationsDir(config), migrations[0]), "utf8");
87
+ if (hasDefaultPrivileges(first))
88
+ ok(`${migrations[0]} grants the API roles default privileges`);
89
+ else
90
+ error(`the first migration (${migrations[0]}) does not grant default privileges; branch databases will 403`, "Run `swp init` to add the grants migration before it.");
91
+ }
92
+ const toml = join(cwd, config.supabaseDir, "config.toml");
93
+ if (existsSync(toml)) {
94
+ const text = readFileSync(toml, "utf8");
95
+ if (!/additional_redirect_urls[^\]]*workers\.dev/s.test(text))
96
+ warn(`${config.supabaseDir}/config.toml additional_redirect_urls has no workers.dev entry; the integration resets branch auth URLs from it on every push`, `Add "https://*-${config.worker || "<worker>"}.<subdomain>.workers.dev/**" to [auth] additional_redirect_urls.`);
97
+ }
98
+ return out;
99
+ }
100
+ export async function checkRemote(config, supabase, cwd = process.cwd()) {
101
+ const out = [];
102
+ const parent = config.supabaseProjectRef;
103
+ try {
104
+ await supabase.getProject(parent);
105
+ }
106
+ catch (err) {
107
+ const status = err.status;
108
+ return [
109
+ {
110
+ level: "error",
111
+ message: `cannot read project ${parent}: ${err.message}`,
112
+ ...(status === 404 && {
113
+ fix: `Check "supabaseProjectRef" in swp.config.json, and that SUPABASE_ACCESS_TOKEN can reach that project.`,
114
+ }),
115
+ },
116
+ ];
117
+ }
118
+ let branches;
119
+ try {
120
+ branches = await supabase.listBranches(parent);
121
+ }
122
+ catch (err) {
123
+ return [
124
+ {
125
+ level: "error",
126
+ message: `cannot list the branches of ${parent}: ${err.message}`,
127
+ fix: `The token needs Development Branches: Read-write (${TOKENS_URL}).`,
128
+ },
129
+ ];
130
+ }
131
+ if (!branches.some((b) => b.is_default))
132
+ out.push({
133
+ level: "error",
134
+ message: `branching is not enabled on ${parent}`,
135
+ fix: "Supabase dashboard, Project Settings, Integrations, GitHub: connect the repo with automatic branching on.",
136
+ });
137
+ const shared = branches.find((b) => b.name === config.sharedBranch);
138
+ const finding = await githubFinding(branches.filter((b) => !b.is_default && b.project_ref !== parent), shared, supabase);
139
+ if (finding)
140
+ out.push(finding);
141
+ if (!shared) {
142
+ out.push({
143
+ level: "error",
144
+ message: `no "${config.sharedBranch}" branch, the shared Preview database`,
145
+ fix: "Run `swp shared` to create it.",
146
+ });
147
+ return out;
148
+ }
149
+ if (shared.project_ref === parent || shared.is_default)
150
+ out.push({
151
+ level: "error",
152
+ message: `"${config.sharedBranch}" is the production project itself`,
153
+ fix: 'Set "sharedBranch" in swp.config.json to another name, then run `swp shared`.',
154
+ });
155
+ if (!shared.persistent)
156
+ out.push({
157
+ level: "warn",
158
+ message: `"${config.sharedBranch}" is not persistent`,
159
+ fix: "Run `swp shared` to make it persistent.",
160
+ });
161
+ if (shared.git_branch !== config.trunk)
162
+ out.push({
163
+ level: "error",
164
+ message: `"${config.sharedBranch}" tracks ${shared.git_branch ?? "no git branch"}, not ${config.trunk}`,
165
+ fix: `Run \`swp shared\` to point it at ${config.trunk}.`,
166
+ });
167
+ const vars = readWranglerConfig(cwd)?.json.previews
168
+ ?.vars;
169
+ const varsRef = projectRefOf(vars?.SUPABASE_URL);
170
+ if (varsRef && varsRef !== shared.project_ref)
171
+ out.push({
172
+ level: "error",
173
+ message: `previews.vars uses ${varsRef}, but "${config.sharedBranch}" is ${shared.project_ref}`,
174
+ fix: "Run `swp shared` and paste the values it prints into previews.vars.",
175
+ });
176
+ else if (varsRef)
177
+ out.push({
178
+ level: "ok",
179
+ message: `"${config.sharedBranch}" (${shared.project_ref}) tracks ${config.trunk}`,
180
+ });
181
+ return out;
182
+ }
183
+ /**
184
+ * The Management API does not say whether a project is connected to GitHub.
185
+ * Runs the integration made carry the repo in `git_config`, so look for one.
186
+ */
187
+ async function githubFinding(branches, shared, supabase) {
188
+ const ordered = [...(shared ? [shared] : []), ...branches.filter((b) => b !== shared)].slice(0, 5);
189
+ if (!ordered.length)
190
+ return null;
191
+ let runs = 0;
192
+ for (const branch of ordered) {
193
+ const list = await supabase.actionRuns(branch.project_ref).catch(() => null);
194
+ if (!list)
195
+ continue;
196
+ runs += list.length;
197
+ const git = list.find((r) => r.git_config?.repo)?.git_config;
198
+ if (git)
199
+ return {
200
+ level: "ok",
201
+ message: `Supabase builds "${branch.name}" from GitHub ${git.owner}/${git.repo}, so branches run the repo's migrations`,
202
+ };
203
+ }
204
+ const names = ordered.map((b) => `"${b.name}"`).join(", ");
205
+ return {
206
+ level: "warn",
207
+ message: `cannot confirm the Supabase GitHub integration: ${runs ? `none of the ${runs} runs` : "no runs"} on ${names} came from GitHub. ` +
208
+ "Branching without Git copies the schema without privileges, so signed-in reads 403",
209
+ fix: "Connect the repo under Integrations, GitHub; if it is connected, push to the trunk and run doctor again.",
210
+ };
211
+ }
212
+ function htmlFiles(dir) {
213
+ return existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".html")) : [];
214
+ }
215
+ function installedWranglerVersion(cwd) {
216
+ const path = join(cwd, "node_modules", "wrangler", "package.json");
217
+ return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")).version : null;
218
+ }
219
+ export function compareVersions(version, min) {
220
+ const parts = version.split(/[.-]/).map((p) => Number.parseInt(p, 10));
221
+ for (let i = 0; i < min.length; i++) {
222
+ const diff = (parts[i] ?? 0) - min[i];
223
+ if (diff)
224
+ return diff;
225
+ }
226
+ return 0;
227
+ }
228
+ /** Local checks always; online checks when there is a token and a usable config. */
229
+ export async function doctor(overrides, supabase, cwd = process.cwd()) {
230
+ const local = { title: "Local files", findings: [] };
231
+ const online = { title: "Supabase (online)", findings: [] };
232
+ let config;
233
+ try {
234
+ config = loadConfig(overrides, cwd);
235
+ }
236
+ catch (err) {
237
+ local.findings.push({ level: "error", message: err.message });
238
+ }
239
+ try {
240
+ local.findings.push(...checkLocal(config ?? { ...CONFIG_DEFAULTS, worker: "", supabaseProjectRef: "", ...defined(overrides) }, cwd));
241
+ }
242
+ catch (err) {
243
+ local.findings.push({ level: "error", message: err.message });
244
+ }
245
+ if (!config)
246
+ online.skipped = "skipped until the config above is fixed";
247
+ else if (!supabase)
248
+ online.skipped =
249
+ "skipped: SUPABASE_ACCESS_TOKEN is not set. Add it to .env.swp to also check branching, " +
250
+ `the shared database and the GitHub integration (${TOKENS_URL}).`;
251
+ else {
252
+ online.title = `Supabase (online, project ${config.supabaseProjectRef})`;
253
+ try {
254
+ online.findings.push(...(await checkRemote(config, supabase, cwd)));
255
+ }
256
+ catch (err) {
257
+ online.findings.push({ level: "error", message: err.message });
258
+ }
259
+ }
260
+ return [local, online];
261
+ }
262
+ const MARKS = { ok: "✓", warn: "!", error: "✗" };
263
+ export function formatReport(sections, s = makeStyle(false)) {
264
+ const color = { ok: s.green, warn: s.yellow, error: s.red };
265
+ const lines = [];
266
+ for (const section of sections) {
267
+ if (lines.length)
268
+ lines.push("");
269
+ lines.push(s.bold(section.title));
270
+ for (const f of section.findings) {
271
+ lines.push(` ${color[f.level](MARKS[f.level])} ${f.message}`);
272
+ if (f.fix && f.level !== "ok")
273
+ lines.push(` ${s.dim(`→ ${f.fix}`)}`);
274
+ }
275
+ if (section.skipped)
276
+ lines.push(` ${s.dim(`- ${section.skipped}`)}`);
277
+ }
278
+ const all = sections.flatMap((section) => section.findings);
279
+ const errors = all.filter((f) => f.level === "error").length;
280
+ const warnings = all.filter((f) => f.level === "warn").length;
281
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
282
+ const skipped = sections.some((section) => section.skipped) ? " (online checks skipped)" : "";
283
+ lines.push("", errors
284
+ ? s.red(`${plural(errors, "error")}, ${plural(warnings, "warning")}${skipped}. Fix the errors and run swp doctor again.`)
285
+ : warnings
286
+ ? s.yellow(`No errors, ${plural(warnings, "warning")}${skipped}.`)
287
+ : s.green(`All checks passed${skipped}.`));
288
+ return { text: lines.join("\n"), pass: errors === 0 };
289
+ }
@@ -0,0 +1,11 @@
1
+ export type InitOptions = {
2
+ supabaseProjectRef?: string;
3
+ trunk?: string;
4
+ /** Write the workflow that uses the published GitHub Action. */
5
+ action?: boolean;
6
+ dryRun?: boolean;
7
+ log?: (line: string) => void;
8
+ };
9
+ /** `20260914000000_x.sql` minus one second, so a new migration sorts first. */
10
+ export declare function timestampBefore(file: string): string;
11
+ export declare function init(options?: InitOptions, cwd?: string): void;