// Idempotent PocketBase schema setup for the Car Control project.
//
// Creates the collections behind the app: cars, service_records and parts (which
// match the original "Car Service.xlsx"), the sharing/tenancy tables, and the
// fuel, maintenance, document and reminder logs layered on top. Access rules are
// left admin-only (null) on purpose: every client goes through the API Server,
// which authenticates as a superuser, so the database is never exposed directly
// — including document attachments, which are proxied by the API rather than
// served as public file URLs.
//
// Usage (PowerShell):
//   $env:PB_URL="http://10.2.1.10:8027"
//   $env:PB_ADMIN_EMAIL="you@example.com"
//   $env:PB_ADMIN_PASSWORD="secret"
//   node scripts/setup-pocketbase.mjs
//
// Re-running is safe: existing collections are skipped.

const PB_URL = (process.env.PB_URL || "http://10.2.1.10:8027").replace(/\/+$/, "");
const EMAIL = process.env.PB_ADMIN_EMAIL;
const PASSWORD = process.env.PB_ADMIN_PASSWORD;

if (!EMAIL || !PASSWORD) {
  console.error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD environment variables.");
  process.exit(1);
}

async function authenticate() {
  const endpoints = [
    "/api/collections/_superusers/auth-with-password",
    "/api/admins/auth-with-password",
  ];
  for (const ep of endpoints) {
    const res = await fetch(PB_URL + ep, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ identity: EMAIL, password: PASSWORD }),
    });
    if (res.ok) {
      const data = await res.json();
      return data.token;
    }
  }
  throw new Error("Authentication failed. Check PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD.");
}

async function listCollections(token) {
  const res = await fetch(PB_URL + "/api/collections?perPage=200", {
    headers: { Authorization: token },
  });
  if (!res.ok) throw new Error(`list collections failed: ${res.status} ${await res.text()}`);
  const data = await res.json();
  return Array.isArray(data) ? data : data.items || [];
}

// Detects whether this PocketBase version serializes fields under "fields"
// (v0.23+) or the legacy "schema" key.
function detectFormat(collections) {
  for (const c of collections) {
    if (Array.isArray(c.fields)) return "fields";
    if (Array.isArray(c.schema)) return "schema";
  }
  return "fields"; // default to modern format
}

// Field builders normalized to {name,type,required,relTo}. They are rendered
// into the right wire shape per detected format.
const F = {
  text: (name, required = false) => ({ name, type: "text", required }),
  number: (name) => ({ name, type: "number", required: false }),
  bool: (name) => ({ name, type: "bool", required: false }),
  date: (name, required = false) => ({ name, type: "date", required }),
  relation: (name, relTo, required = false, cascadeDelete = true) => ({ name, type: "relation", required, relTo, cascadeDelete }),
  select: (name, values, required = false) => ({ name, type: "select", required, values }),
  autodate: (name, onCreate = false, onUpdate = false) => ({ name, type: "autodate", required: false, onCreate, onUpdate }),
  // Single-file attachment. maxSize is in bytes; mimeTypes [] means "any".
  file: (name, maxSize, mimeTypes = []) => ({ name, type: "file", required: false, maxSize, mimeTypes }),
  // Free-form JSON blob. maxSize is in bytes.
  json: (name, maxSize = 100000) => ({ name, type: "json", required: false, maxSize }),
};

function renderField(def, format, idByName) {
  if (format === "schema") {
    // Legacy: options nested under "options".
    const options = {};
    if (def.type === "relation") {
      options.collectionId = idByName[def.relTo];
      options.cascadeDelete = def.cascadeDelete !== false;
      options.maxSelect = 1;
      options.minSelect = 0;
    }
    if (def.type === "select") {
      options.values = def.values;
      options.maxSelect = 1;
    }
    if (def.type === "file") {
      options.maxSelect = 1;
      options.maxSize = def.maxSize;
      options.mimeTypes = def.mimeTypes || [];
    }
    if (def.type === "json") {
      options.maxSize = def.maxSize;
    }
    return { name: def.name, type: def.type, required: def.required, options };
  }
  // Modern: options flattened onto the field.
  const field = { name: def.name, type: def.type, required: def.required };
  if (def.type === "relation") {
    field.collectionId = idByName[def.relTo];
    field.cascadeDelete = def.cascadeDelete !== false;
    field.maxSelect = 1;
    field.minSelect = 0;
  }
  if (def.type === "select") {
    field.values = def.values;
    field.maxSelect = 1;
  }
  if (def.type === "autodate") {
    field.onCreate = def.onCreate;
    field.onUpdate = def.onUpdate;
  }
  if (def.type === "file") {
    field.maxSelect = 1;
    field.maxSize = def.maxSize;
    field.mimeTypes = def.mimeTypes || [];
  }
  if (def.type === "json") {
    field.maxSize = def.maxSize;
  }
  return field;
}

