Setup-Skript um Tabellen gallery und reports (Spalten boolean/integer/enum, Volltext- und Key-Indizes) sowie den Bucket gallery-images erweitert. DESIRED auf tables/buckets umgebaut, Vergleich je Spaltentyp und Index, Indizes erst nach available-Spalten. Console-Anleitung um Abschnitt Community (JWT an, Label admin, Key aircard-api) ergänzt. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
603 lines
23 KiB
JavaScript
603 lines
23 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* AirCard – Appwrite-Schema-Setup (idempotent)
|
||
*
|
||
* Legt im selbst gehosteten Appwrite (Projekt `aircard`) die Datenbank `aircard`,
|
||
* die Tabellen `cards`, `gallery` und `reports` (TablesDB, mit Spalten und
|
||
* Indizes) und die Buckets `card-images` und `gallery-images` an.
|
||
* Reines `fetch`, keine Abhängigkeiten, Node 22+.
|
||
*
|
||
* Aufruf (vom Repo-Wurzelverzeichnis):
|
||
* node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs [--check]
|
||
*
|
||
* Umgebung:
|
||
* AIRCARD_APPWRITE_API_KEY Projekt-API-Key `aircard-provision` (Pflicht)
|
||
* AIRCARD_APPWRITE_ENDPOINT optional, Standard https://appwrite.webklar.com/v1
|
||
*
|
||
* Ohne --check: fehlende Objekte werden angelegt (Indizes erst, wenn alle Spalten
|
||
* der Tabelle `available` sind). Bestehende Objekte werden nur verglichen –
|
||
* Abweichungen werden gemeldet, nie geändert.
|
||
* Mit --check: es wird nichts angelegt; Exit 1, wenn etwas fehlt oder abweicht.
|
||
*
|
||
* Ausgabe: eine Zeile je Objekt –
|
||
* OK: … FEHLT: … ANGELEGT: … ABWEICHUNG: <feld> ist <x>, erwartet <y>
|
||
*
|
||
* Exit-Codes: 0 = Schema vollständig, 1 = fehlt/weicht ab, 2 = Abbruch
|
||
* (fehlender Key, ungültiger Aufruf, Server-/Zugriffsfehler).
|
||
*
|
||
* Der API-Key wird nie ausgegeben (weder Wert noch Header).
|
||
*/
|
||
|
||
import { pathToFileURL } from 'node:url';
|
||
|
||
export const DEFAULT_ENDPOINT = 'https://appwrite.webklar.com/v1';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Soll-Zustand
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const str = (key, size, required) => ({ key, type: 'string', size, required });
|
||
|
||
export const DESIRED = {
|
||
database: { id: 'aircard', name: 'AirCard' },
|
||
// Reihenfolge ist Ablauf: cards, gallery, reports (je Tabelle: Spalten, dann Indizes).
|
||
tables: [
|
||
{
|
||
id: 'cards',
|
||
name: 'Cards',
|
||
permissions: ['create("users")'],
|
||
rowSecurity: true,
|
||
columns: [str('name', 80, true), str('config', 2000, true), str('imageFileId', 36, true), str('previewFileId', 36, true)],
|
||
indexes: [],
|
||
},
|
||
{
|
||
// Zeilenrechte setzt nur die API (aircard-api), Clients dürfen nichts auf Tabellenebene.
|
||
id: 'gallery',
|
||
name: 'Gallery',
|
||
permissions: [],
|
||
rowSecurity: true,
|
||
columns: [
|
||
str('title', 60, true),
|
||
str('issuer', 40, false),
|
||
str('tags', 120, false),
|
||
str('displayName', 30, false),
|
||
str('imageFileId', 36, true),
|
||
str('previewFileId', 36, true),
|
||
str('sourceCardId', 36, false),
|
||
str('ownerId', 36, true),
|
||
{ key: 'hidden', type: 'boolean', required: false, default: false },
|
||
{ key: 'reportCount', type: 'integer', required: false, default: 0, min: 0 },
|
||
str('searchText', 300, false),
|
||
],
|
||
indexes: [
|
||
{ key: 'search_text', type: 'fulltext', columns: ['searchText'] },
|
||
{ key: 'owner', type: 'key', columns: ['ownerId'] },
|
||
{ key: 'hidden', type: 'key', columns: ['hidden'] },
|
||
],
|
||
},
|
||
{
|
||
// Nur Admins lesen (Label `admin`); geschrieben wird ausschließlich über die API.
|
||
id: 'reports',
|
||
name: 'Reports',
|
||
permissions: ['read("label:admin")'],
|
||
rowSecurity: false,
|
||
columns: [
|
||
str('galleryId', 36, true),
|
||
{ key: 'reason', type: 'enum', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
|
||
str('reporterHash', 64, true),
|
||
],
|
||
indexes: [{ key: 'gallery', type: 'key', columns: ['galleryId'] }],
|
||
},
|
||
],
|
||
buckets: [
|
||
{
|
||
id: 'card-images',
|
||
name: 'Card Images',
|
||
permissions: ['create("users")'],
|
||
fileSecurity: true,
|
||
maximumFileSize: 5242880,
|
||
allowedFileExtensions: ['png', 'jpg', 'jpeg', 'webp'],
|
||
compression: 'none',
|
||
antivirus: false,
|
||
},
|
||
{
|
||
id: 'gallery-images',
|
||
name: 'Gallery Images',
|
||
permissions: ['create("users")'],
|
||
fileSecurity: true,
|
||
maximumFileSize: 5242880,
|
||
allowedFileExtensions: ['png', 'webp'],
|
||
compression: 'none',
|
||
antivirus: false,
|
||
},
|
||
],
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Reine Helfer (ohne Netzwerk, einzeln testbar)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** 404 heißt „Objekt fehlt" – außer der ganze Projekt-Kontext fehlt (project_not_found). */
|
||
export function isMissing(status, body) {
|
||
return status === 404 && body?.type !== 'project_not_found';
|
||
}
|
||
|
||
const fmt = (v) => {
|
||
if (typeof v === 'string') return v;
|
||
if (Array.isArray(v)) return `[${v.join(', ')}]`;
|
||
return JSON.stringify(v) ?? 'undefined';
|
||
};
|
||
|
||
const sorted = (list) => [...(list ?? [])].sort();
|
||
const sameList = (a, b) => JSON.stringify(sorted(a)) === JSON.stringify(sorted(b));
|
||
|
||
function mismatch(field, actual, expected) {
|
||
return `${field} ist ${fmt(actual)}, erwartet ${fmt(expected)}`;
|
||
}
|
||
|
||
/** Vergleich der Tabelle: `$permissions` (Reihenfolge egal) und `rowSecurity`. */
|
||
export function compareTable(actual, desired) {
|
||
const out = [];
|
||
if (!sameList(actual.$permissions, desired.permissions)) {
|
||
out.push(mismatch('$permissions', sorted(actual.$permissions), sorted(desired.permissions)));
|
||
}
|
||
if (actual.rowSecurity !== desired.rowSecurity) {
|
||
out.push(mismatch('rowSecurity', actual.rowSecurity, desired.rowSecurity));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Vergleich einer Spalte, je nach Typ:
|
||
* string → type, size, required
|
||
* boolean → type, required, default
|
||
* integer → type, required, default (min/max der Antwort sind riesige Standardwerte, werden nicht verglichen)
|
||
* enum → elements (sortiert), required (Appwrite meldet Enum-Spalten als type `string` + format `enum`)
|
||
* Außerdem immer `status` (erwartet `available`).
|
||
*/
|
||
export function compareColumn(actual, desired) {
|
||
const out = [];
|
||
if (desired.type !== 'enum' && actual.type !== desired.type) out.push(mismatch('type', actual.type, desired.type));
|
||
if (desired.type === 'string' && actual.size !== desired.size) out.push(mismatch('size', actual.size, desired.size));
|
||
if (desired.type === 'enum' && !sameList(actual.elements, desired.elements)) {
|
||
out.push(mismatch('elements', sorted(actual.elements), sorted(desired.elements)));
|
||
}
|
||
if (actual.required !== desired.required) out.push(mismatch('required', actual.required, desired.required));
|
||
if ((desired.type === 'boolean' || desired.type === 'integer') && actual.default !== desired.default) {
|
||
out.push(mismatch('default', actual.default, desired.default));
|
||
}
|
||
if (actual.status !== 'available') out.push(mismatch('status', actual.status, 'available'));
|
||
return out;
|
||
}
|
||
|
||
/** Vergleich eines Index: `type`, `columns` (ältere Antworten: `attributes`; Reihenfolge zählt), `status`. */
|
||
export function compareIndex(actual, desired) {
|
||
const out = [];
|
||
const columns = Array.isArray(actual.columns) ? actual.columns : (actual.attributes ?? []);
|
||
if (actual.type !== desired.type) out.push(mismatch('type', actual.type, desired.type));
|
||
if (JSON.stringify(columns) !== JSON.stringify(desired.columns)) out.push(mismatch('columns', columns, desired.columns));
|
||
if (actual.status !== 'available') out.push(mismatch('status', actual.status, 'available'));
|
||
return out;
|
||
}
|
||
|
||
/** Vergleich des Buckets: Rechte, `fileSecurity`, `maximumFileSize`, `allowedFileExtensions` (sortiert). */
|
||
export function compareBucket(actual, desired) {
|
||
const out = [];
|
||
if (!sameList(actual.$permissions, desired.permissions)) {
|
||
out.push(mismatch('$permissions', sorted(actual.$permissions), sorted(desired.permissions)));
|
||
}
|
||
if (actual.fileSecurity !== desired.fileSecurity) {
|
||
out.push(mismatch('fileSecurity', actual.fileSecurity, desired.fileSecurity));
|
||
}
|
||
if (actual.maximumFileSize !== desired.maximumFileSize) {
|
||
out.push(mismatch('maximumFileSize', actual.maximumFileSize, desired.maximumFileSize));
|
||
}
|
||
if (!sameList(actual.allowedFileExtensions, desired.allowedFileExtensions)) {
|
||
out.push(mismatch('allowedFileExtensions', sorted(actual.allowedFileExtensions), sorted(desired.allowedFileExtensions)));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Zugriff auf die Appwrite-REST-API
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const REQUEST_TIMEOUT_MS = 20_000;
|
||
|
||
/** Abbruch mit verständlicher Meldung (Exit 2). Enthält nie den Key. */
|
||
class SetupError extends Error {}
|
||
|
||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||
|
||
/** `api(method, path, body?)` → `{ status, body }` für Aufrufe gegen Endpoint + Pfad. */
|
||
function createApi({ endpoint, key, project }) {
|
||
const base = endpoint.replace(/\/+$/, '');
|
||
return async function api(method, path, body) {
|
||
let res;
|
||
try {
|
||
res = await fetch(`${base}${path}`, {
|
||
method,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-Appwrite-Project': project,
|
||
'X-Appwrite-Key': key,
|
||
},
|
||
body: body === undefined ? undefined : JSON.stringify(body),
|
||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||
});
|
||
} catch (err) {
|
||
// Nur Fehlercode bzw. -name ausgeben, nie das Request-Objekt.
|
||
throw new SetupError(`Appwrite nicht erreichbar (${base}): ${err?.cause?.code ?? err?.name ?? 'unbekannter Fehler'}.`);
|
||
}
|
||
const text = await res.text();
|
||
let json;
|
||
try {
|
||
json = text ? JSON.parse(text) : undefined;
|
||
} catch {
|
||
json = { message: text.replace(/\s+/g, ' ').slice(0, 200) };
|
||
}
|
||
if (typeof json?.message === 'string') json.message = json.message.split(key).join('***');
|
||
return { status: res.status, body: json };
|
||
};
|
||
}
|
||
|
||
/** Klare deutsche Meldung für eine unerwartete Antwort. */
|
||
function describeFailure(status, body, what, project) {
|
||
const detail = [body?.type, body?.message].filter((part) => typeof part === 'string' && part).join(': ');
|
||
const tail = detail ? ` Server: ${detail}` : '';
|
||
if (status === 404 && body?.type === 'project_not_found') {
|
||
return `Das Projekt „${project}” existiert auf dem Server nicht. Bitte zuerst docs/appwrite-console.md abarbeiten und die AIRCARD_APPWRITE_PROJECT_ID in /home/webklar/apps/.env prüfen.`;
|
||
}
|
||
if (status === 401 || status === 403) {
|
||
return `${what}: Zugriff verweigert (HTTP ${status}). API-Key und Scopes prüfen (databases, tables, columns, buckets – jeweils read/write).${tail}`;
|
||
}
|
||
if (status >= 500) return `${what}: Serverfehler (HTTP ${status}).${tail}`;
|
||
return `${what}: unerwartete Antwort (HTTP ${status}).${tail}`;
|
||
}
|
||
|
||
/** GET: Objekt oder `null` (fehlt). Jeder andere Fehler bricht ab. */
|
||
async function getObject(ctx, path, what) {
|
||
const res = await ctx.api('GET', path);
|
||
if (res.status === 200) return res.body;
|
||
if (isMissing(res.status, res.body)) return null;
|
||
throw new SetupError(describeFailure(res.status, res.body, what, ctx.project));
|
||
}
|
||
|
||
async function createObject(ctx, path, body, what) {
|
||
const res = await ctx.api('POST', path, body);
|
||
if (res.status >= 200 && res.status < 300) return res.body;
|
||
throw new SetupError(describeFailure(res.status, res.body, what, ctx.project));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Objekte sicherstellen
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const DB_ID = DESIRED.database.id;
|
||
const tablePath = (tableId) => `/tablesdb/${DB_ID}/tables/${tableId}`;
|
||
const columnPath = (tableId, key) => `${tablePath(tableId)}/columns/${key}`;
|
||
const indexPath = (tableId, key) => `${tablePath(tableId)}/indexes/${key}`;
|
||
|
||
/** Gibt zurück, ob die Datenbank (jetzt) existiert. */
|
||
export async function ensureDatabase(ctx) {
|
||
const { id, name } = DESIRED.database;
|
||
const label = `Datenbank ${id}`;
|
||
if (await getObject(ctx, `/tablesdb/${id}`, label)) {
|
||
ctx.ok(label);
|
||
return true;
|
||
}
|
||
if (ctx.check) {
|
||
ctx.missing(label);
|
||
return false;
|
||
}
|
||
await createObject(ctx, '/tablesdb', { databaseId: id, name }, label);
|
||
ctx.created(label);
|
||
return true;
|
||
}
|
||
|
||
/** Gibt zurück, ob die Tabelle (jetzt) existiert. `parentExists`: Datenbank vorhanden. */
|
||
export async function ensureTable(ctx, table, parentExists) {
|
||
const label = `Tabelle ${table.id}`;
|
||
const actual = parentExists ? await getObject(ctx, tablePath(table.id), label) : null;
|
||
if (actual) {
|
||
const deviations = compareTable(actual, table);
|
||
if (deviations.length) ctx.deviations(table.id, deviations);
|
||
else ctx.ok(label);
|
||
return true;
|
||
}
|
||
if (ctx.check) {
|
||
ctx.missing(label);
|
||
return false;
|
||
}
|
||
await createObject(
|
||
ctx,
|
||
`/tablesdb/${DB_ID}/tables`,
|
||
{ tableId: table.id, name: table.name, permissions: table.permissions, rowSecurity: table.rowSecurity },
|
||
label,
|
||
);
|
||
ctx.created(label);
|
||
return true;
|
||
}
|
||
|
||
/** Spalten und Indizes entstehen asynchron (`processing`) und werden über ihren eigenen Pfad abgefragt. */
|
||
const CHILD_KINDS = {
|
||
column: { noun: 'Spalte', plural: 'Spalte(n)', path: columnPath },
|
||
index: { noun: 'Index', plural: 'Index(e)', path: indexPath },
|
||
};
|
||
|
||
/**
|
||
* Wartet (bis `ctx.timeoutMs`), bis alle genannten Spalten (`kind` = 'column')
|
||
* bzw. Indizes (`kind` = 'index') der Tabelle `available` sind.
|
||
* Gibt eine Map Schlüssel → aktueller Zustand zurück.
|
||
*/
|
||
export async function waitAvailable(ctx, tableId, keys, kind = 'column') {
|
||
const { noun, plural, path } = CHILD_KINDS[kind];
|
||
const deadline = Date.now() + ctx.timeoutMs;
|
||
const done = new Map();
|
||
let pending = [...keys];
|
||
for (;;) {
|
||
const still = [];
|
||
for (const key of pending) {
|
||
const label = `${noun} ${tableId}.${key}`;
|
||
const actual = await getObject(ctx, path(tableId, key), label);
|
||
if (!actual) throw new SetupError(`${label} ist beim Warten auf „available“ verschwunden.`);
|
||
if (actual.status === 'available') {
|
||
done.set(key, actual);
|
||
} else if (actual.status === 'failed' || actual.status === 'stuck') {
|
||
throw new SetupError(`${label}: Status „${actual.status}“. ${noun} in der Console prüfen, ggf. löschen, und das Skript erneut starten.`);
|
||
} else {
|
||
still.push(key);
|
||
}
|
||
}
|
||
if (!still.length) return done;
|
||
if (Date.now() >= deadline) {
|
||
throw new SetupError(
|
||
`${plural} ${still.map((key) => `${tableId}.${key}`).join(', ')} nicht innerhalb von ${ctx.timeoutMs / 1000} s verfügbar. Das Skript später erneut starten.`,
|
||
);
|
||
}
|
||
pending = still;
|
||
await sleep(ctx.pollMs);
|
||
}
|
||
}
|
||
|
||
/** REST-Pfad und Body zum Anlegen einer Spalte (je Typ eine eigene Route). */
|
||
function columnRequest(table, col) {
|
||
const path = `${tablePath(table.id)}/columns/${col.type}`;
|
||
switch (col.type) {
|
||
case 'string':
|
||
return { path, body: { key: col.key, size: col.size, required: col.required } };
|
||
case 'boolean':
|
||
return { path, body: { key: col.key, required: col.required, default: col.default } };
|
||
case 'integer':
|
||
return { path, body: { key: col.key, required: col.required, default: col.default, min: col.min } };
|
||
case 'enum':
|
||
return { path, body: { key: col.key, elements: col.elements, required: col.required } };
|
||
default:
|
||
throw new SetupError(`Unbekannter Spaltentyp „${col.type}“ für ${table.id}.${col.key}.`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Gemeinsamer Ablauf für Spalten und Indizes einer Tabelle: vorhandene lesen,
|
||
* fehlende nacheinander anlegen, danach auf `available` warten (nur ohne
|
||
* --check) und alles mit dem Soll vergleichen. Gibt die Einträge zurück
|
||
* (`actual` ist dort gesetzt, wo das Objekt existiert).
|
||
* `canCreate: false` → fehlende Objekte werden nur gemeldet, auch ohne --check.
|
||
*/
|
||
async function ensureChildren(ctx, table, parentExists, { kind, items, request, compare, canCreate = true }) {
|
||
const { noun, path } = CHILD_KINDS[kind];
|
||
const entries = [];
|
||
for (const item of items) {
|
||
const label = `${noun} ${table.id}.${item.key}`;
|
||
const actual = parentExists ? await getObject(ctx, path(table.id, item.key), label) : null;
|
||
if (actual) {
|
||
entries.push({ item, label, actual, created: false });
|
||
} else if (ctx.check || !canCreate) {
|
||
ctx.missing(label);
|
||
} else {
|
||
const { path: createPath, body } = request(item);
|
||
await createObject(ctx, createPath, body, label);
|
||
ctx.created(label);
|
||
entries.push({ item, label, actual: null, created: true });
|
||
}
|
||
}
|
||
|
||
if (!ctx.check) {
|
||
const waiting = entries.filter((e) => e.created || e.actual.status === 'processing');
|
||
if (waiting.length) {
|
||
const ready = await waitAvailable(ctx, table.id, waiting.map((e) => e.item.key), kind);
|
||
for (const e of waiting) e.actual = ready.get(e.item.key);
|
||
}
|
||
}
|
||
|
||
for (const e of entries) {
|
||
const deviations = compare(e.actual, e.item);
|
||
if (deviations.length) ctx.deviations(`${table.id}.${e.item.key}`, deviations);
|
||
else if (!e.created) ctx.ok(e.label);
|
||
}
|
||
return entries;
|
||
}
|
||
|
||
/**
|
||
* Spalten nacheinander anlegen, danach auf `available` warten – damit sind
|
||
* sie bereit, bevor jemand schreibt (und bevor Indizes angelegt werden).
|
||
* `parentExists`: Tabelle vorhanden. Gibt zurück, ob alle Spalten vorhanden
|
||
* und `available` sind (Voraussetzung für die Indizes).
|
||
*/
|
||
export async function ensureColumns(ctx, table, parentExists) {
|
||
const entries = await ensureChildren(ctx, table, parentExists, {
|
||
kind: 'column',
|
||
items: table.columns,
|
||
request: (col) => columnRequest(table, col),
|
||
compare: compareColumn,
|
||
});
|
||
return entries.length === table.columns.length && entries.every((e) => e.actual.status === 'available');
|
||
}
|
||
|
||
/**
|
||
* Indizes der Tabelle sicherstellen. Angelegt wird erst, wenn alle Spalten
|
||
* `available` sind (`columnsReady`), sonst wird der Index nur als fehlend
|
||
* gemeldet (die Spalte steht dann schon als ABWEICHUNG/FEHLT in der Ausgabe).
|
||
*/
|
||
export async function ensureIndexes(ctx, table, parentExists, columnsReady = true) {
|
||
const indexes = table.indexes ?? [];
|
||
if (!indexes.length) return;
|
||
await ensureChildren(ctx, table, parentExists, {
|
||
kind: 'index',
|
||
items: indexes,
|
||
request: (idx) => ({
|
||
path: `${tablePath(table.id)}/indexes`,
|
||
body: { key: idx.key, type: idx.type, columns: idx.columns },
|
||
}),
|
||
compare: compareIndex,
|
||
canCreate: columnsReady,
|
||
});
|
||
}
|
||
|
||
export async function ensureBucket(ctx, bucket) {
|
||
const b = bucket;
|
||
const label = `Bucket ${b.id}`;
|
||
const actual = await getObject(ctx, `/storage/buckets/${b.id}`, label);
|
||
if (actual) {
|
||
const deviations = compareBucket(actual, b);
|
||
if (deviations.length) ctx.deviations(b.id, deviations);
|
||
else ctx.ok(label);
|
||
return;
|
||
}
|
||
if (ctx.check) {
|
||
ctx.missing(label);
|
||
return;
|
||
}
|
||
await createObject(
|
||
ctx,
|
||
'/storage/buckets',
|
||
{
|
||
bucketId: b.id,
|
||
name: b.name,
|
||
permissions: b.permissions,
|
||
fileSecurity: b.fileSecurity,
|
||
maximumFileSize: b.maximumFileSize,
|
||
allowedFileExtensions: b.allowedFileExtensions,
|
||
compression: b.compression,
|
||
antivirus: b.antivirus,
|
||
},
|
||
label,
|
||
);
|
||
ctx.created(label);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Ablauf
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Führt das Setup (oder mit `check` nur die Prüfung) aus und gibt den Exit-Code
|
||
* zurück: 0 vollständig, 1 fehlt/weicht ab, 2 Abbruch.
|
||
*/
|
||
export async function main({
|
||
endpoint = DEFAULT_ENDPOINT,
|
||
key,
|
||
project,
|
||
check = false,
|
||
log = console.log,
|
||
pollMs = 1000,
|
||
timeoutMs = 30_000,
|
||
} = {}) {
|
||
if (!project) {
|
||
throw new SetupError('project-Parameter erforderlich');
|
||
}
|
||
const stats = { missing: 0, deviations: 0 };
|
||
const ctx = {
|
||
api: createApi({ endpoint, key, project }),
|
||
check,
|
||
pollMs,
|
||
timeoutMs,
|
||
project,
|
||
ok: (label) => log(`OK: ${label}`),
|
||
created: (label) => log(`ANGELEGT: ${label}`),
|
||
missing: (label) => {
|
||
stats.missing += 1;
|
||
log(`FEHLT: ${label}`);
|
||
},
|
||
deviations: (prefix, list) => {
|
||
for (const text of list) {
|
||
stats.deviations += 1;
|
||
log(`ABWEICHUNG: ${prefix}.${text}`);
|
||
}
|
||
},
|
||
};
|
||
|
||
log(`Appwrite ${endpoint}, Projekt ${project}, ${check ? 'nur prüfen (--check)' : 'prüfen und fehlende Objekte anlegen'}`);
|
||
try {
|
||
const databaseExists = await ensureDatabase(ctx);
|
||
for (const table of DESIRED.tables) {
|
||
const tableExists = await ensureTable(ctx, table, databaseExists);
|
||
const columnsReady = await ensureColumns(ctx, table, tableExists);
|
||
await ensureIndexes(ctx, table, tableExists, columnsReady);
|
||
}
|
||
for (const bucket of DESIRED.buckets) await ensureBucket(ctx, bucket);
|
||
} catch (err) {
|
||
if (!(err instanceof SetupError)) throw err;
|
||
log(`ABBRUCH: ${err.message}`);
|
||
return 2;
|
||
}
|
||
|
||
if (stats.missing || stats.deviations) {
|
||
const parts = [];
|
||
if (stats.missing) parts.push(`${stats.missing} fehlend`);
|
||
if (stats.deviations) parts.push(`${stats.deviations} Abweichung(en)`);
|
||
log(`UNVOLLSTÄNDIG: ${parts.join(', ')}.`);
|
||
if (stats.missing) log('Zum Anlegen das Skript ohne --check starten.');
|
||
if (stats.deviations) log('Abweichungen werden nie automatisch geändert – bitte in der Console korrigieren.');
|
||
return 1;
|
||
}
|
||
log('OK: Schema vollständig');
|
||
return 0;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Kommandozeile
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function cli() {
|
||
const args = process.argv.slice(2);
|
||
const unknown = args.filter((arg) => arg !== '--check');
|
||
if (unknown.length) {
|
||
console.error(`Unbekannte Option: ${unknown.join(' ')}`);
|
||
console.error('Aufruf: node --env-file=/home/webklar/apps/.env web/scripts/appwrite-setup.mjs [--check]');
|
||
process.exitCode = 2;
|
||
return;
|
||
}
|
||
const key = process.env.AIRCARD_APPWRITE_API_KEY?.trim();
|
||
if (!key) {
|
||
console.error('Fehler: AIRCARD_APPWRITE_API_KEY fehlt.');
|
||
console.error('Den API-Key „aircard-provision” als Zeile AIRCARD_APPWRITE_API_KEY=… in /home/webklar/apps/.env eintragen (siehe docs/appwrite-console.md)');
|
||
console.error('und das Skript mit --env-file=/home/webklar/apps/.env starten.');
|
||
process.exitCode = 2;
|
||
return;
|
||
}
|
||
const project = process.env.AIRCARD_APPWRITE_PROJECT_ID?.trim();
|
||
if (!project) {
|
||
console.error('Fehler: AIRCARD_APPWRITE_PROJECT_ID fehlt.');
|
||
console.error('Die Projekt-ID als Zeile AIRCARD_APPWRITE_PROJECT_ID=6ac2130a00289343d6d5 in /home/webklar/apps/.env eintragen.');
|
||
console.error('und das Skript mit --env-file=/home/webklar/apps/.env starten.');
|
||
process.exitCode = 2;
|
||
return;
|
||
}
|
||
process.exitCode = await main({
|
||
endpoint: process.env.AIRCARD_APPWRITE_ENDPOINT || DEFAULT_ENDPOINT,
|
||
key,
|
||
project,
|
||
check: args.includes('--check'),
|
||
});
|
||
}
|
||
|
||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||
try {
|
||
await cli();
|
||
} catch (err) {
|
||
console.error(`Unerwarteter Fehler: ${err?.message ?? err}`);
|
||
process.exitCode = 2;
|
||
}
|
||
}
|