// Creates an account, also while sign-ups are closed (SIGNUPS_DISABLED=1). // // node scripts/create-user.mjs [name] // docker compose exec vectrum node scripts/create-user.mjs [name] // // The password is read from VECTRUM_PASSWORD, or asked for. Vectrum must be running: the script talks // to its PocketBase server as the superuser, the same way the app does (lib/db.ts). import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { createInterface } from "node:readline"; const [username = "", email = "", name = ""] = process.argv.slice(2); if (!username || !email) { console.error("Usage: node scripts/create-user.mjs [name]"); process.exit(1); } // The same rules as createUser() in lib/users.ts and isValidOwner() in lib/git.ts. const RESERVED = new Set([ "new", "api", "_next", "favicon.ico", "login", "logout", "signup", "settings", "explore", "about", "admin", "v2", "mcp", "oauth", "organizations", "lists", ]); const fail = (message) => (console.error(message), process.exit(1)); if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(username) || RESERVED.has(username.toLowerCase())) { fail("Username may only contain letters, numbers and hyphens, and can't be a reserved word."); } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) fail("Email address is invalid."); async function askPassword() { if (!process.stdin.isTTY) fail("Set VECTRUM_PASSWORD, or run this in a terminal (docker compose exec -it …)."); const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true }); rl._writeToOutput = () => {}; // don't echo what's typed const lines = rl[Symbol.asyncIterator](); const ask = async (prompt) => { process.stdout.write(prompt); const { value = "" } = await lines.next(); process.stdout.write("\n"); return value; }; const password = await ask("Password: "); const again = await ask("Password (again): "); rl.close(); if (password !== again) fail("The passwords don't match."); return password; } const password = process.env.VECTRUM_PASSWORD ?? (await askPassword()); if (password.length < 8) fail("Password must be at least 8 characters."); if (Buffer.byteLength(password) > 71) fail("Password is too long."); const DATA_ROOT = process.env.DATA_ROOT ?? path.join(process.cwd(), "data"); const PB_URL = (process.env.PB_URL ?? `http://127.0.0.1:${process.env.PB_PORT ?? 8090}`).replace(/\/+$/, ""); const APP_URL = `http://127.0.0.1:${process.env.PORT ?? 3000}`; function superuser() { const { PB_SUPERUSER_EMAIL: email, PB_SUPERUSER_PASSWORD: password } = process.env; if (email && password) return { email, password }; const file = path.join(DATA_ROOT, "pocketbase-superuser.json"); if (!existsSync(file)) fail(`No superuser login in ${file}. Start Vectrum first.`); return JSON.parse(readFileSync(file, "utf8")); } async function api(route, init = {}) { const res = await fetch(`${PB_URL}${route}`, { ...init, headers: { "content-type": "application/json", ...init.headers } }); const body = await res.json().catch(() => ({})); if (!res.ok) { const details = Object.entries(body.data ?? {}).map(([field, e]) => `${field}: ${e.message}`); fail([body.message ?? `PocketBase answered ${res.status}.`, ...details].join("\n ")); } return body; } // Vectrum starts PocketBase on first use; its health check makes sure it runs. const reachable = (url) => fetch(url, { signal: AbortSignal.timeout(5000) }).then((r) => r.ok, () => false); if (!(await reachable(`${PB_URL}/api/health`)) && !(await reachable(`${APP_URL}/api/health`))) { fail(`Can't reach PocketBase at ${PB_URL}. Is Vectrum running?`); } const { token } = await api("/api/collections/_superusers/auth-with-password", { method: "POST", body: JSON.stringify({ identity: superuser().email, password: superuser().password }), }); const auth = { headers: { authorization: token } }; const query = (expr) => `/api/collections/users/records?perPage=1&skipTotal=1&fields=id&filter=${encodeURIComponent(expr)}`; const quote = (s) => JSON.stringify(s.toLowerCase()); if ((await api(query(`username:lower = ${quote(username)}`), auth)).items.length) fail(`Username ${username} is not available.`); if ((await api(query(`email:lower = ${quote(email)}`), auth)).items.length) fail("That email address is already registered."); await api("/api/collections/users/records", { ...auth, method: "POST", body: JSON.stringify({ username, email, name: name.trim().slice(0, 100), password, passwordConfirm: password }), }); console.log(`Created ${username} <${email}>.`);