async function createCollection(token, name, defs, format, idByName) {
  const rendered = defs.map((d) => renderField(d, format, idByName));
  const body = {
    name,
    type: "base",
    [format]: rendered, // "fields" or "schema"
    // Rules left null => superuser-only access (API Server is the only client).
    listRule: null,
    viewRule: null,
    createRule: null,
    updateRule: null,
    deleteRule: null,
  };
  if (INDEXES[name]) body.indexes = INDEXES[name];
  const res = await fetch(PB_URL + "/api/collections", {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: token },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`create ${name} failed: ${res.status} ${await res.text()}`);
  const created = await res.json();
  idByName[name] = created.id;
  return created;
}

async function getCollection(token, idOrName) {
  const res = await fetch(PB_URL + "/api/collections/" + idOrName, {
    headers: { Authorization: token },
  });
  if (!res.ok) throw new Error(`get ${idOrName} failed: ${res.status} ${await res.text()}`);
  return res.json();
}

// reconcileFields brings an existing collection's schema in line with the desired
// definition: it appends any missing fields AND updates relation options
// (currently cascadeDelete) and select options (the "values" list) on existing
// fields. Existing field ids/data are kept. Safe to re-run as the schema
// evolves (e.g. adding cars.current_km, enabling cascade delete, or adding a
// new select choice like a date-format option).
async function reconcileFields(token, name, defs, format, idByName) {
  const col = await getCollection(token, name);
  const current = col[format] || [];
  const byName = new Map(current.map((f) => [f.name, f]));

  const changes = [];

  // Update relation cascadeDelete and select values on existing fields to match desired.
  const merged = current.map((f) => {
    const def = defs.find((d) => d.name === f.name);
    if (def && def.type === "relation") {
      const wantCascade = def.cascadeDelete !== false;
      if (f.cascadeDelete !== wantCascade) {
        changes.push(`${f.name}.cascadeDelete=${wantCascade}`);
        return { ...f, cascadeDelete: wantCascade };
      }
    }
    if (def && def.type === "select") {
      const same =
        Array.isArray(f.values) &&
        f.values.length === def.values.length &&
        def.values.every((v) => f.values.includes(v));
      if (!same) {
        changes.push(`${f.name}.values=[${def.values.join(",")}]`);
        return { ...f, values: def.values };
      }
    }
    return f;
  });

  // Append missing fields.
  const missing = defs.filter((d) => !byName.has(d.name));
  for (const d of missing) {
    merged.push(renderField(d, format, idByName));
    changes.push(`+${d.name}`);
  }

  if (changes.length === 0) {
    console.log(`• ${name} — up to date`);
    return;
  }
  const res = await fetch(PB_URL + "/api/collections/" + col.id, {
    method: "PATCH",
    headers: { "Content-Type": "application/json", Authorization: token },
    body: JSON.stringify({ [format]: merged }),
  });
  if (!res.ok) throw new Error(`update ${name} failed: ${res.status} ${await res.text()}`);
  console.log(`✓ ${name} — ${changes.join(", ")}`);
}

// The single optional attachment a record can carry — a scan, a receipt, a
// workshop invoice, a photo of a part. The 10MB cap matches maxAttachmentUpload
// in the API server, and the file is reached only via the API's own file
// endpoint, never as a public URL.
const attachment = () =>
  F.file("file", 10485760, [
    "application/pdf",
    "image/jpeg",
    "image/png",
    "image/webp",
    "image/heic",
  ]);

