Improve MineGeo interface and functionality
This commit is contained in:
306
scripts/deploy-vercel.mjs
Normal file
306
scripts/deploy-vercel.mjs
Normal file
@@ -0,0 +1,306 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { unzipSync } from "fflate";
|
||||
|
||||
const defaultRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
);
|
||||
export const deploymentProject = {
|
||||
name: "minegeo",
|
||||
project: "prj_St6KMC5H8PakQxe3UCQxJa5b8gWy",
|
||||
teamId: "team_03U5cmiAfXJvpuxDDh33CqOT",
|
||||
productionUrl: "https://minegeo.vercel.app",
|
||||
};
|
||||
|
||||
export function deploymentFiles(root = defaultRoot) {
|
||||
const files = [];
|
||||
const rootReal = fs.realpathSync(root);
|
||||
let privateTerms = [];
|
||||
try {
|
||||
const remote = execFileSync(
|
||||
"git",
|
||||
["-C", root, "remote", "get-url", "origin"],
|
||||
{
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
},
|
||||
).trim();
|
||||
const hostname = remote.includes("://")
|
||||
? new URL(remote).hostname
|
||||
: remote.split("@").pop().split(":")[0];
|
||||
privateTerms = [remote, hostname]
|
||||
.filter(Boolean)
|
||||
.map((value) => value.toLowerCase());
|
||||
} catch {
|
||||
/* A source snapshot can be deployed without Git. */
|
||||
}
|
||||
|
||||
function add(full, relative) {
|
||||
const real = fs.realpathSync(full);
|
||||
if (real !== rootReal && !real.startsWith(rootReal + path.sep))
|
||||
throw new Error(`Deployment file escapes the project: ${relative}`);
|
||||
let data = fs.readFileSync(full);
|
||||
if (relative === "next.config.ts")
|
||||
data = Buffer.from(
|
||||
data.toString().replace(/^\s*allowedDevOrigins:.*\r?\n/gm, ""),
|
||||
);
|
||||
if (relative === "package.json") {
|
||||
const pkg = JSON.parse(data);
|
||||
pkg.scripts = {
|
||||
prebuild: "node scripts/clean-build.mjs",
|
||||
build: "next build",
|
||||
start: "next start",
|
||||
};
|
||||
delete pkg.repository;
|
||||
delete pkg.bugs;
|
||||
delete pkg.homepage;
|
||||
data = Buffer.from(JSON.stringify(pkg, null, 2));
|
||||
}
|
||||
if (relative === "tsconfig.json") {
|
||||
const config = JSON.parse(data);
|
||||
config.include = [
|
||||
"next-env.d.ts",
|
||||
"next.config.ts",
|
||||
"proxy.ts",
|
||||
"app/**/*.ts",
|
||||
"app/**/*.tsx",
|
||||
"components/**/*.tsx",
|
||||
"lib/**/*.ts",
|
||||
"shared/**/*.ts",
|
||||
".next/types/**/*.ts",
|
||||
];
|
||||
data = Buffer.from(JSON.stringify(config, null, 2));
|
||||
}
|
||||
const contents = relative.endsWith(".jar")
|
||||
? Object.values(unzipSync(data))
|
||||
: [data];
|
||||
if (
|
||||
contents.some((content) =>
|
||||
privateTerms.some((term) =>
|
||||
Buffer.from(content).toString("utf8").toLowerCase().includes(term),
|
||||
),
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
`Private Git server reference found in ${relative}. Remove it before deploying.`,
|
||||
);
|
||||
files.push({
|
||||
file: relative,
|
||||
data: data.toString("base64"),
|
||||
encoding: "base64",
|
||||
});
|
||||
}
|
||||
|
||||
function walk(relative) {
|
||||
const dir = path.join(root, relative);
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (
|
||||
entry.name.startsWith(".") ||
|
||||
entry.name === "node_modules" ||
|
||||
(relative === "public" &&
|
||||
["generated", "downloads", "artifacts", "build"].includes(entry.name))
|
||||
)
|
||||
continue;
|
||||
const file = path.posix.join(relative, entry.name);
|
||||
if (entry.isDirectory()) walk(file);
|
||||
else add(path.join(root, file), file);
|
||||
}
|
||||
}
|
||||
for (const directory of ["app", "components", "lib", "shared", "public"])
|
||||
walk(directory);
|
||||
for (const file of [
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"tsconfig.json",
|
||||
"next.config.ts",
|
||||
"next-env.d.ts",
|
||||
"proxy.ts",
|
||||
"postcss.config.mjs",
|
||||
"vercel.json",
|
||||
"scripts/clean-build.mjs",
|
||||
]) {
|
||||
if (fs.existsSync(path.join(root, file))) add(path.join(root, file), file);
|
||||
}
|
||||
if (
|
||||
!files.some((file) => file.file === "package.json") ||
|
||||
!files.some((file) => file.file === "app/layout.tsx")
|
||||
)
|
||||
throw new Error("Run this command from a complete MineGeo project.");
|
||||
return files;
|
||||
}
|
||||
|
||||
export function deploymentPayload(files, preview = false) {
|
||||
return {
|
||||
name: deploymentProject.name,
|
||||
project: process.env.VERCEL_PROJECT_ID || deploymentProject.project,
|
||||
...(preview ? {} : { target: "production" }),
|
||||
files,
|
||||
projectSettings: {
|
||||
framework: "nextjs",
|
||||
nodeVersion: "22.x",
|
||||
buildCommand: "npm run build",
|
||||
skipGitConnectDuringLink: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function accessToken() {
|
||||
if (process.env.VERCEL_TOKEN) return process.env.VERCEL_TOKEN;
|
||||
const authPaths =
|
||||
process.platform === "win32"
|
||||
? [
|
||||
path.join(
|
||||
process.env.APPDATA || path.join(os.homedir(), "AppData/Roaming"),
|
||||
"com.vercel.cli/Data/auth.json",
|
||||
),
|
||||
]
|
||||
: [
|
||||
path.join(
|
||||
process.env.XDG_DATA_HOME ||
|
||||
path.join(os.homedir(), ".local/share"),
|
||||
"com.vercel.cli/auth.json",
|
||||
),
|
||||
path.join(
|
||||
os.homedir(),
|
||||
"Library/Application Support/com.vercel.cli/auth.json",
|
||||
),
|
||||
];
|
||||
for (const file of authPaths) {
|
||||
try {
|
||||
const token = JSON.parse(fs.readFileSync(file, "utf8")).token;
|
||||
if (typeof token === "string") return token;
|
||||
} catch {
|
||||
/* No CLI login saved here. */
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
"Vercel authentication is needed once: run vercel login, or put VERCEL_TOKEN in .env.deploy.local. Then run npm run deploy again. The Codex Vercel plugin login is separate from CLI/script authentication.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof deploymentPayload>} payload
|
||||
* @param {{ token: string, request?: typeof fetch, wait?: (milliseconds: number) => Promise<void>, log?: (message: string) => void }} options
|
||||
*/
|
||||
export async function deploy(
|
||||
payload,
|
||||
{
|
||||
token,
|
||||
request = fetch,
|
||||
wait = (milliseconds) =>
|
||||
new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
||||
log = console.log,
|
||||
},
|
||||
) {
|
||||
if (!token) throw new Error("A Vercel access token is required.");
|
||||
const team = process.env.VERCEL_ORG_ID || deploymentProject.teamId;
|
||||
async function api(endpoint, body) {
|
||||
const response = await request(
|
||||
`https://api.vercel.com${endpoint}${endpoint.includes("?") ? "&" : "?"}teamId=${encodeURIComponent(team)}`,
|
||||
{
|
||||
method: body ? "POST" : "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(body ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
},
|
||||
);
|
||||
const data = await response.json();
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
`Vercel ${response.status}: ${data.error?.message || "Deployment request failed"}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
let deployment = await api("/v13/deployments", payload);
|
||||
if (!deployment.id) throw new Error("Vercel did not return a deployment ID.");
|
||||
log(
|
||||
`Uploaded ${payload.files.length} site files in one request. Waiting for Vercel…`,
|
||||
);
|
||||
let previousState;
|
||||
for (let attempts = 0; attempts < 180; attempts++) {
|
||||
const state =
|
||||
deployment.readyState || deployment.status || deployment.state;
|
||||
if (state !== previousState) {
|
||||
log(`Build: ${state}`);
|
||||
previousState = state;
|
||||
}
|
||||
if (state === "READY") {
|
||||
const url =
|
||||
payload.target === "production" &&
|
||||
payload.project === deploymentProject.project
|
||||
? deploymentProject.productionUrl
|
||||
: `https://${deployment.url}`;
|
||||
log(`Deployed: ${url}`);
|
||||
return deployment;
|
||||
}
|
||||
if (["ERROR", "CANCELED"].includes(state))
|
||||
throw new Error(
|
||||
`Deployment ${state.toLowerCase()}: ${deployment.errorMessage || deployment.error?.message || "See the build log in Vercel."}`,
|
||||
);
|
||||
await wait(5000);
|
||||
deployment = await api(
|
||||
`/v13/deployments/${encodeURIComponent(deployment.id)}`,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Build is still running. Check deployment ${deployment.id} in Vercel.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes("--help")) {
|
||||
console.log(
|
||||
"npm run deploy [-- --preview | --dry-run]\nOne upload of all site files, with no Git metadata or local secrets.\nAuthenticate once using vercel login or VERCEL_TOKEN in .env.deploy.local.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const envPath = path.join(defaultRoot, ".env.deploy.local");
|
||||
if (fs.existsSync(envPath)) process.loadEnvFile(envPath);
|
||||
const files = deploymentFiles();
|
||||
const manifestIndex = args.indexOf("--manifest");
|
||||
if (manifestIndex !== -1) {
|
||||
if (!args[manifestIndex + 1])
|
||||
throw new Error("--manifest requires a file path");
|
||||
fs.writeFileSync(
|
||||
path.resolve(args[manifestIndex + 1]),
|
||||
JSON.stringify(files),
|
||||
);
|
||||
}
|
||||
if (args.includes("--dry-run")) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
files: files.length,
|
||||
bytes: files.reduce(
|
||||
(sum, file) => sum + Buffer.from(file.data, "base64").length,
|
||||
0,
|
||||
),
|
||||
privacyScan: "passed",
|
||||
gitMetadata: false,
|
||||
localSecrets: false,
|
||||
target: args.includes("--preview") ? "preview" : "production",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await deploy(deploymentPayload(files, args.includes("--preview")), {
|
||||
token: accessToken(),
|
||||
});
|
||||
}
|
||||
if (
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
)
|
||||
main().catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
73
scripts/test-site-settings-sql.mjs
Normal file
73
scripts/test-site-settings-sql.mjs
Normal file
@@ -0,0 +1,73 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
|
||||
|
||||
const db = new PGlite();
|
||||
try {
|
||||
await db.exec(`create role anon; create role authenticated; create role service_role bypassrls;
|
||||
create table public.profiles(id uuid primary key, role text not null);
|
||||
grant select on public.profiles to service_role;
|
||||
insert into public.profiles values
|
||||
('00000000-0000-4000-8000-000000000001', 'moderator'),
|
||||
('00000000-0000-4000-8000-000000000002', 'contributor');`);
|
||||
await db.exec(
|
||||
await readFile(
|
||||
"supabase/migrations/20261006141532_site_settings.sql",
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const flag = async () =>
|
||||
(await db.query("select beta_badge_enabled from public.site_settings"))
|
||||
.rows[0].beta_badge_enabled;
|
||||
assert.equal(await flag(), true);
|
||||
for (const role of ["anon", "authenticated"]) {
|
||||
await db.exec(`set role ${role}`);
|
||||
await assert.rejects(
|
||||
db.query("update public.site_settings set beta_badge_enabled = false"),
|
||||
/permission denied/,
|
||||
);
|
||||
await assert.rejects(
|
||||
db.query(
|
||||
"select public.set_beta_badge('00000000-0000-4000-8000-000000000001', false)",
|
||||
),
|
||||
/permission denied/,
|
||||
);
|
||||
await db.exec("reset role");
|
||||
}
|
||||
await db.exec("set role service_role");
|
||||
await assert.rejects(
|
||||
db.query(
|
||||
"select public.set_beta_badge('00000000-0000-4000-8000-000000000002', false)",
|
||||
),
|
||||
/Moderator permission required/,
|
||||
);
|
||||
await assert.rejects(
|
||||
db.query("select public.set_beta_badge(null, false)"),
|
||||
/Moderator permission required/,
|
||||
);
|
||||
assert.equal(await flag(), true);
|
||||
await db.query(
|
||||
"select public.set_beta_badge('00000000-0000-4000-8000-000000000001', false)",
|
||||
);
|
||||
assert.equal(await flag(), false);
|
||||
assert.equal(
|
||||
(await db.query("select updated_by from public.site_settings")).rows[0]
|
||||
.updated_by,
|
||||
"00000000-0000-4000-8000-000000000001",
|
||||
);
|
||||
await db.query(
|
||||
"select public.set_beta_badge('00000000-0000-4000-8000-000000000001', true)",
|
||||
);
|
||||
assert.equal(await flag(), true);
|
||||
await assert.rejects(
|
||||
db.query(
|
||||
"select public.set_beta_badge('00000000-0000-4000-8000-000000000001', null)",
|
||||
),
|
||||
/visibility is required/,
|
||||
);
|
||||
console.log(
|
||||
"Site settings SQL passed: guests and players denied; moderators can hide and restore; updates persist and record the moderator.",
|
||||
);
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
Reference in New Issue
Block a user