#!/usr/bin/env node /** * AirCard – Appwrite-Schema-Setup (idempotent) * * Legt im selbst gehosteten Appwrite (Projekt `aircard`) Datenbank, Tabelle * `cards` (TablesDB) mit vier Spalten und den Bucket `card-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. 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: ist , erwartet * * 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 // --------------------------------------------------------------------------- export const DESIRED = { database: { id: 'aircard', name: 'AirCard' }, table: { id: 'cards', name: 'Cards', permissions: ['create("users")'], rowSecurity: true, columns: [ { key: 'name', size: 80, required: true }, { key: 'config', size: 2000, required: true }, { key: 'imageFileId', size: 36, required: true }, { key: 'previewFileId', size: 36, required: true }, ], }, bucket: { id: 'card-images', name: 'Card Images', permissions: ['create("users")'], fileSecurity: true, maximumFileSize: 5242880, allowedFileExtensions: ['png', 'jpg', 'jpeg', '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: `size`, `required`, `status` (erwartet `available`). */ export function compareColumn(actual, desired) { const out = []; if (actual.size !== desired.size) out.push(mismatch('size', actual.size, desired.size)); if (actual.required !== desired.required) out.push(mismatch('required', actual.required, desired.required)); 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 columnPath = (key) => `/tablesdb/${DB_ID}/tables/${DESIRED.table.id}/columns/${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, parentExists) { const t = DESIRED.table; const label = `Tabelle ${t.id}`; const actual = parentExists ? await getObject(ctx, `/tablesdb/${DB_ID}/tables/${t.id}`, label) : null; if (actual) { const deviations = compareTable(actual, t); if (deviations.length) ctx.deviations(t.id, deviations); else ctx.ok(label); return true; } if (ctx.check) { ctx.missing(label); return false; } await createObject( ctx, `/tablesdb/${DB_ID}/tables`, { tableId: t.id, name: t.name, permissions: t.permissions, rowSecurity: t.rowSecurity }, label, ); ctx.created(label); return true; } /** * Wartet (bis `ctx.timeoutMs`), bis alle genannten Spalten `available` sind. * Gibt eine Map Schlüssel → aktueller Spaltenzustand zurück. */ export async function waitAvailable(ctx, tableId, keys) { const deadline = Date.now() + ctx.timeoutMs; const done = new Map(); let pending = [...keys]; for (;;) { const still = []; for (const key of pending) { const label = `Spalte ${tableId}.${key}`; const actual = await getObject(ctx, columnPath(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}“. Spalte 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( `Spalte(n) ${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); } } /** * Spalten nacheinander anlegen, danach auf `available` warten – damit sind * sie bereit, bevor jemand schreibt. `parentExists`: Tabelle vorhanden. */ export async function ensureColumns(ctx, parentExists) { const t = DESIRED.table; const entries = []; for (const col of t.columns) { const label = `Spalte ${t.id}.${col.key}`; const actual = parentExists ? await getObject(ctx, columnPath(col.key), label) : null; if (actual) { entries.push({ col, label, actual, created: false }); } else if (ctx.check) { ctx.missing(label); } else { await createObject( ctx, `/tablesdb/${DB_ID}/tables/${t.id}/columns/string`, { key: col.key, size: col.size, required: col.required }, label, ); ctx.created(label); entries.push({ col, 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, t.id, waiting.map((e) => e.col.key)); for (const e of waiting) e.actual = ready.get(e.col.key); } } for (const e of entries) { const deviations = compareColumn(e.actual, e.col); if (deviations.length) ctx.deviations(`${t.id}.${e.col.key}`, deviations); else if (!e.created) ctx.ok(e.label); } } export async function ensureBucket(ctx) { const b = DESIRED.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); const tableExists = await ensureTable(ctx, databaseExists); await ensureColumns(ctx, tableExists); await ensureBucket(ctx); } 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; } }