Vectrum
Sign In
96 lines · 4.5 KB · JavaScript
// 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}>.`);