// Desired schema. Edit here to evolve collections; re-run the script to apply.
const DESIRED = {
  cars: [
    F.text("name", true),
    F.text("make"),
    F.text("model"),
    F.number("year"),
    F.text("registration"),
    F.text("registration_country"),
    F.text("vin"),
    F.number("service_interval_days"),
    F.number("service_interval_km"),
    // Roadworthiness inspection cycle. Only prefills a check's next-due date —
    // the legal interval changes as the car ages, so each check can override it.
    F.number("technical_check_interval_days"),
    F.text("oil_spec"),
    F.text("transmission_oil_spec"),
    F.text("differential_oil_spec"),
    F.text("brake_fluid_spec"),
    F.text("coolant_spec"),
    F.number("current_km"),
    // Bi-fuel LPG conversions are their own choice rather than a flag: the car
    // runs on either tank, so "petrol + LPG" is what an owner picks it out as.
    F.select("fuel_type", [
      "petrol",
      "petrol_lpg",
      "diesel",
      "diesel_lpg",
      "hybrid",
      "electric",
      "hydrogen",
    ]),
    F.text("build_date"), // ISO YYYY-MM-DD (date-only; VIN 10th digit ≈ model year)
    F.text("first_registration_date"), // ISO YYYY-MM-DD
    // Link to the manufacturer service this car came from (see
    // internal/api/vehicleproviders.go): the plugin name, plus that plugin's own
    // id for the vehicle (the VIN, for Toyota). Set when a car is imported from
    // or linked to a connected account; blank for a hand-entered car.
    F.text("provider"),
    F.text("provider_vehicle_id"),
    // What this car's page shows: the tabs switched off (["fuel"] on an EV) and
    // the Information rows switched off (["differentialOil"]). Properties of the
    // car, so everyone it is shared with sees the same page. The hidden sets,
    // not the visible ones, so anything added in a later release is on by
    // default. Keys are validated in internal/api/cars.go.
    F.json("hidden_tabs", 2000),
    F.json("hidden_fields", 2000),
    // The order the tabs are laid out in, as tab keys, and the order the
    // Information rows are laid out in, as field keys — the hidden ones
    // included in both, so one switched back on returns to where it was. Empty
    // means the page's own default order. metric_order is the same for the
    // headline readings on the connected service's tab.
    F.json("tab_order", 2000),
    F.json("field_order", 2000),
    F.json("metric_order", 2000),
    // Owner of this car. Non-cascading on purpose: deleting a user must not
    // wipe their cars (account deletion in me.go intentionally leaves cars).
    // required:false at the DB level — the API always sets owner on create and
    // existing rows are backfilled (scripts/backfill-car-owners.mjs).
    F.relation("owner", "users", false, false),
  ],
  service_records: [
    F.relation("car", "cars", true),
    F.date("date", true),
    F.number("km"),
    F.bool("changed_oil"),
    F.bool("changed_engine_air_filter"),
    F.bool("changed_cabin_air_filter"),
    F.text("notes"),
    attachment(), // the workshop receipt / stamped service-book page
  ],
  // Mandatory roadworthiness inspections (przegląd techniczny / MOT / TÜV).
  // Like service_records but time-only: a check falls due on a date whatever the
  // odometer reads. valid_until is the expiry printed on the certificate; when
  // blank the API derives it from the car's interval.
  technical_checks: [
    F.relation("car", "cars", true),
    F.date("date", true),
    F.select("result", ["passed", "failed"]),
    F.number("cost"),
    F.text("station"),
    F.date("valid_until"),
    F.text("notes"),
    attachment(), // the certificate
  ],
  parts: [
    F.relation("car", "cars", true),
    F.text("name", true),
    F.text("part_number"),
    F.text("category"),
    F.text("notes"),
    attachment(), // a photo of the box, or the part's spec sheet
  ],
  // Fuel refills. Consumption is NOT stored — the API derives it from the whole
  // history on read (models.ComputeFuelDerived), so correcting an old fill fixes
  // every figure it affects with no rows to migrate.
  fuel_entries: [
    F.relation("car", "cars", true),
    F.date("date", true),
    F.number("km"), // odometer at the pump
    F.number("liters"),
    F.number("cost"),
    // Filled to the brim — the reference point efficiency is measured between.
    F.bool("full_tank"),
    // A refill happened before this one without being logged, so any window
    // containing it is left uncomputed rather than reported as implausibly good.
    F.bool("missed_fill"),
    F.text("station"),
    F.text("notes"),
    attachment(), // the pump receipt
  ],
  // Charging sessions for an electric car. The EV counterpart of fuel_entries
  // and deliberately the same shape: kWh where litres would be, a charge to the
  // usual full point as the reference the windows are measured between, and
  // consumption derived on read (models.ComputeChargingDerived).
  charging_sessions: [
    F.relation("car", "cars", true),
    F.date("date", true),
    F.number("km"), // odometer when plugging in
    F.number("kwh"),
    F.number("cost"),
    // Charged to the car's usual full point — the reference point.
    F.bool("full_charge"),
    // The car was charged before this without being logged, so any window
    // containing it is left uncomputed rather than reported as implausible.
    F.bool("missed_session"),
    F.text("location"), // "Home", "Ionity Koge"
    F.text("notes"),
    attachment(), // the charge point's receipt
  ],
  // Workshop visits and repairs. Deliberately separate from service_records:
  // that collection is the routine interval schedule (and drives next-service
  // due), this one is unplanned/one-off garage work with a labour bill.
  maintenance_entries: [
    F.relation("car", "cars", true),
    F.date("date", true),
    F.number("km"),
    F.select("type", ["repair", "inspection", "bodywork", "tyres", "diagnostics", "recall", "warranty", "other"]),
    F.select("status", ["scheduled", "in_progress", "completed"]),
    F.text("workshop"),
    F.text("location"),
    F.text("description"),
    F.text("parts_used"),
    F.number("labor_cost"),
    F.number("parts_cost"),
    F.text("invoice_number"),
    F.date("warranty_until"),
    F.text("notes"),
    attachment(), // the workshop's invoice
  ],
  // Insurance, pollution certificates, registration papers … The expiry date is
  // the point of the record: it drives the renewal status badges and the
  // auto-derived reminders. Blank expiry = never expires.
  car_documents: [
    F.relation("car", "cars", true),
    F.select("type", ["insurance", "pollution", "registration", "inspection", "roadTax", "warranty", "other"]),
    F.text("title", true),
    F.text("provider"),
    F.text("reference"),
    F.date("issue_date"),
    F.date("expiry_date"),
    F.number("cost"),
    F.text("notes"),
    attachment(), // the scan/PDF of the paperwork itself
  ],
  // User-set reminders. The API additionally synthesises read-only ones from
  // document expiry dates and the next service due — those are derived on read
  // and have no rows here.
  reminders: [
    F.relation("car", "cars", true),
    F.text("title", true),
    F.select("type", ["maintenance", "document", "service", "inspection", "other"]),
    F.date("due_date"),
    F.number("due_km"),
    // Non-zero => recurring: completing rolls the trigger forward by this much.
    F.number("repeat_days"),
    F.number("repeat_km"),
    F.bool("done"),
    F.date("done_at"),
    F.text("notes"),
  ],
  // Per-car sharing grants. One row = "this user may access this car" at the
  // given permission. Cascades on both relations so grants disappear when
  // either the car or the user is deleted. (Owner access is NOT stored here —
  // it's implied by cars.owner.)
  car_shares: [
    F.relation("car", "cars", true),
    F.relation("user", "users", true),
    F.select("permission", ["read", "write"], true),
    F.autodate("created", true, false),
  ],
  // Append-only audit trail for OCPP charger control (start/stop/limit/reset/
  // unlock/…, token generate/revoke, and charger connects). Actor/org are stored
  // as plain text ids (not relations) so the trail survives user or org deletion.
  // Written best-effort by the API Server (internal/api/integrations_ankersolix_control.go);
  // if this collection is absent, control still works and only the structured log
  // line remains.
  control_audit: [
    F.text("user_id"),
    F.text("org_id"),
    F.text("serial"),
    F.text("action", true),
    F.text("result"),
    F.json("params", 10000),
    F.autodate("created", true, false),
  ],
  // The chargers a user owns — their own wallbox, not the public network. A
  // charger belongs to a person rather than to a car: it charges whichever car
  // is plugged into it, and it outlives any of them. Created by importing from a
  // connected charger service (Anker Solix, Greencell); see
  // internal/api/chargerproviders.go.
  home_chargers: [
    F.text("name", true),
    F.text("serial"),
    F.text("vendor"), // "Anker Solix", "Greencell" — who makes it
    F.text("model"), // "A5191"
    F.text("site_name"),
    F.number("power_kw"),
    F.text("connector"),
    // Where this charger came from: the provider id plus that provider's own id
    // for it (the serial, for both providers we speak to). Blank when added by hand.
    F.text("provider"),
    F.text("provider_charger_id"),
    // Owner. Non-cascading, like a car's: deleting a user must not wipe their records.
    F.relation("owner", "users", false, false),
    // A charger carries no date of its own, so the import order is the only order
    // there is to list them in. PocketBase adds no created field to a collection
    // defined through the API, so it is declared here like the audit trail's.
    F.autodate("created", true, false),
  ],
  // Server-wide settings as a single record, keyed "global". Today it holds
  // pluginSettings: the top (L1) layer of the integration cascade — every
  // plugin's enable state, its global config, and the registration of any
  // external HTTP plugin. The org and user layers below keep their own plugin
  // config in a field of the same name. See internal/plugins/store.go.
  app_settings: [
    F.text("key", true),
    F.json("pluginSettings", 200000),
  ],
  // Tenants that users belong to. A superadmin spans all of them; an admin
  // manages only their own.
  organizations: [
    F.text("name", true),
    F.autodate("created", true, false),
    // Per-organization plugin/integration config — the middle (org admin) layer
    // of the integration cascade (API Server → org admin → user). Shape:
    //   { "<plugin>": { "config": {…}, "disabled": bool } }
    // See internal/api/integrations.go. Only meaningful for plugins that expose
    // a per-user cascade (today: toyota).
    F.json("pluginSettings"),
  ],
  // Custom fields layered onto the built-in "users" auth collection (which
  // already ships with email/name/avatar). Settings-panel additions:
  users: [
    F.text("bio"),
    F.select("theme", ["light", "dark", "system"]),
    F.text("locale"),
    F.select("date_format", ["YMD", "DMY_NUM", "DMY", "MDY"]),
    // "auto" is the region's own convention. Kept in step with
    // validTimeFormats in internal/api/me.go.
    F.select("time_format", ["auto", "24", "12"]),
    // European currencies plus the non-European ones the panel already offered.
    // Kept in step with validCurrencies in internal/api/me.go and CURRENCY_CODES
    // in the web app's Settings.vue.
    F.select("currency", [
      "EUR", "GBP", "CHF", "PLN", "CZK", "HUF", "RON", "BGN", "DKK", "SEK", "NOK",
      "ISK", "ALL", "AMD", "AZN", "BAM", "BYN", "GEL", "MDL", "MKD", "RSD", "RUB",
      "TRY", "UAH", "USD", "CAD", "AUD", "JPY",
    ]),
    F.select("font_size", ["small", "medium", "large"]),
    // Holds every arrangement on this user's pages still — the garage, a car's
    // tabs and Information rows, the provider's readings — so reading a page
    // cannot nudge its layout. Per user, like car_order.
    F.bool("drag_locked"),
    F.date("deletion_requested_at"),
    // Access role. Empty value is treated as "user" by the API.
    F.select("role", ["user", "admin", "superadmin"]),
    // Organization membership. Non-cascading on purpose: deleting an org must
    // not delete its people. (The API refuses to delete an org that still has
    // members, so this should not arise in practice.)
    F.relation("organization", "organizations", false, false),
    // Per-user plugin/integration config — the bottom (user) layer of the
    // integration cascade. Shape:
    //   { "<plugin>": { "config": {…}, "enabled": bool } }
    // The `enabled` flag is the personal opt-in; see internal/api/integrations.go.
    F.json("pluginSettings"),
    // The garage order: car ids as this user dragged them, e.g. ["c2","c1"].
    // Per user rather than per car, so it also covers cars shared with them and
    // never reorders somebody else's garage. See internal/api/cars.go.
    F.json("car_order", 20000),
    // The charging page's tab and card arrangements, and which tab each tabbed
    // page opens on ({"charging":"home",…}). Small lists and a three-entry map,
    // so none of them need car_order's room.
    F.json("charger_tab_order", 2000),
    F.json("charger_card_order", 2000),
    F.json("default_tabs", 2000),
  ],
};

