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>
This commit is contained in:
1 parent
906bdf1033
commit
d1d4d621a7
4 files changed
+1100
No files matched your search
@@ -0,0 +1,448 @@
|
||||
#!/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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user