// Creates an account, also while sign-ups are closed (SIGNUPS_DISABLED=1).
//
// node scripts/create-user.mjs <username> <email> [name]
// docker compose exec vectrum node scripts/create-user.mjs <username> <email> [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 <username> <email> [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",
]);
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}>.`);