// Extra SQL indexes, applied at collection-create time. Organization names are
// unique so the API can rely on PocketBase rejecting a duplicate.
const INDEXES = {
  // One settings record per key, so the global singleton cannot be duplicated.
  app_settings: ["CREATE UNIQUE INDEX `idx_app_settings_key` ON `app_settings` (`key`)"],
  organizations: ["CREATE UNIQUE INDEX `idx_organizations_name` ON `organizations` (`name`)"],
  // Every read of these is "…for this car", and the fuel history is walked in
  // odometer order to build its efficiency windows.
  fuel_entries: ["CREATE INDEX `idx_fuel_entries_car_km` ON `fuel_entries` (`car`, `km`)"],
  charging_sessions: ["CREATE INDEX `idx_charging_sessions_car_km` ON `charging_sessions` (`car`, `km`)"],
  maintenance_entries: ["CREATE INDEX `idx_maintenance_entries_car_date` ON `maintenance_entries` (`car`, `date`)"],
  car_documents: ["CREATE INDEX `idx_car_documents_car_expiry` ON `car_documents` (`car`, `expiry_date`)"],
  reminders: ["CREATE INDEX `idx_reminders_car_due` ON `reminders` (`car`, `due_date`)"],
  // Read as "this car's checks, newest first" every time.
  technical_checks: ["CREATE INDEX `idx_technical_checks_car_date` ON `technical_checks` (`car`, `date`)"],
  // A charger is looked up by its owner, and by serial when checking whether the
  // account it came from has already been imported.
  home_chargers: ["CREATE INDEX `idx_home_chargers_owner_serial` ON `home_chargers` (`owner`, `serial`)"],
  // Audit is queried "this charger's events, newest first" and "this user's events".
  control_audit: [
    "CREATE INDEX `idx_control_audit_serial_created` ON `control_audit` (`serial`, `created`)",
    "CREATE INDEX `idx_control_audit_user_created` ON `control_audit` (`user_id`, `created`)",
  ],
};

