supabase-worker-previews 0.0.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/LICENSE +21 -0
- package/README.md +143 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +152 -0
- package/dist/cloudflare.d.ts +37 -0
- package/dist/cloudflare.js +95 -0
- package/dist/commands/branches.d.ts +62 -0
- package/dist/commands/branches.js +273 -0
- package/dist/commands/doctor.d.ts +24 -0
- package/dist/commands/doctor.js +289 -0
- package/dist/commands/init.d.ts +11 -0
- package/dist/commands/init.js +132 -0
- package/dist/commands/pr.d.ts +37 -0
- package/dist/commands/pr.js +78 -0
- package/dist/commands/prune.d.ts +32 -0
- package/dist/commands/prune.js +110 -0
- package/dist/config.d.ts +50 -0
- package/dist/config.js +105 -0
- package/dist/feedback.d.ts +64 -0
- package/dist/feedback.js +238 -0
- package/dist/github.d.ts +33 -0
- package/dist/github.js +78 -0
- package/dist/http.d.ts +7 -0
- package/dist/http.js +44 -0
- package/dist/jsonc.d.ts +2 -0
- package/dist/jsonc.js +56 -0
- package/dist/preview-name.d.ts +3 -0
- package/dist/preview-name.js +15 -0
- package/dist/run.d.ts +21 -0
- package/dist/run.js +59 -0
- package/dist/runtime.d.ts +56 -0
- package/dist/runtime.js +121 -0
- package/dist/style.d.ts +8 -0
- package/dist/style.js +21 -0
- package/dist/suggest.d.ts +2 -0
- package/dist/suggest.js +22 -0
- package/dist/supabase.d.ts +78 -0
- package/dist/supabase.js +168 -0
- package/dist/usage.d.ts +46 -0
- package/dist/usage.js +297 -0
- package/package.json +84 -9
- package/templates/default-privileges.sql +9 -0
- package/templates/supabase-previews-action.yml +29 -0
- package/templates/supabase-previews.yml +38 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { CONFIG_FILE, migrationFiles, migrationsDir, PLACEHOLDER_REF, readWranglerConfig, WRANGLER_FILES, } from "../config.js";
|
|
6
|
+
import { QUICKSTART_URL, TOKENS_URL } from "../http.js";
|
|
7
|
+
import { hasDefaultPrivileges } from "./doctor.js";
|
|
8
|
+
const TEMPLATES = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "templates");
|
|
9
|
+
const WORKFLOW = ".github/workflows/supabase-previews.yml";
|
|
10
|
+
/** `20260914000000_x.sql` minus one second, so a new migration sorts first. */
|
|
11
|
+
export function timestampBefore(file) {
|
|
12
|
+
const m = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(file);
|
|
13
|
+
if (!m)
|
|
14
|
+
throw new Error(`${file} does not start with a YYYYMMDDHHMMSS timestamp`);
|
|
15
|
+
const [, y, mo, d, h, mi, s] = m.map(Number);
|
|
16
|
+
return stamp(new Date(Date.UTC(y, mo - 1, d, h, mi, s) - 1000));
|
|
17
|
+
}
|
|
18
|
+
function stamp(date) {
|
|
19
|
+
return date.toISOString().replace(/[-:T]/g, "").slice(0, 14);
|
|
20
|
+
}
|
|
21
|
+
function defaultTrunk(cwd) {
|
|
22
|
+
try {
|
|
23
|
+
return execFileSync("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], {
|
|
24
|
+
cwd,
|
|
25
|
+
encoding: "utf8",
|
|
26
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
27
|
+
})
|
|
28
|
+
.trim()
|
|
29
|
+
.replace(/^origin\//, "");
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return "main";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function hasProjectRef(json) {
|
|
36
|
+
try {
|
|
37
|
+
const ref = JSON.parse(json).supabaseProjectRef;
|
|
38
|
+
return typeof ref === "string" && !!ref && ref !== PLACEHOLDER_REF;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function linkedProjectRef(cwd) {
|
|
45
|
+
const path = join(cwd, "supabase", ".temp", "project-ref");
|
|
46
|
+
return existsSync(path) ? readFileSync(path, "utf8").trim() : undefined;
|
|
47
|
+
}
|
|
48
|
+
export function init(options = {}, cwd = process.cwd()) {
|
|
49
|
+
const log = options.log ?? console.log;
|
|
50
|
+
const wrangler = readWranglerConfig(cwd);
|
|
51
|
+
if (!wrangler)
|
|
52
|
+
throw new Error(`No ${WRANGLER_FILES.join(", ")} in this directory. Run swp init in the Worker's directory.`);
|
|
53
|
+
if (!wrangler.name)
|
|
54
|
+
throw new Error(`${wrangler.file} has no "name". Add the Worker's name, then run swp init again.`);
|
|
55
|
+
const write = (path, content) => {
|
|
56
|
+
if (options.dryRun)
|
|
57
|
+
return;
|
|
58
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
59
|
+
writeFileSync(path, content);
|
|
60
|
+
};
|
|
61
|
+
if (options.dryRun)
|
|
62
|
+
log("Dry run: nothing is written.\n");
|
|
63
|
+
const configPath = join(cwd, CONFIG_FILE);
|
|
64
|
+
let needsRef;
|
|
65
|
+
if (existsSync(configPath)) {
|
|
66
|
+
needsRef = !hasProjectRef(readFileSync(configPath, "utf8"));
|
|
67
|
+
log(`- ${CONFIG_FILE} exists, left alone`);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
const linked = linkedProjectRef(cwd);
|
|
71
|
+
const ref = options.supabaseProjectRef ?? linked;
|
|
72
|
+
needsRef = !ref;
|
|
73
|
+
const config = {
|
|
74
|
+
supabaseProjectRef: ref ?? PLACEHOLDER_REF,
|
|
75
|
+
trunk: options.trunk ?? defaultTrunk(cwd),
|
|
76
|
+
};
|
|
77
|
+
write(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
78
|
+
const note = !ref
|
|
79
|
+
? " (fill in supabaseProjectRef)"
|
|
80
|
+
: ref === linked && !options.supabaseProjectRef
|
|
81
|
+
? " (project ref from supabase link)"
|
|
82
|
+
: "";
|
|
83
|
+
log(`+ ${CONFIG_FILE}${note}`);
|
|
84
|
+
}
|
|
85
|
+
const config = { supabaseDir: "supabase" };
|
|
86
|
+
const migrations = migrationFiles(config, cwd);
|
|
87
|
+
const first = migrations[0];
|
|
88
|
+
if (first && hasDefaultPrivileges(readFileSync(join(cwd, migrationsDir(config), first), "utf8"))) {
|
|
89
|
+
log(`- ${first} already grants default privileges`);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
const name = `${first ? timestampBefore(first) : stamp(new Date())}_api_default_privileges.sql`;
|
|
93
|
+
write(join(cwd, migrationsDir(config), name), readFileSync(join(TEMPLATES, "default-privileges.sql")));
|
|
94
|
+
log(`+ ${migrationsDir(config)}/${name}`);
|
|
95
|
+
if (first)
|
|
96
|
+
log(" It is dated before your existing migrations. Production has not run it: apply it with\n" +
|
|
97
|
+
` \`supabase db push --include-all\`, or run the SQL once and \`supabase migration repair --status applied ${name.slice(0, 14)}\`.`);
|
|
98
|
+
}
|
|
99
|
+
const workflowPath = join(cwd, WORKFLOW);
|
|
100
|
+
if (existsSync(workflowPath))
|
|
101
|
+
log(`- ${WORKFLOW} exists, left alone`);
|
|
102
|
+
else {
|
|
103
|
+
write(workflowPath, readFileSync(join(TEMPLATES, options.action ? "supabase-previews-action.yml" : "supabase-previews.yml")));
|
|
104
|
+
log(`+ ${WORKFLOW}`);
|
|
105
|
+
log(options.action
|
|
106
|
+
? " It uses the published action, mattruby/supabase-worker-previews@v0."
|
|
107
|
+
: " It runs `npx supabase-worker-previews pr`; `npx supabase-worker-previews init --action` writes one that uses the published action instead.");
|
|
108
|
+
}
|
|
109
|
+
const steps = [
|
|
110
|
+
...(needsRef
|
|
111
|
+
? [`Set "supabaseProjectRef" in ${CONFIG_FILE}: the <ref> in https://<ref>.supabase.co.`]
|
|
112
|
+
: []),
|
|
113
|
+
"Supabase dashboard, Project Settings, Integrations, GitHub: connect the repo, with\n" +
|
|
114
|
+
'automatic branching on and "Deploy to production" off.',
|
|
115
|
+
`In supabase/config.toml, add "https://*-${wrangler.name}.<subdomain>.workers.dev/**" to\n` +
|
|
116
|
+
"[auth] additional_redirect_urls.",
|
|
117
|
+
...(wrangler.json.previews
|
|
118
|
+
? []
|
|
119
|
+
: [`Add a "previews" block to ${wrangler.file}, redeclaring every binding the Worker uses.`]),
|
|
120
|
+
"Put SUPABASE_ACCESS_TOKEN, CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID in .env.swp\n" +
|
|
121
|
+
`(git-ignore it; scopes: ${TOKENS_URL}),\n` +
|
|
122
|
+
"then run `npx supabase-worker-previews shared` and paste the previews.vars it prints.",
|
|
123
|
+
"Wrap the Worker's default export:\n" +
|
|
124
|
+
' import { withSupabasePreviews } from "supabase-worker-previews";\n' +
|
|
125
|
+
" export default withSupabasePreviews(app);",
|
|
126
|
+
"Workers Builds: turn on non-production branch builds, deploy command `npx wrangler preview`.",
|
|
127
|
+
"GitHub repo, Settings, Secrets and variables, Actions: add the same three tokens.",
|
|
128
|
+
"Run `npx supabase-worker-previews doctor` until it passes, then commit and open a PR.",
|
|
129
|
+
];
|
|
130
|
+
log(`\nNext, by hand (the full guide: ${QUICKSTART_URL}):`);
|
|
131
|
+
steps.forEach((step, i) => log(` ${i + 1}. ${step.replace(/\n/g, "\n ")}`));
|
|
132
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Config } from "../config.js";
|
|
2
|
+
import { type FeedbackDeps } from "../feedback.js";
|
|
3
|
+
export type PullRequestEvent = {
|
|
4
|
+
action: string;
|
|
5
|
+
number: number;
|
|
6
|
+
pull_request: {
|
|
7
|
+
head: {
|
|
8
|
+
ref: string;
|
|
9
|
+
sha: string;
|
|
10
|
+
repo?: {
|
|
11
|
+
full_name: string;
|
|
12
|
+
} | null;
|
|
13
|
+
};
|
|
14
|
+
labels: {
|
|
15
|
+
name: string;
|
|
16
|
+
}[];
|
|
17
|
+
};
|
|
18
|
+
repository: {
|
|
19
|
+
full_name: string;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
export declare function readEvent(path?: string | undefined): PullRequestEvent;
|
|
23
|
+
export declare const PR_ENV: string[];
|
|
24
|
+
export type Skip = {
|
|
25
|
+
level: "notice" | "warning" | "error";
|
|
26
|
+
message: string;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* A fork PR, or a bot PR without secrets (Dependabot gets none), is skipped with an annotation.
|
|
30
|
+
* Anyone else's PR without secrets fails, so a misconfigured repo never shows a green check.
|
|
31
|
+
*/
|
|
32
|
+
export declare function skipReason(event: PullRequestEvent, env?: Record<string, string | undefined>): Skip | null;
|
|
33
|
+
export declare function annotation(skip: Skip): string;
|
|
34
|
+
export declare function changedFiles(event: PullRequestEvent, token: string, fetchImpl: typeof fetch): Promise<string[]>;
|
|
35
|
+
export declare function needsIsolatedDb(event: PullRequestEvent, files: string[], config: Config): boolean;
|
|
36
|
+
/** One entry point for a pull_request workflow: clean up on close, otherwise point and prove. */
|
|
37
|
+
export declare function pr(event: PullRequestEvent, deps: FeedbackDeps): Promise<void>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { Feedback } from "../feedback.js";
|
|
3
|
+
import { apiErrorMessage } from "../http.js";
|
|
4
|
+
import { check, down, release, up } from "./branches.js";
|
|
5
|
+
export function readEvent(path = process.env.GITHUB_EVENT_PATH) {
|
|
6
|
+
if (!path)
|
|
7
|
+
throw new Error("GITHUB_EVENT_PATH is not set: `swp pr` runs inside a pull_request workflow. To try one branch here, use `swp up` or `swp check`.");
|
|
8
|
+
const event = JSON.parse(readFileSync(path, "utf8"));
|
|
9
|
+
if (!event.pull_request)
|
|
10
|
+
throw new Error("`swp pr` needs a pull_request event");
|
|
11
|
+
return event;
|
|
12
|
+
}
|
|
13
|
+
export const PR_ENV = [
|
|
14
|
+
"SUPABASE_ACCESS_TOKEN",
|
|
15
|
+
"CLOUDFLARE_API_TOKEN",
|
|
16
|
+
"CLOUDFLARE_ACCOUNT_ID",
|
|
17
|
+
"GITHUB_TOKEN",
|
|
18
|
+
];
|
|
19
|
+
/**
|
|
20
|
+
* A fork PR, or a bot PR without secrets (Dependabot gets none), is skipped with an annotation.
|
|
21
|
+
* Anyone else's PR without secrets fails, so a misconfigured repo never shows a green check.
|
|
22
|
+
*/
|
|
23
|
+
export function skipReason(event, env = process.env) {
|
|
24
|
+
const head = event.pull_request.head.repo;
|
|
25
|
+
if (head !== undefined && head?.full_name !== event.repository.full_name)
|
|
26
|
+
return {
|
|
27
|
+
level: "notice",
|
|
28
|
+
message: `swp pr skipped PR #${event.number}: it comes from ${head ? `the fork ${head.full_name}` : "a deleted fork"}, and GitHub Actions gives fork PRs no secrets`,
|
|
29
|
+
};
|
|
30
|
+
const missing = PR_ENV.filter((name) => !env[name]);
|
|
31
|
+
if (missing.length) {
|
|
32
|
+
const bot = env.GITHUB_ACTOR?.endsWith("[bot]") ?? false;
|
|
33
|
+
return {
|
|
34
|
+
level: bot ? "warning" : "error",
|
|
35
|
+
message: `swp pr ${bot ? "skipped" : "cannot check"} PR #${event.number}: ${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} empty; add ${missing.length === 1 ? "it" : "them"} to the repository's Actions secrets`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
export function annotation(skip) {
|
|
41
|
+
return `::${skip.level}::${skip.message.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A")}`;
|
|
42
|
+
}
|
|
43
|
+
export async function changedFiles(event, token, fetchImpl) {
|
|
44
|
+
const files = [];
|
|
45
|
+
for (let page = 1;; page += 1) {
|
|
46
|
+
const res = await fetchImpl(`https://api.github.com/repos/${event.repository.full_name}/pulls/${event.number}/files?per_page=100&page=${page}`, { headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" } });
|
|
47
|
+
if (!res.ok)
|
|
48
|
+
throw new Error(apiErrorMessage("GitHub API GET PR files", res.status, await res.text(), "GITHUB_TOKEN"));
|
|
49
|
+
const batch = (await res.json());
|
|
50
|
+
for (const f of batch)
|
|
51
|
+
files.push(f.filename, ...(f.previous_filename ? [f.previous_filename] : []));
|
|
52
|
+
if (batch.length < 100)
|
|
53
|
+
return files;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function needsIsolatedDb(event, files, config) {
|
|
57
|
+
if (event.pull_request.labels.some((l) => l.name === config.isolatedLabel))
|
|
58
|
+
return true;
|
|
59
|
+
const dir = config.supabaseDir.replace(/\/+$/, "") + "/";
|
|
60
|
+
return files.some((f) => f.startsWith(dir));
|
|
61
|
+
}
|
|
62
|
+
/** One entry point for a pull_request workflow: clean up on close, otherwise point and prove. */
|
|
63
|
+
export async function pr(event, deps) {
|
|
64
|
+
const branch = event.pull_request.head.ref;
|
|
65
|
+
const feedback = new Feedback(event, deps);
|
|
66
|
+
const files = await changedFiles(event, deps.githubToken, deps.fetchImpl);
|
|
67
|
+
const isolated = needsIsolatedDb(event, files, deps.config);
|
|
68
|
+
if (event.action === "closed")
|
|
69
|
+
return feedback.close(() => down(branch, deps, event.number), isolated);
|
|
70
|
+
deps.runner.log(`PR #${event.number} (${branch}): ${isolated ? "its own database" : `the shared "${deps.config.sharedBranch}" database`}`);
|
|
71
|
+
await feedback.run(isolated, async () => {
|
|
72
|
+
if (isolated)
|
|
73
|
+
await up(branch, deps, event.number);
|
|
74
|
+
else
|
|
75
|
+
await release(branch, deps, event.number);
|
|
76
|
+
await check(branch, isolated, deps, event.number);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { PreviewRecord } from "../cloudflare.js";
|
|
2
|
+
import type { Config } from "../config.js";
|
|
3
|
+
import type { Branch } from "../supabase.js";
|
|
4
|
+
import { type Deps } from "./branches.js";
|
|
5
|
+
export type OpenPr = {
|
|
6
|
+
number: number;
|
|
7
|
+
head: string;
|
|
8
|
+
};
|
|
9
|
+
export type PrunePlan = {
|
|
10
|
+
branches: Branch[];
|
|
11
|
+
previews: PreviewRecord[];
|
|
12
|
+
};
|
|
13
|
+
/** What the repo says: its git branches, its open PRs, and the heads that only have closed PRs. */
|
|
14
|
+
export type RepoFacts = {
|
|
15
|
+
branches: string[];
|
|
16
|
+
open: OpenPr[];
|
|
17
|
+
closedHeads: string[];
|
|
18
|
+
};
|
|
19
|
+
export declare function openPullRequests(repo: string, token: string, fetchImpl: typeof fetch): Promise<OpenPr[]>;
|
|
20
|
+
export declare function repoBranches(repo: string, token: string, fetchImpl: typeof fetch): Promise<string[]>;
|
|
21
|
+
/**
|
|
22
|
+
* Leftovers. A disposable Supabase branch whose git branch is gone, or has a
|
|
23
|
+
* closed PR and no open one. A Preview whose git branch is gone, or with
|
|
24
|
+
* previewName "pr", a pr-<n> whose PR is not open. Never the trunk's Preview.
|
|
25
|
+
*/
|
|
26
|
+
export declare function planPrune(branches: Branch[], previews: PreviewRecord[], facts: RepoFacts, config: Config): PrunePlan;
|
|
27
|
+
/** Prints the leftovers; deletes them only with `yes`. */
|
|
28
|
+
export declare function prune(deps: Deps & {
|
|
29
|
+
githubToken: string;
|
|
30
|
+
repo: string;
|
|
31
|
+
yes: boolean;
|
|
32
|
+
}): Promise<PrunePlan>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { apiErrorMessage } from "../http.js";
|
|
2
|
+
import { previewName } from "../preview-name.js";
|
|
3
|
+
import { assertIsolated, isDisposable } from "./branches.js";
|
|
4
|
+
async function github(path, token, fetchImpl) {
|
|
5
|
+
const res = await fetchImpl(`https://api.github.com/repos/${path}`, {
|
|
6
|
+
headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
|
|
7
|
+
});
|
|
8
|
+
if (!res.ok)
|
|
9
|
+
throw new Error(apiErrorMessage(`GitHub API GET ${path}`, res.status, await res.text(), "GITHUB_TOKEN"));
|
|
10
|
+
return (await res.json());
|
|
11
|
+
}
|
|
12
|
+
async function allPages(path, token, fetchImpl) {
|
|
13
|
+
const all = [];
|
|
14
|
+
for (let page = 1;; page += 1) {
|
|
15
|
+
const sep = path.includes("?") ? "&" : "?";
|
|
16
|
+
const batch = await github(`${path}${sep}per_page=100&page=${page}`, token, fetchImpl);
|
|
17
|
+
all.push(...batch);
|
|
18
|
+
if (batch.length < 100)
|
|
19
|
+
return all;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export async function openPullRequests(repo, token, fetchImpl) {
|
|
23
|
+
const prs = await allPages(`${repo}/pulls?state=open`, token, fetchImpl);
|
|
24
|
+
return prs.map((p) => ({ number: p.number, head: p.head.ref }));
|
|
25
|
+
}
|
|
26
|
+
export async function repoBranches(repo, token, fetchImpl) {
|
|
27
|
+
return (await allPages(`${repo}/branches`, token, fetchImpl)).map((b) => b.name);
|
|
28
|
+
}
|
|
29
|
+
async function hasClosedPr(repo, ref, token, fetchImpl) {
|
|
30
|
+
const owner = repo.split("/")[0];
|
|
31
|
+
const head = encodeURIComponent(`${owner}:${ref}`);
|
|
32
|
+
return ((await github(`${repo}/pulls?state=closed&head=${head}&per_page=1`, token, fetchImpl)).length >
|
|
33
|
+
0);
|
|
34
|
+
}
|
|
35
|
+
function slugOf(branch) {
|
|
36
|
+
try {
|
|
37
|
+
return previewName(branch);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Leftovers. A disposable Supabase branch whose git branch is gone, or has a
|
|
45
|
+
* closed PR and no open one. A Preview whose git branch is gone, or with
|
|
46
|
+
* previewName "pr", a pr-<n> whose PR is not open. Never the trunk's Preview.
|
|
47
|
+
*/
|
|
48
|
+
export function planPrune(branches, previews, facts, config) {
|
|
49
|
+
const existing = new Set(facts.branches);
|
|
50
|
+
const existingSlugs = new Set(facts.branches.map(slugOf).filter((s) => s !== undefined));
|
|
51
|
+
const openHeads = new Set(facts.open.map((p) => p.head));
|
|
52
|
+
const closedHeads = new Set(facts.closedHeads);
|
|
53
|
+
const numbers = new Set(facts.open.map((p) => p.number));
|
|
54
|
+
const isTrunk = (p) => p.name === config.trunk || p.slug === slugOf(config.trunk);
|
|
55
|
+
return {
|
|
56
|
+
branches: branches.filter((b) => isDisposable(b, config) &&
|
|
57
|
+
!!b.git_branch &&
|
|
58
|
+
!openHeads.has(b.git_branch) &&
|
|
59
|
+
(!existing.has(b.git_branch) || closedHeads.has(b.git_branch))),
|
|
60
|
+
previews: previews.filter((p) => {
|
|
61
|
+
if (isTrunk(p))
|
|
62
|
+
return false;
|
|
63
|
+
if (config.previewName === "pr") {
|
|
64
|
+
const n = /^pr-(\d+)$/.exec(p.name)?.[1];
|
|
65
|
+
return n !== undefined && !numbers.has(Number(n));
|
|
66
|
+
}
|
|
67
|
+
return !existing.has(p.name) && !existingSlugs.has(p.slug);
|
|
68
|
+
}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** Prints the leftovers; deletes them only with `yes`. */
|
|
72
|
+
export async function prune(deps) {
|
|
73
|
+
const { config, runner } = deps;
|
|
74
|
+
const { repo, githubToken: token, fetchImpl } = deps;
|
|
75
|
+
const open = await openPullRequests(repo, token, fetchImpl);
|
|
76
|
+
const existing = await repoBranches(repo, token, fetchImpl);
|
|
77
|
+
const supabaseBranches = await deps.supabase.listBranches(config.supabaseProjectRef);
|
|
78
|
+
const openHeads = new Set(open.map((p) => p.head));
|
|
79
|
+
const closedHeads = [];
|
|
80
|
+
for (const gitBranch of new Set(supabaseBranches.filter((b) => isDisposable(b, config)).map((b) => b.git_branch))) {
|
|
81
|
+
if (!gitBranch || openHeads.has(gitBranch) || !existing.includes(gitBranch))
|
|
82
|
+
continue;
|
|
83
|
+
if (await hasClosedPr(repo, gitBranch, token, fetchImpl))
|
|
84
|
+
closedHeads.push(gitBranch);
|
|
85
|
+
}
|
|
86
|
+
const plan = planPrune(supabaseBranches, await deps.cloudflare.listPreviews(), { branches: existing, open, closedHeads }, config);
|
|
87
|
+
runner.log(`${repo} has ${existing.length} branches and ${open.length} open PRs`);
|
|
88
|
+
for (const b of plan.branches)
|
|
89
|
+
runner.log(` Supabase branch ${b.name} (${b.project_ref}), git branch ${b.git_branch}`);
|
|
90
|
+
for (const p of plan.previews)
|
|
91
|
+
runner.log(` Preview ${p.name}`);
|
|
92
|
+
if (!plan.branches.length && !plan.previews.length) {
|
|
93
|
+
runner.log("Nothing to prune");
|
|
94
|
+
return plan;
|
|
95
|
+
}
|
|
96
|
+
if (!deps.yes || runner.dryRun) {
|
|
97
|
+
runner.log("Run again with --yes to delete these");
|
|
98
|
+
return plan;
|
|
99
|
+
}
|
|
100
|
+
for (const b of plan.branches) {
|
|
101
|
+
assertIsolated(b, config.supabaseProjectRef);
|
|
102
|
+
await deps.supabase.deleteBranch(b.project_ref);
|
|
103
|
+
runner.log(` deleted Supabase branch ${b.name}`);
|
|
104
|
+
}
|
|
105
|
+
for (const p of plan.previews) {
|
|
106
|
+
deps.cloudflare.deletePreview(p.name);
|
|
107
|
+
runner.log(` deleted Preview ${p.name}`);
|
|
108
|
+
}
|
|
109
|
+
return plan;
|
|
110
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export declare const CONFIG_FILE = "swp.config.json";
|
|
2
|
+
export declare const WRANGLER_FILES: string[];
|
|
3
|
+
/** What `swp init` writes when it cannot find the project ref. */
|
|
4
|
+
export declare const PLACEHOLDER_REF = "<production project ref>";
|
|
5
|
+
export type Config = {
|
|
6
|
+
/** Worker name; defaults to `name` in the wrangler config. */
|
|
7
|
+
worker: string;
|
|
8
|
+
/** The parent (production) Supabase project. Previews must never serve it. */
|
|
9
|
+
supabaseProjectRef: string;
|
|
10
|
+
/** Git branch the shared Preview database tracks. */
|
|
11
|
+
trunk: string;
|
|
12
|
+
/** Name of the persistent Supabase branch every Preview shares by default. */
|
|
13
|
+
sharedBranch: string;
|
|
14
|
+
/** The account's workers.dev subdomain; looked up through the API when absent. */
|
|
15
|
+
workersSubdomain?: string;
|
|
16
|
+
supabaseDir: string;
|
|
17
|
+
/** PR label that asks for an isolated database without a schema change. */
|
|
18
|
+
isolatedLabel: string;
|
|
19
|
+
/** Path `check` fetches when the Worker does not serve the identity route. */
|
|
20
|
+
checkPath: string;
|
|
21
|
+
/** How Previews are named: after the raw git branch (Workers Builds), or `pr-<number>`. */
|
|
22
|
+
previewName: "branch" | "pr";
|
|
23
|
+
/** Which Supabase API keys to hand a Preview when a project has both kinds. */
|
|
24
|
+
apiKeys: "legacy" | "new";
|
|
25
|
+
/** `swp pr` keeps one status comment on the PR. Default true. */
|
|
26
|
+
prComment?: boolean;
|
|
27
|
+
/** `swp pr` records a GitHub deployment for the PR head. Default true. */
|
|
28
|
+
githubDeployments?: boolean;
|
|
29
|
+
/** GitHub environment of those deployments, shared by every PR; `{branch}` makes one per branch. Default "Preview". */
|
|
30
|
+
deploymentEnvironment?: string;
|
|
31
|
+
};
|
|
32
|
+
export type WranglerConfig = {
|
|
33
|
+
file: string;
|
|
34
|
+
json: Record<string, unknown>;
|
|
35
|
+
name?: string;
|
|
36
|
+
};
|
|
37
|
+
export declare const CONFIG_DEFAULTS: {
|
|
38
|
+
trunk: string;
|
|
39
|
+
sharedBranch: string;
|
|
40
|
+
supabaseDir: string;
|
|
41
|
+
isolatedLabel: string;
|
|
42
|
+
checkPath: string;
|
|
43
|
+
previewName: "branch";
|
|
44
|
+
apiKeys: "legacy";
|
|
45
|
+
};
|
|
46
|
+
export declare function readWranglerConfig(cwd?: string): WranglerConfig | null;
|
|
47
|
+
export declare function loadConfig(overrides?: Partial<Config>, cwd?: string): Config;
|
|
48
|
+
export declare function migrationsDir(config: Pick<Config, "supabaseDir">): string;
|
|
49
|
+
export declare function migrationFiles(config: Pick<Config, "supabaseDir">, cwd?: string): string[];
|
|
50
|
+
export declare function defined<T extends object>(o: T): Partial<T>;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parse as parseToml } from "smol-toml";
|
|
4
|
+
import { parseJsonc } from "./jsonc.js";
|
|
5
|
+
import { didYouMean } from "./suggest.js";
|
|
6
|
+
export const CONFIG_FILE = "swp.config.json";
|
|
7
|
+
export const WRANGLER_FILES = ["wrangler.jsonc", "wrangler.json", "wrangler.toml"];
|
|
8
|
+
/** What `swp init` writes when it cannot find the project ref. */
|
|
9
|
+
export const PLACEHOLDER_REF = "<production project ref>";
|
|
10
|
+
export const CONFIG_DEFAULTS = {
|
|
11
|
+
trunk: "main",
|
|
12
|
+
sharedBranch: "preview",
|
|
13
|
+
supabaseDir: "supabase",
|
|
14
|
+
isolatedLabel: "isolated-db",
|
|
15
|
+
checkPath: "/",
|
|
16
|
+
previewName: "branch",
|
|
17
|
+
apiKeys: "legacy",
|
|
18
|
+
};
|
|
19
|
+
const CHOICES = { previewName: ["branch", "pr"], apiKeys: ["legacy", "new"] };
|
|
20
|
+
const KEYS = [
|
|
21
|
+
"$schema",
|
|
22
|
+
"worker",
|
|
23
|
+
"supabaseProjectRef",
|
|
24
|
+
"trunk",
|
|
25
|
+
"sharedBranch",
|
|
26
|
+
"workersSubdomain",
|
|
27
|
+
"supabaseDir",
|
|
28
|
+
"isolatedLabel",
|
|
29
|
+
"checkPath",
|
|
30
|
+
"previewName",
|
|
31
|
+
"apiKeys",
|
|
32
|
+
"prComment",
|
|
33
|
+
"githubDeployments",
|
|
34
|
+
"deploymentEnvironment",
|
|
35
|
+
];
|
|
36
|
+
export function readWranglerConfig(cwd = process.cwd()) {
|
|
37
|
+
const file = WRANGLER_FILES.find((f) => existsSync(join(cwd, f)));
|
|
38
|
+
if (!file)
|
|
39
|
+
return null;
|
|
40
|
+
const text = readFileSync(join(cwd, file), "utf8");
|
|
41
|
+
let json;
|
|
42
|
+
try {
|
|
43
|
+
json = (file.endsWith(".toml") ? parseToml(text) : parseJsonc(text));
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
throw new Error(`Cannot parse ${file}: ${err.message}`, { cause: err });
|
|
47
|
+
}
|
|
48
|
+
return { file, json, name: typeof json.name === "string" ? json.name : undefined };
|
|
49
|
+
}
|
|
50
|
+
export function loadConfig(overrides = {}, cwd = process.cwd()) {
|
|
51
|
+
const path = join(cwd, CONFIG_FILE);
|
|
52
|
+
const exists = existsSync(path);
|
|
53
|
+
const file = exists ? readConfigFile(path) : {};
|
|
54
|
+
const wrangler = readWranglerConfig(cwd);
|
|
55
|
+
const merged = { ...CONFIG_DEFAULTS, worker: wrangler?.name, ...file, ...defined(overrides) };
|
|
56
|
+
if (!merged.supabaseProjectRef)
|
|
57
|
+
throw new Error(exists
|
|
58
|
+
? `Set "supabaseProjectRef" (the production project) in ${CONFIG_FILE}`
|
|
59
|
+
: `No ${CONFIG_FILE} in this directory. Run \`swp init\` first, or pass --project-ref <ref>.`);
|
|
60
|
+
if (merged.supabaseProjectRef === PLACEHOLDER_REF)
|
|
61
|
+
throw new Error(`"supabaseProjectRef" in ${CONFIG_FILE} is still ${PLACEHOLDER_REF}. ` +
|
|
62
|
+
"Set it to the production project's ref, the <ref> in https://<ref>.supabase.co.");
|
|
63
|
+
if (!merged.worker)
|
|
64
|
+
throw new Error(wrangler
|
|
65
|
+
? `${wrangler.file} has no "name"; add the Worker's name there or "worker" in ${CONFIG_FILE}`
|
|
66
|
+
: `No ${WRANGLER_FILES.join(", ")} in this directory. Run swp in the Worker's directory, or set "worker" in ${CONFIG_FILE}.`);
|
|
67
|
+
for (const [key, allowed] of Object.entries(CHOICES)) {
|
|
68
|
+
const value = merged[key];
|
|
69
|
+
if (!allowed.includes(value))
|
|
70
|
+
throw new Error(`"${key}" in ${CONFIG_FILE} must be ${allowed.map((a) => `"${a}"`).join(" or ")}`);
|
|
71
|
+
}
|
|
72
|
+
return merged;
|
|
73
|
+
}
|
|
74
|
+
function readConfigFile(path) {
|
|
75
|
+
let json;
|
|
76
|
+
try {
|
|
77
|
+
json = JSON.parse(readFileSync(path, "utf8"));
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
throw new Error(`Cannot parse ${CONFIG_FILE}: ${err.message}`, { cause: err });
|
|
81
|
+
}
|
|
82
|
+
if (!json || typeof json !== "object" || Array.isArray(json))
|
|
83
|
+
throw new Error(`${CONFIG_FILE} must hold a JSON object`);
|
|
84
|
+
for (const key of Object.keys(json)) {
|
|
85
|
+
if (KEYS.includes(key))
|
|
86
|
+
continue;
|
|
87
|
+
const guess = didYouMean(key, KEYS);
|
|
88
|
+
throw new Error(`Unknown key "${key}" in ${CONFIG_FILE}.${guess ? ` Did you mean "${guess}"?` : ""} See the README for the keys.`);
|
|
89
|
+
}
|
|
90
|
+
return json;
|
|
91
|
+
}
|
|
92
|
+
export function migrationsDir(config) {
|
|
93
|
+
return join(config.supabaseDir, "migrations");
|
|
94
|
+
}
|
|
95
|
+
export function migrationFiles(config, cwd = process.cwd()) {
|
|
96
|
+
const dir = join(cwd, migrationsDir(config));
|
|
97
|
+
return existsSync(dir)
|
|
98
|
+
? readdirSync(dir)
|
|
99
|
+
.filter((f) => f.endsWith(".sql"))
|
|
100
|
+
.sort()
|
|
101
|
+
: [];
|
|
102
|
+
}
|
|
103
|
+
export function defined(o) {
|
|
104
|
+
return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
|
|
105
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type Deps } from "./commands/branches.js";
|
|
2
|
+
import type { PullRequestEvent } from "./commands/pr.js";
|
|
3
|
+
import { GitHubApi } from "./github.js";
|
|
4
|
+
export declare const COMMENT_MARKER = "<!-- supabase-worker-previews -->";
|
|
5
|
+
export type Database = {
|
|
6
|
+
kind: "shared" | "own";
|
|
7
|
+
name: string;
|
|
8
|
+
ref?: string;
|
|
9
|
+
};
|
|
10
|
+
export type CommentState = {
|
|
11
|
+
phase: "checking" | "passed" | "failed" | "removed" | "cleanup-failed";
|
|
12
|
+
worker: string;
|
|
13
|
+
sha: string;
|
|
14
|
+
commitUrl: string;
|
|
15
|
+
dashboardUrl: string;
|
|
16
|
+
updated: Date;
|
|
17
|
+
previewUrl?: string;
|
|
18
|
+
database?: Database;
|
|
19
|
+
error?: string;
|
|
20
|
+
runUrl?: string;
|
|
21
|
+
};
|
|
22
|
+
export declare function renderComment(s: CommentState): string;
|
|
23
|
+
/** `::warning::` lines end at a newline, so GitHub's escapes keep a multi-line message whole. */
|
|
24
|
+
export declare function warning(message: string): string;
|
|
25
|
+
export type FeedbackDeps = Deps & {
|
|
26
|
+
githubToken: string;
|
|
27
|
+
github?: GitHubApi;
|
|
28
|
+
env?: Record<string, string | undefined>;
|
|
29
|
+
clock?: () => Date;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The PR comment and GitHub deployment for one `swp pr` run. Any GitHub API
|
|
33
|
+
* failure becomes a warning; only the wrapped database work decides the job.
|
|
34
|
+
*/
|
|
35
|
+
export declare class Feedback {
|
|
36
|
+
private event;
|
|
37
|
+
private deps;
|
|
38
|
+
private github;
|
|
39
|
+
private commentOn;
|
|
40
|
+
private deploymentsOn;
|
|
41
|
+
private commentId?;
|
|
42
|
+
private branch;
|
|
43
|
+
private sha;
|
|
44
|
+
private environment;
|
|
45
|
+
private server;
|
|
46
|
+
private runUrl?;
|
|
47
|
+
constructor(event: PullRequestEvent, deps: FeedbackDeps);
|
|
48
|
+
/** Reports on `work` (up and check) from start to finish, and rethrows its error. */
|
|
49
|
+
run(isolated: boolean, work: () => Promise<void>): Promise<void>;
|
|
50
|
+
/** Reports on `work` (down) when the PR closes. */
|
|
51
|
+
/** `isolated`: the PR had its own database, which Supabase's integration may already have deleted. */
|
|
52
|
+
close(work: () => Promise<void>, isolated?: boolean): Promise<void>;
|
|
53
|
+
private database;
|
|
54
|
+
private previewUrl;
|
|
55
|
+
private comment;
|
|
56
|
+
private startDeployment;
|
|
57
|
+
private deploymentStatus;
|
|
58
|
+
/**
|
|
59
|
+
* Transient deployments are never marked inactive automatically, so this PR's
|
|
60
|
+
* older ones are retired here. Other PRs share the environment.
|
|
61
|
+
*/
|
|
62
|
+
private deactivateDeployments;
|
|
63
|
+
private safe;
|
|
64
|
+
}
|