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>
449 lines
16 KiB
JavaScript
449 lines
16 KiB
JavaScript
#!/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;
|
||
}
|
||
}
|