async function main() {
  console.log(`Connecting to ${PB_URL} ...`);
  const token = await authenticate();
  console.log("Authenticated as superuser.");

  let collections = await listCollections(token);
  const format = detectFormat(collections);
  console.log(`Schema format: "${format}"`);

  const idByName = {};
  for (const c of collections) idByName[c.name] = c.id;

  // Create in dependency order (organizations before users references it; cars
  // before its relations; "users" already exists as PocketBase's built-in auth
  // collection, so it's never created here — only reconciled below).
  for (const name of [
    "app_settings",
    "organizations",
    "cars",
    "service_records",
    "technical_checks",
    "parts",
    "car_shares",
    "fuel_entries",
    "charging_sessions",
    "maintenance_entries",
    "car_documents",
    "reminders",
    "control_audit",
    "home_chargers",
  ]) {
    if (collections.some((c) => c.name === name)) continue;
    await createCollection(token, name, DESIRED[name], format, idByName);
    console.log(`✓ ${name} — created`);
    // Refresh so later relations can reference newly-created collection ids.
    collections = await listCollections(token);
    for (const c of collections) idByName[c.name] = c.id;
  }

  // Reconcile fields on existing collections (add missing + fix relation options
  // and select values — this is what grows users.role to include "superadmin"
  // and adds users.organization on an existing deployment).
  for (const name of [
    "app_settings",
    "organizations",
    "users",
    "cars",
    "service_records",
    "technical_checks",
    "parts",
    "car_shares",
    "fuel_entries",
    "charging_sessions",
    "maintenance_entries",
    "car_documents",
    "reminders",
    "control_audit",
    "home_chargers",
  ]) {
    await reconcileFields(token, name, DESIRED[name], format, idByName);
  }

  console.log(
    "\nDone. Collections ready: app_settings, organizations, users, cars,\n" +
      "service_records,\n" +
      "technical_checks, parts, car_shares, fuel_entries, charging_sessions,\n" +
      "maintenance_entries, car_documents, reminders, control_audit, home_chargers.",
  );
  console.log(
    "Note: the legacy `sessions` collection is no longer used (auth moved to PocketBase\n" +
      "tokens). It is left in place rather than dropped — delete it by hand if you want.",
  );
}

main().catch((err) => {
  console.error("\nSetup failed:", err.message);
  process.exit(1);
});
