Files
AirCardIOS/web/scripts/appwrite-setup.mjs
T
ClaudeandClaude Opus 5.5 19c8151c7e feat(api): Benutzernamen (Profil-Tabelle, /api/profil, Pflicht beim Hochladen und Veröffentlichen)
- Schema: Tabelle `profiles` (Zeilen-ID = Konto-ID, `username` und `usernameKey`
  je 20 Zeichen, Unique-Index `username_key` auf `usernameKey`, rowSecurity an,
  keine Tabellenrechte) im Setup-Skript.
- Neues Modul `deploy/api/profiles.js`: `POST /api/profil` legt das Profil an
  oder ändert es (Zeilenrecht genau read("any"), Sperre je Konto). Muster,
  Sperrliste und Schlüssel (`usernameKey`) stehen in `common.js`; vergeben ->
  409 `usernameTaken`, auch wenn erst der Unique-Index bei einem Rennen
  entscheidet. Dazu die MSG-Texte `usernameInvalid`, `usernameReserved`,
  `usernameTaken`, `usernameRequired`.
- `POST /api/logos/hochladen` und `POST /api/veroeffentlichen` antworten 403
  `usernameRequired`, wenn das Konto kein Profil hat (`hasProfile`, nach der
  Anmeldung und vor allen anderen Prüfungen).
- Fake-Appwrite: Tabelle `profiles` mit Unique-Index (409), `addProfile`,
  `holdNext`. API-Dienst besteht jetzt aus sechs Dateien (READMEs).
- Client: die vier API-Texte in `API_MESSAGES` und `en.ts` (Gleichlauf mit MSG).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 06:40:44 +00:00

673 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* AirCard – Appwrite-Schema-Setup (idempotent)
*
* Legt im selbst gehosteten Appwrite (Projekt `aircard`) die Datenbank `aircard`,
* die Tabellen `cards`, `gallery`, `reports`, `logos`, `logo_likes`,
* `logo_reports` und `profiles` (TablesDB, mit Spalten und Indizes) und die
* Buckets `card-images`, `gallery-images` und `logo-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, logos, logo_likes, logo_reports, profiles (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'] }],
},
{
// Community-Logos. Wie `gallery`: Zeilenrechte setzt nur die API, Clients dürfen nichts auf Tabellenebene.
id: 'logos',
name: 'Logos',
permissions: [],
rowSecurity: true,
columns: [
str('name', 40, true),
str('displayName', 30, false),
str('fileId', 36, true),
str('ownerId', 36, true),
{ key: 'mono', type: 'boolean', required: false, default: false },
{ key: 'likeCount', type: 'integer', required: false, default: 0, min: 0 },
{ key: 'hidden', type: 'boolean', required: false, default: false },
{ key: 'reportCount', type: 'integer', required: false, default: 0, min: 0 },
],
indexes: [
{ key: 'owner', type: 'key', columns: ['ownerId'] },
{ key: 'hidden', type: 'key', columns: ['hidden'] },
{ key: 'likes', type: 'key', columns: ['likeCount'] },
],
},
{
// Ein Herz je Konto und Logo: Zeilen-ID hash32("<logoId>|<userId>"), lesen darf nur das Konto selbst; geschrieben wird nur über die API.
id: 'logo_likes',
name: 'Logo Likes',
permissions: [],
rowSecurity: true,
columns: [str('logoId', 36, true), str('userId', 36, true)],
indexes: [
{ key: 'logo', type: 'key', columns: ['logoId'] },
// Mehrspalten-Index: die Reihenfolge der Spalten zählt (und wird verglichen).
{ key: 'user_logo', type: 'key', columns: ['userId', 'logoId'] },
],
},
{
// Nur Admins lesen (Label `admin`); geschrieben wird ausschließlich über die API.
id: 'logo_reports',
name: 'Logo Reports',
permissions: ['read("label:admin")'],
rowSecurity: false,
columns: [
str('logoId', 36, true),
{ key: 'reason', type: 'enum', elements: ['unangemessen', 'kartendaten', 'urheberrecht', 'spam', 'sonstiges'], required: true },
str('reporterHash', 64, true),
],
indexes: [{ key: 'logo', type: 'key', columns: ['logoId'] }],
},
{
// Benutzernamen: eine Zeile je Konto, Zeilen-ID = Konto-ID, öffentlich lesbar (Zeilenrecht read("any") setzt die API); geschrieben wird nur
// über die API. `usernameKey` ist der Name klein geschrieben: der Unique-Index macht ihn auch bei gleichzeitigen Anfragen nur einmal vergebbar.
id: 'profiles',
name: 'Profiles',
permissions: [],
rowSecurity: true,
columns: [str('username', 20, true), str('usernameKey', 20, true)],
indexes: [{ key: 'username_key', type: 'unique', columns: ['usernameKey'] }],
},
],
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,
},
{
// Logo-PNG (höchstens 1 MiB); die Dateirechte setzt die API je Datei.
id: 'logo-images',
name: 'Logo Images',
permissions: ['create("users")'],
fileSecurity: true,
maximumFileSize: 1048576,
allowedFileExtensions: ['png'],
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;
}
}