Decouple Directus from project

This commit is contained in:
2026-06-21 09:33:54 -04:00
parent 31a8a43225
commit 9b7b5401b0
12 changed files with 83 additions and 877 deletions
-129
View File
@@ -1,129 +0,0 @@
#!/usr/bin/env node
// Bootstrap Directus schema using the directus CLI (schema apply)
// This approach bypasses the REST API permission issues entirely
import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const __filename = fileURLToPath(import.meta.url);
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'http://directus:8055';
const DIRECTUS_TOKEN = process.env.DIRECTUS_TOKEN || '';
const DIRECTUS_ADMIN_EMAIL = process.env.DIRECTUS_ADMIN_EMAIL || 'admin@example.com';
const DIRECTUS_ADMIN_PASSWORD = process.env.DIRECTUS_ADMIN_PASSWORD || 'change-me';
const SNAPSHOT_PATH = path.join(__dirname, '..', 'directus', 'schema-snapshot.json');
console.log('========================================');
console.log('Directus Bootstrap (CLI-based)');
console.log('========================================');
console.log('');
// Check if snapshot exists
if (!fs.existsSync(SNAPSHOT_PATH)) {
console.error(`❌ ERROR: Schema snapshot not found at ${SNAPSHOT_PATH}`);
process.exit(1);
}
console.log(`✓ Found schema snapshot at ${SNAPSHOT_PATH}`);
console.log('');
// Determine authentication method
let hasToken = Boolean(DIRECTUS_TOKEN);
if (hasToken) {
console.log('✓ DIRECTUS_TOKEN is set; will use bearer token');
} else {
console.log('⚠️ DIRECTUS_TOKEN not set; will attempt email/password login');
}
console.log('');
// Wait for Directus to be fully ready
console.log('⏳ Waiting for Directus to be ready...');
const maxRetries = 30;
let retry = 0;
async function checkHealth() {
while (retry < maxRetries) {
try {
const response = await fetch(`${DIRECTUS_URL}/server/health`, {
timeout: 5000,
});
if (response.ok) {
console.log('✓ Directus is ready');
return true;
}
} catch (e) {
// Continue retrying
}
retry++;
if (retry % 5 === 0) {
process.stdout.write('.');
}
await new Promise(r => setTimeout(r, 1000));
}
if (retry >= maxRetries) {
console.error(`\n❌ ERROR: Directus did not become ready after ${maxRetries} seconds`);
process.exit(1);
}
return false;
}
async function applySchema() {
await checkHealth();
console.log('');
console.log('📋 Applying schema snapshot...');
console.log(' This will create all collections, fields, and relations.');
console.log('');
// Set environment variables for the directus CLI
process.env.DIRECTUS_URL = DIRECTUS_URL;
process.env.DIRECTUS_TOKEN = DIRECTUS_TOKEN;
process.env.DIRECTUS_ADMIN_EMAIL = DIRECTUS_ADMIN_EMAIL;
process.env.DIRECTUS_ADMIN_PASSWORD = DIRECTUS_ADMIN_PASSWORD;
try {
// Execute the schema apply command using npx
// The directus CLI is available globally in the directus container
const command = `npx directus schema apply "${SNAPSHOT_PATH}" --yes`;
console.log(`Running: ${command}`);
console.log('');
// Execute synchronously and capture output
const output = execSync(command, {
stdio: 'inherit',
env: process.env,
});
console.log('');
console.log('✅ SUCCESS: Schema snapshot applied successfully!');
console.log('');
console.log('Created collections:');
console.log(' - tags');
console.log(' - news');
console.log(' - events');
console.log(' - notices');
console.log(' - fuel_prices');
console.log(' - documents');
console.log(' - contacts');
console.log(' - news_tags (M2M junction)');
console.log(' - events_tags (M2M junction)');
console.log('');
console.log('All fields and relations have been initialized.');
process.exit(0);
} catch (error) {
console.error('');
console.error('❌ ERROR: Schema apply failed');
console.error(error.message);
process.exit(1);
}
}
// Run the bootstrap
applySchema().catch((error) => {
console.error('❌ Unexpected error:', error);
process.exit(1);
});
-87
View File
@@ -1,87 +0,0 @@
#!/bin/bash
# Bootstrap Directus schema using the directus CLI (schema apply)
# This approach bypasses the REST API permission issues entirely
set -e
DIRECTUS_URL="${DIRECTUS_URL:-http://directus:8055}"
DIRECTUS_TOKEN="${DIRECTUS_TOKEN:-}"
DIRECTUS_ADMIN_EMAIL="${DIRECTUS_ADMIN_EMAIL:-admin@example.com}"
DIRECTUS_ADMIN_PASSWORD="${DIRECTUS_ADMIN_PASSWORD:-change-me}"
SNAPSHOT_PATH="/app/directus/schema-snapshot.json"
echo "========================================"
echo "Directus Bootstrap (CLI-based)"
echo "========================================"
# Check if snapshot exists
if [ ! -f "$SNAPSHOT_PATH" ]; then
echo "❌ ERROR: Schema snapshot not found at $SNAPSHOT_PATH"
exit 1
fi
echo "✓ Found schema snapshot at $SNAPSHOT_PATH"
# Determine authentication method
if [ -z "$DIRECTUS_TOKEN" ]; then
echo "⚠️ DIRECTUS_TOKEN not set; will attempt email/password login"
LOGIN_METHOD="email"
else
echo "✓ DIRECTUS_TOKEN is set; will use bearer token"
LOGIN_METHOD="token"
fi
# Wait for Directus to be fully ready
echo "⏳ Waiting for Directus to be ready..."
max_retries=30
retry=0
while [ $retry -lt $max_retries ]; do
if curl -s "$DIRECTUS_URL/server/health" > /dev/null 2>&1; then
echo "✓ Directus is ready"
break
fi
retry=$((retry + 1))
if [ $retry -eq $max_retries ]; then
echo "❌ ERROR: Directus did not become ready after ${max_retries} seconds"
exit 1
fi
sleep 1
done
# Apply schema snapshot using directus CLI
echo ""
echo "📋 Applying schema snapshot..."
echo " This will create all collections, fields, and relations."
echo ""
# The directus CLI is available inside the directus container
# We'll use it via npx since directus is installed globally there
export DIRECTUS_URL
export DIRECTUS_TOKEN
export DIRECTUS_ADMIN_EMAIL
export DIRECTUS_ADMIN_PASSWORD
# Execute the schema apply command
# Note: The CLI will prompt for missing credentials if needed
if npx directus schema apply "$SNAPSHOT_PATH" --yes 2>&1; then
echo ""
echo "✅ SUCCESS: Schema snapshot applied successfully!"
echo ""
echo "Created collections:"
echo " - tags"
echo " - news"
echo " - events"
echo " - notices"
echo " - fuel_prices"
echo " - documents"
echo " - contacts"
echo " - news_tags (M2M junction)"
echo " - events_tags (M2M junction)"
echo ""
echo "All fields and relations have been initialized."
exit 0
else
echo ""
echo "❌ ERROR: Schema apply failed"
exit 1
fi
-187
View File
@@ -1,187 +0,0 @@
#!/usr/bin/env node
/**
* Bootstrap Directus schema by creating PostgreSQL tables.
* Directus will auto-discover these tables on startup and create the field metadata automatically.
*/
import pkg from 'pg';
const { Pool } = pkg;
const pgConfig = {
host: process.env.POSTGRES_HOST || 'db',
port: parseInt(process.env.POSTGRES_PORT || '5432'),
user: process.env.POSTGRES_USER || 'directus',
password: process.env.POSTGRES_PASSWORD || 'change-me',
database: process.env.POSTGRES_DB || 'directus',
};
const pool = new Pool(pgConfig);
console.log('========================================');
console.log('Directus Schema Bootstrap');
console.log('========================================');
console.log('');
// Define collections and fields
const schema = {
tags: [
{ field: 'id', type: 'integer', nullable: false, default: null },
{ field: 'name', type: 'varchar(255)', nullable: false, default: null },
{ field: 'slug', type: 'varchar(255)', nullable: false, default: null },
],
news: [
{ field: 'id', type: 'integer', nullable: false, default: null },
{ field: 'title', type: 'varchar(255)', nullable: false, default: null },
{ field: 'slug', type: 'varchar(255)', nullable: false, default: null },
{ field: 'body', type: 'text', nullable: true, default: null },
{ field: 'summary', type: 'text', nullable: true, default: null },
{ field: 'featured_image', type: 'uuid', nullable: true, default: null },
{ field: 'status', type: 'varchar(255)', nullable: true, default: null },
{ field: 'publish_date', type: 'timestamp with time zone', nullable: true, default: null },
{ field: 'author', type: 'varchar(255)', nullable: true, default: null },
],
events: [
{ field: 'id', type: 'integer', nullable: false, default: null },
{ field: 'title', type: 'varchar(255)', nullable: false, default: null },
{ field: 'slug', type: 'varchar(255)', nullable: false, default: null },
{ field: 'description', type: 'text', nullable: true, default: null },
{ field: 'start_datetime', type: 'timestamp with time zone', nullable: false, default: null },
{ field: 'end_datetime', type: 'timestamp with time zone', nullable: true, default: null },
{ field: 'location_text', type: 'varchar(255)', nullable: true, default: null },
{ field: 'image', type: 'uuid', nullable: true, default: null },
{ field: 'registration_link', type: 'varchar(500)', nullable: true, default: null },
{ field: 'status', type: 'varchar(255)', nullable: true, default: null },
{ field: 'is_featured', type: 'boolean', nullable: true, default: 'false' },
],
notices: [
{ field: 'id', type: 'integer', nullable: false, default: null },
{ field: 'title', type: 'varchar(255)', nullable: false, default: null },
{ field: 'message', type: 'text', nullable: false, default: null },
{ field: 'severity', type: 'varchar(20)', nullable: true, default: "'info'" },
{ field: 'start_date', type: 'date', nullable: true, default: null },
{ field: 'end_date', type: 'date', nullable: true, default: null },
{ field: 'active', type: 'boolean', nullable: true, default: 'true' },
{ field: 'priority', type: 'integer', nullable: true, default: '0' },
],
fuel_prices: [
{ field: 'id', type: 'integer', nullable: false, default: null },
{ field: 'fuel_type', type: 'varchar(255)', nullable: false, default: null },
{ field: 'price_per_litre', type: 'numeric(10,2)', nullable: false, default: null },
{ field: 'currency', type: 'varchar(3)', nullable: true, default: "'GBP'" },
{ field: 'last_updated', type: 'timestamp with time zone', nullable: true, default: null },
{ field: 'notes', type: 'text', nullable: true, default: null },
],
documents: [
{ field: 'id', type: 'integer', nullable: false, default: null },
{ field: 'title', type: 'varchar(255)', nullable: false, default: null },
{ field: 'file', type: 'uuid', nullable: true, default: null },
{ field: 'category', type: 'varchar(255)', nullable: true, default: null },
{ field: 'description', type: 'text', nullable: true, default: null },
{ field: 'uploaded_at', type: 'timestamp with time zone', nullable: true, default: null },
],
contacts: [
{ field: 'id', type: 'integer', nullable: false, default: null },
{ field: 'name', type: 'varchar(255)', nullable: false, default: null },
{ field: 'role', type: 'varchar(255)', nullable: true, default: null },
{ field: 'email', type: 'varchar(255)', nullable: false, default: null },
{ field: 'phone', type: 'varchar(20)', nullable: true, default: null },
{ field: 'is_public', type: 'boolean', nullable: true, default: 'true' },
{ field: 'order', type: 'integer', nullable: true, default: '0' },
],
news_tags: [
{ field: 'id', type: 'integer', nullable: false, default: null },
{ field: 'news_id', type: 'integer', nullable: false, default: null },
{ field: 'tag_id', type: 'integer', nullable: false, default: null },
],
events_tags: [
{ field: 'id', type: 'integer', nullable: false, default: null },
{ field: 'event_id', type: 'integer', nullable: false, default: null },
{ field: 'tag_id', type: 'integer', nullable: false, default: null },
],
};
async function main() {
const client = await pool.connect();
try {
console.log('🔐 Connecting to PostgreSQL...');
await client.query('SELECT 1');
console.log('✓ Connected successfully');
console.log('');
console.log('📋 Creating tables...');
console.log('');
// Create all tables
const created = [];
for (const [collectionName, fields] of Object.entries(schema)) {
const wasCreated = await createTable(client, collectionName, fields);
if (wasCreated) created.push(collectionName);
}
console.log('');
if (created.length > 0) {
console.log('✅ Schema bootstrap completed successfully!');
console.log('');
console.log(`Created ${created.length} tables:`);
created.forEach(name => console.log(` - ${name}`));
console.log('');
console.log('Directus will discover these tables automatically.');
} else {
console.log('️ All tables already exist. Schema is up to date.');
}
} catch (error) {
console.error('❌ Bootstrap failed:', error.message);
console.error(error);
process.exit(1);
} finally {
client.release();
await pool.end();
}
}
async function createTable(client, collectionName, fields) {
// Check if table already exists
const checkResult = await client.query(
`SELECT to_regclass($1)`,
[`public.${collectionName}`]
);
if (checkResult.rows[0].to_regclass) {
console.log(` Table exists: ${collectionName}`);
return false;
}
// Build CREATE TABLE statement
const columnDefs = fields.map(f => {
let def = `"${f.field}" ${f.type}`;
if (!f.nullable) {
def += ' NOT NULL';
}
if (f.default) {
def += ` DEFAULT ${f.default}`;
}
return def;
});
// Add primary key if not already in fields
if (!fields.some(f => f.field === 'id')) {
columnDefs.unshift('"id" integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY');
}
const createTableSQL = `CREATE TABLE "public"."${collectionName}" (${columnDefs.join(', ')})`;
try {
await client.query(createTableSQL);
console.log(` ✓ Created table: ${collectionName}`);
return true;
} catch (error) {
if (error.code === '42P07') {
// Table already exists
console.log(` Table exists: ${collectionName}`);
return false;
}
throw error;
}
}
main();
-389
View File
@@ -1,389 +0,0 @@
#!/usr/bin/env node
/**
* Idempotent Directus schema bootstrap for the Swansea airfield project.
*
* Usage (containerized):
* docker run --rm --network webdev_default \
* -v "$PWD:/app" -w /app \
* -e DIRECTUS_BASE_URL=http://directus:8055 \
* -e DIRECTUS_EMAIL=admin@example.com \
* -e DIRECTUS_PASSWORD=change-me \
* node:22-alpine node scripts/bootstrap-directus.mjs
*/
const baseUrl = (process.env.DIRECTUS_URL || process.env.DIRECTUS_BASE_URL || 'http://localhost:8055').replace(/\/$/, '');
const email = process.env.DIRECTUS_ADMIN_EMAIL || process.env.DIRECTUS_EMAIL || 'admin@example.com';
const password = process.env.DIRECTUS_ADMIN_PASSWORD || process.env.DIRECTUS_PASSWORD || 'change-me';
// Note: We intentionally ignore pre-set DIRECTUS_TOKEN because it often lacks proper
// permissions. Email/password login to get a fresh admin token is more reliable.
const collections = [
{ collection: 'tags', icon: 'sell' },
{ collection: 'news', icon: 'article' },
{ collection: 'events', icon: 'event' },
{ collection: 'notices', icon: 'campaign' },
{ collection: 'fuel_prices', icon: 'local_gas_station' },
{ collection: 'documents', icon: 'description' },
{ collection: 'contacts', icon: 'contacts' },
{ collection: 'news_tags', icon: 'link', hidden: true },
{ collection: 'events_tags', icon: 'link', hidden: true },
];
const fieldsByCollection = {
tags: [
{ field: 'name', type: 'string', required: true },
{ field: 'slug', type: 'string', required: true },
],
news: [
{ field: 'title', type: 'string', required: true },
{ field: 'slug', type: 'string', required: true },
{ field: 'body', type: 'text' },
{ field: 'summary', type: 'text' },
{ field: 'featured_image', type: 'uuid' },
{ field: 'status', type: 'string' },
{ field: 'publish_date', type: 'dateTime' },
{ field: 'author', type: 'string' },
],
events: [
{ field: 'title', type: 'string', required: true },
{ field: 'slug', type: 'string', required: true },
{ field: 'description', type: 'text' },
{ field: 'start_datetime', type: 'dateTime', required: true },
{ field: 'end_datetime', type: 'dateTime' },
{ field: 'location_text', type: 'string' },
{ field: 'image', type: 'uuid' },
{ field: 'registration_link', type: 'string' },
{ field: 'status', type: 'string' },
{ field: 'is_featured', type: 'boolean', default: false },
],
notices: [
{ field: 'title', type: 'string', required: true },
{ field: 'message', type: 'text', required: true },
{ field: 'severity', type: 'string', default: 'info' },
{ field: 'start_date', type: 'date' },
{ field: 'end_date', type: 'date' },
{ field: 'active', type: 'boolean', default: true },
{ field: 'priority', type: 'integer', default: 0 },
],
fuel_prices: [
{ field: 'fuel_type', type: 'string', required: true },
{ field: 'price_per_litre', type: 'decimal', required: true },
{ field: 'currency', type: 'string', default: 'GBP' },
{ field: 'last_updated', type: 'dateTime' },
{ field: 'notes', type: 'text' },
],
documents: [
{ field: 'title', type: 'string', required: true },
{ field: 'file', type: 'uuid' },
{ field: 'category', type: 'string' },
{ field: 'description', type: 'text' },
{ field: 'uploaded_at', type: 'dateTime' },
],
contacts: [
{ field: 'name', type: 'string', required: true },
{ field: 'role', type: 'string' },
{ field: 'email', type: 'string', required: true },
{ field: 'phone', type: 'string' },
{ field: 'is_public', type: 'boolean', default: true },
{ field: 'order', type: 'integer', default: 0 },
],
news_tags: [
{ field: 'news_id', type: 'integer', required: true },
{ field: 'tag_id', type: 'integer', required: true },
],
events_tags: [
{ field: 'event_id', type: 'integer', required: true },
{ field: 'tag_id', type: 'integer', required: true },
],
};
const typeMap = {
string: { data_type: 'varchar', max_length: 255 },
text: { data_type: 'text' },
integer: { data_type: 'integer' },
decimal: { data_type: 'decimal', numeric_precision: 10, numeric_scale: 2 },
boolean: { data_type: 'boolean' },
date: { data_type: 'date' },
dateTime: { data_type: 'timestamp with time zone' },
uuid: { data_type: 'uuid' },
};
async function main() {
try {
// Wait for Directus to be ready
console.log('========================================');
console.log('Directus Schema Bootstrap');
console.log('========================================');
console.log('');
console.log('⏳ Waiting for Directus to be ready...');
let ready = false;
let retries = 0;
const maxRetries = 30;
while (!ready && retries < maxRetries) {
try {
const health = await fetch(`${baseUrl}/server/health`);
if (health.ok) {
ready = true;
console.log('✓ Directus is ready');
}
} catch (e) {
// Continue retrying
}
if (!ready) {
retries++;
if (retries % 5 === 0) {
process.stdout.write('.');
}
await new Promise(r => setTimeout(r, 1000));
}
}
if (!ready) {
throw new Error(`Directus did not become ready after ${maxRetries} seconds`);
}
console.log('');
console.log('🔐 Authenticating with email/password login...');
const token = await login(email, password);
console.log('✓ Authentication successful');
console.log('');
console.log('📋 Creating schema...');
console.log('');
// Create all collections with their fields in one call
// This ensures both metadata and tables are created
const created = [];
const existing = [];
for (const collection of collections) {
const collectionName = collection.collection;
const fields = fieldsByCollection[collectionName] || [];
const result = await ensureCollection(token, collection, fields);
if (result.created) {
created.push(collectionName);
} else {
existing.push(collectionName);
}
}
console.log('');
if (created.length > 0) {
console.log('✅ Schema bootstrap completed successfully!');
console.log('');
console.log(`Created ${created.length} collections:`);
created.forEach(name => console.log(` - ${name}`));
if (existing.length > 0) {
console.log('');
console.log(`${existing.length} collections already exist.`);
}
} else {
console.log('️ All collections already exist. Schema is up to date.');
}
console.log('');
} catch (error) {
console.error('');
console.error('❌ Bootstrap failed:', error.message);
console.error('');
if (error.details) {
console.error('Details:', error.details);
}
process.exit(1);
}
}
async function login(userEmail, userPassword) {
const response = await fetch(`${baseUrl}/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: userEmail, password: userPassword }),
});
if (!response.ok) {
throw new Error(`Login failed with status ${response.status}`);
}
const payload = await response.json();
const token = payload?.data?.access_token;
if (!token) {
throw new Error('No access token returned from Directus login.');
}
return token;
}
async function ensureCollection(token, collection, fields = []) {
const collectionName = collection.collection;
// Build field payloads - convert to Directus field format
const fieldPayloads = fields.map(field => {
const typeConfig = typeMap[field.type];
if (!typeConfig) {
throw new Error(`Unsupported field type: ${field.type}`);
}
return {
field: field.field,
type: field.type,
meta: {
required: Boolean(field.required),
interface: defaultInterfaceForType(field.type),
},
schema: {
...typeConfig,
is_nullable: !field.required,
default_value: field.default || null,
},
};
});
try {
const result = await fetchJson(`${baseUrl}/collections`, token, 'POST', {
collection: collectionName,
meta: {
icon: collection.icon,
hidden: Boolean(collection.hidden),
singleton: false,
note: `Managed by scripts/bootstrap-directus.mjs`,
},
// Include schema to tell Directus to create the table
schema: {
name: collectionName,
},
// Include fields to create them in one call
fields: fieldPayloads,
});
console.log(`Created collection: ${collectionName}`);
return { created: true };
} catch (error) {
// Check if it's a "collection already exists" error
if (error.message && error.message.includes('already exists')) {
console.log(`Collection exists: ${collectionName}`);
return { created: false };
}
// Check if response indicates duplicate
if (error.statusCode === 409 || error.statusCode === 400) {
console.log(`Collection exists: ${collectionName}`);
return { created: false };
}
throw error;
}
}
function defaultInterfaceForType(type) {
switch (type) {
case 'text':
return 'input-multiline';
case 'boolean':
return 'boolean';
case 'date':
return 'datetime';
case 'dateTime':
return 'datetime';
case 'uuid':
return 'select-dropdown-m2o';
case 'decimal':
case 'integer':
return 'input';
default:
return 'input';
}
}
async function ensureRelation(token, relation) {
const result = await fetchJson(`${baseUrl}/relations`, token, 'POST', {
collection: relation.collection,
field: relation.field,
related_collection: relation.related_collection,
schema: {
on_delete: 'CASCADE',
},
meta: {
many_collection: relation.collection,
many_field: relation.field,
one_collection: relation.one_collection,
one_field: relation.one_field,
one_deselect_action: 'nullify',
},
}, { allowAlreadyExists: true });
if (result?.alreadyExists) {
console.log(`Relation exists: ${relation.collection}.${relation.field}`);
return;
}
console.log(`Created relation: ${relation.collection}.${relation.field} -> ${relation.related_collection}`);
}
async function fetchJson(url, token, method, body, options = {}) {
const { allowNotFound = false, allowAlreadyExists = false } = options;
const response = await fetch(url, {
method,
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});
if (allowNotFound && response.status === 404) {
return null;
}
if (!response.ok) {
const text = await response.text();
if (allowAlreadyExists && isAlreadyExistsError(response.status, text)) {
return { alreadyExists: true };
}
if (response.status === 403) {
throw new Error(
`${method} ${url} failed (403): ${text}\n` +
'Bootstrap authentication is valid but lacks admin privileges. ' +
'If this database was initialized earlier, your current DIRECTUS_ADMIN_EMAIL/PASSWORD may no longer match the original admin user. ' +
'Use a stable DIRECTUS_ADMIN_TOKEN, or reset dev volumes with "docker compose down -v".'
);
}
throw new Error(`${method} ${url} failed (${response.status}): ${text}`);
}
if (response.status === 204) {
return null;
}
return response.json();
}
function isAlreadyExistsError(status, text) {
if (status !== 400 && status !== 409) {
return false;
}
const normalized = text.toLowerCase();
return (
normalized.includes('already exists') ||
normalized.includes('duplicate') ||
normalized.includes('not unique') ||
normalized.includes('record_not_unique')
);
}
main().catch((error) => {
console.error(error.message || error);
process.exit(1);
});