Files
AirCardIOS/web/scripts/appwrite-setup.mjs
T
ClaudeandClaude Opus 5.5 d1d4d621a7 feat(appwrite): Setup-Skript und Console-Anleitung
web/scripts/appwrite-setup.mjs legt Datenbank aircard, Tabelle cards
(4 Spalten) und Bucket card-images idempotent per fetch an, wartet auf
Spalten-Status available und meldet Abweichungen, ohne sie zu ändern
(--check: nichts anlegen, Exit 1 bei Fehlendem/Abweichendem).
docs/appwrite-console.md ist die Klickliste für Projekt, Plattformen,
Auth, Dienste und API-Key. Tests laufen mit node --test gegen einen
Fake-Appwrite; Vitest nimmt nur src/**/*.test.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 08:39:02 +00:00

449 lines
16 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`) 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: <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';
const PROJECT = 'aircard';
// ---------------------------------------------------------------------------
// 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) {
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 (Projekt mit der ID „${PROJECT}“ anlegen).`;
}
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));
}
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));
}
// ---------------------------------------------------------------------------
// 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 = PROJECT,
check = false,
log = console.log,
pollMs = 1000,
timeoutMs = 30_000,
} = {}) {
const stats = { missing: 0, deviations: 0 };
const ctx = {
api: createApi({ endpoint, key, project }),
check,
pollMs,
timeoutMs,
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;
}
process.exitCode = await main({
endpoint: process.env.AIRCARD_APPWRITE_ENDPOINT || DEFAULT_ENDPOINT,
key,
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;
}
}