chore: convert submodules to standard directories for true monorepo structure
This commit is contained in:
73
vibn-frontend/scripts/activate-workspace-bots.ts
Normal file
73
vibn-frontend/scripts/activate-workspace-bots.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Backfill: activate any Gitea bot users that were created before the
|
||||
* provisioner started flipping `active: true` automatically.
|
||||
*
|
||||
* Safe to re-run — bots that are already active stay active.
|
||||
*
|
||||
* Usage:
|
||||
* npx dotenv-cli -e .env.local -- npx tsx scripts/activate-workspace-bots.ts
|
||||
*/
|
||||
|
||||
import { query } from '../lib/db-postgres';
|
||||
|
||||
const GITEA_API_URL = process.env.GITEA_API_URL ?? 'https://git.vibnai.com';
|
||||
const GITEA_API_TOKEN = process.env.GITEA_API_TOKEN ?? '';
|
||||
|
||||
if (!GITEA_API_TOKEN) {
|
||||
console.error('GITEA_API_TOKEN not set — cannot proceed');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function gitea(path: string, init?: RequestInit) {
|
||||
const res = await fetch(`${GITEA_API_URL}/api/v1${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `token ${GITEA_API_TOKEN}`,
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok && res.status !== 204) {
|
||||
throw new Error(`gitea ${path} → ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const rows = await query<{ slug: string; gitea_bot_username: string }>(
|
||||
`SELECT slug, gitea_bot_username
|
||||
FROM vibn_workspaces
|
||||
WHERE gitea_bot_username IS NOT NULL`
|
||||
);
|
||||
console.log(`Checking ${rows.length} workspace bot(s)…`);
|
||||
|
||||
let touched = 0;
|
||||
for (const { slug, gitea_bot_username: bot } of rows) {
|
||||
try {
|
||||
const u = (await gitea(`/users/${bot}`)) as { active?: boolean } | null;
|
||||
if (!u) {
|
||||
console.warn(` [${slug}] bot ${bot} not found in Gitea`);
|
||||
continue;
|
||||
}
|
||||
if (u.active) {
|
||||
console.log(` [${slug}] ${bot} already active`);
|
||||
continue;
|
||||
}
|
||||
await gitea(`/admin/users/${bot}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ source_id: 0, login_name: bot, active: true }),
|
||||
});
|
||||
console.log(` [${slug}] activated ${bot}`);
|
||||
touched++;
|
||||
} catch (err) {
|
||||
console.error(` [${slug}] error:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
console.log(`Done — activated ${touched} bot(s).`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('FAILED:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
76
vibn-frontend/scripts/add-phase-tracking.js
Normal file
76
vibn-frontend/scripts/add-phase-tracking.js
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Migration Script: Add Phase Tracking to Existing Projects
|
||||
*
|
||||
* Run with: node scripts/add-phase-tracking.js
|
||||
*/
|
||||
|
||||
const admin = require('firebase-admin');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Load environment variables
|
||||
require('dotenv').config({ path: '.env.local' });
|
||||
|
||||
// Initialize Firebase Admin
|
||||
const serviceAccount = {
|
||||
projectId: process.env.FIREBASE_PROJECT_ID,
|
||||
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
|
||||
privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n')
|
||||
};
|
||||
|
||||
if (!admin.apps.length) {
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert(serviceAccount)
|
||||
});
|
||||
}
|
||||
|
||||
const db = admin.firestore();
|
||||
|
||||
async function addPhaseTracking() {
|
||||
console.log('🔍 Finding projects without phase tracking...\n');
|
||||
|
||||
const projectsSnapshot = await db.collection('projects').get();
|
||||
|
||||
let updatedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const doc of projectsSnapshot.docs) {
|
||||
const data = doc.data();
|
||||
|
||||
// Skip if already has phase tracking
|
||||
if (data.currentPhase) {
|
||||
console.log(`⏭️ Skipping ${data.name} (${doc.id}) - already has phase tracking`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add phase tracking
|
||||
await doc.ref.update({
|
||||
currentPhase: 'gathering',
|
||||
phaseStatus: 'not_started',
|
||||
phaseData: {},
|
||||
phaseHistory: [],
|
||||
updatedAt: admin.firestore.FieldValue.serverTimestamp()
|
||||
});
|
||||
|
||||
console.log(`✅ Updated ${data.name} (${doc.id}) - initialized with gathering phase`);
|
||||
updatedCount++;
|
||||
}
|
||||
|
||||
console.log(`\n📊 Migration Complete:`);
|
||||
console.log(` Updated: ${updatedCount} projects`);
|
||||
console.log(` Skipped: ${skippedCount} projects`);
|
||||
console.log(` Total: ${projectsSnapshot.size} projects\n`);
|
||||
}
|
||||
|
||||
// Run migration
|
||||
addPhaseTracking()
|
||||
.then(() => {
|
||||
console.log('✅ Migration successful!');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('❌ Migration failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
58
vibn-frontend/scripts/add-phase-tracking.ts
Normal file
58
vibn-frontend/scripts/add-phase-tracking.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Migration Script: Add Phase Tracking to Existing Projects
|
||||
*
|
||||
* Run with: npx tsx scripts/add-phase-tracking.ts
|
||||
*/
|
||||
|
||||
import { getAdminDb } from '../lib/firebase/admin';
|
||||
|
||||
async function addPhaseTracking() {
|
||||
const adminDb = getAdminDb();
|
||||
|
||||
console.log('🔍 Finding projects without phase tracking...\n');
|
||||
|
||||
const projectsSnapshot = await adminDb.collection('projects').get();
|
||||
|
||||
let updatedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const doc of projectsSnapshot.docs) {
|
||||
const data = doc.data();
|
||||
|
||||
// Skip if already has phase tracking
|
||||
if (data.currentPhase) {
|
||||
console.log(`⏭️ Skipping ${data.name} (${doc.id}) - already has phase tracking`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add phase tracking
|
||||
await doc.ref.update({
|
||||
currentPhase: 'gathering',
|
||||
phaseStatus: 'not_started',
|
||||
phaseData: {},
|
||||
phaseHistory: [],
|
||||
updatedAt: new Date()
|
||||
});
|
||||
|
||||
console.log(`✅ Updated ${data.name} (${doc.id}) - initialized with gathering phase`);
|
||||
updatedCount++;
|
||||
}
|
||||
|
||||
console.log(`\n📊 Migration Complete:`);
|
||||
console.log(` Updated: ${updatedCount} projects`);
|
||||
console.log(` Skipped: ${skippedCount} projects`);
|
||||
console.log(` Total: ${projectsSnapshot.size} projects\n`);
|
||||
}
|
||||
|
||||
// Run migration
|
||||
addPhaseTracking()
|
||||
.then(() => {
|
||||
console.log('✅ Migration successful!');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('❌ Migration failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
123
vibn-frontend/scripts/alloydb-setup-commands.txt
Normal file
123
vibn-frontend/scripts/alloydb-setup-commands.txt
Normal file
@@ -0,0 +1,123 @@
|
||||
# ============================================
|
||||
# AlloyDB Setup Commands for Cloud Shell
|
||||
# ============================================
|
||||
# Copy-paste these one at a time into Cloud Shell
|
||||
|
||||
# -------------------------------------------
|
||||
# STEP 1: Get AlloyDB Private IP
|
||||
# -------------------------------------------
|
||||
ALLOYDB_IP=$(gcloud alloydb instances describe vibn-primary \
|
||||
--cluster=vibn \
|
||||
--region=northamerica-northeast1 \
|
||||
--format="value(ipAddress)")
|
||||
|
||||
echo "AlloyDB IP: $ALLOYDB_IP"
|
||||
|
||||
# -------------------------------------------
|
||||
# STEP 2: Test Connection
|
||||
# -------------------------------------------
|
||||
psql "host=$ALLOYDB_IP port=5432 user=mark@getacquired.com dbname=postgres sslmode=disable" -c "\l"
|
||||
|
||||
# -------------------------------------------
|
||||
# STEP 3: Create vibn Database
|
||||
# -------------------------------------------
|
||||
psql "host=$ALLOYDB_IP port=5432 user=mark@getacquired.com dbname=postgres sslmode=disable" -c "CREATE DATABASE vibn;"
|
||||
|
||||
# -------------------------------------------
|
||||
# STEP 4: Enable Extensions
|
||||
# -------------------------------------------
|
||||
psql "host=$ALLOYDB_IP port=5432 user=mark@getacquired.com dbname=vibn sslmode=disable" <<EOF
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
SELECT extname, extversion FROM pg_extension WHERE extname IN ('vector', 'uuid-ossp');
|
||||
EOF
|
||||
|
||||
# -------------------------------------------
|
||||
# STEP 5: Create a temporary SQL file for the schema
|
||||
# -------------------------------------------
|
||||
cat > /tmp/knowledge-chunks-schema.sql << 'EOFSCHEMA'
|
||||
-- Enable required extensions (already done above, but safe to repeat)
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Create the knowledge_chunks table
|
||||
CREATE TABLE IF NOT EXISTS knowledge_chunks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id TEXT NOT NULL,
|
||||
knowledge_item_id TEXT NOT NULL,
|
||||
chunk_index INT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
embedding VECTOR(768) NOT NULL,
|
||||
source_type TEXT,
|
||||
importance TEXT CHECK (importance IN ('primary', 'supporting', 'irrelevant') OR importance IS NULL),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Standard indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_project_id
|
||||
ON knowledge_chunks (project_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_knowledge_item_id
|
||||
ON knowledge_chunks (knowledge_item_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_project_knowledge
|
||||
ON knowledge_chunks (project_id, knowledge_item_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_item_index
|
||||
ON knowledge_chunks (knowledge_item_id, chunk_index);
|
||||
|
||||
-- Vector similarity index using IVFFlat
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_embedding
|
||||
ON knowledge_chunks
|
||||
USING ivfflat (embedding vector_cosine_ops)
|
||||
WITH (lists = 100);
|
||||
|
||||
-- Auto-update trigger
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER update_knowledge_chunks_updated_at
|
||||
BEFORE UPDATE ON knowledge_chunks
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
EOFSCHEMA
|
||||
|
||||
# -------------------------------------------
|
||||
# STEP 6: Run the Schema File
|
||||
# -------------------------------------------
|
||||
psql "host=$ALLOYDB_IP port=5432 user=mark@getacquired.com dbname=vibn sslmode=disable" -f /tmp/knowledge-chunks-schema.sql
|
||||
|
||||
# -------------------------------------------
|
||||
# STEP 7: Verify Everything
|
||||
# -------------------------------------------
|
||||
psql "host=$ALLOYDB_IP port=5432 user=mark@getacquired.com dbname=vibn sslmode=disable" <<EOF
|
||||
-- Check table exists
|
||||
\dt knowledge_chunks
|
||||
|
||||
-- Check indexes
|
||||
\di
|
||||
|
||||
-- Count rows (should be 0)
|
||||
SELECT COUNT(*) FROM knowledge_chunks;
|
||||
|
||||
-- Test vector operations
|
||||
SELECT 1 as test;
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
echo "✅ AlloyDB setup complete!"
|
||||
echo ""
|
||||
echo "Connection string for your .env.local:"
|
||||
echo "ALLOYDB_HOST=$ALLOYDB_IP"
|
||||
echo "ALLOYDB_PORT=5432"
|
||||
echo "ALLOYDB_USER=mark@getacquired.com"
|
||||
echo "ALLOYDB_PASSWORD="
|
||||
echo "ALLOYDB_DATABASE=vibn"
|
||||
echo "ALLOYDB_SSL=false"
|
||||
|
||||
86
vibn-frontend/scripts/check-session-links.ts
Normal file
86
vibn-frontend/scripts/check-session-links.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Quick script to verify session associations in Firestore
|
||||
*/
|
||||
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
// Load environment variables from .env.local
|
||||
config({ path: resolve(__dirname, '../.env.local') });
|
||||
|
||||
import { getAdminDb } from '../lib/firebase/admin';
|
||||
|
||||
async function checkSessionLinks() {
|
||||
const db = getAdminDb();
|
||||
|
||||
console.log('🔍 Checking session associations...\n');
|
||||
|
||||
// Get all sessions
|
||||
const sessionsSnapshot = await db.collection('sessions').get();
|
||||
|
||||
console.log(`📊 Total sessions: ${sessionsSnapshot.size}\n`);
|
||||
|
||||
// Group by status
|
||||
const linked: any[] = [];
|
||||
const unlinked: any[] = [];
|
||||
|
||||
sessionsSnapshot.docs.forEach(doc => {
|
||||
const data = doc.data();
|
||||
const sessionInfo = {
|
||||
id: doc.id,
|
||||
workspaceName: data.workspaceName || 'Unknown',
|
||||
workspacePath: data.workspacePath,
|
||||
projectId: data.projectId,
|
||||
needsProjectAssociation: data.needsProjectAssociation,
|
||||
createdAt: data.createdAt?.toDate?.() || data.createdAt,
|
||||
};
|
||||
|
||||
if (data.projectId) {
|
||||
linked.push(sessionInfo);
|
||||
} else {
|
||||
unlinked.push(sessionInfo);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`✅ Linked to projects: ${linked.length}`);
|
||||
console.log(`⚠️ Not linked: ${unlinked.length}\n`);
|
||||
|
||||
if (linked.length > 0) {
|
||||
console.log('📌 Linked Sessions:');
|
||||
linked.forEach(s => {
|
||||
console.log(` - ${s.workspaceName} → Project: ${s.projectId?.substring(0, 8)}...`);
|
||||
console.log(` needsProjectAssociation: ${s.needsProjectAssociation}`);
|
||||
});
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (unlinked.length > 0) {
|
||||
console.log('⚠️ Unlinked Sessions:');
|
||||
unlinked.forEach(s => {
|
||||
console.log(` - ${s.workspaceName}`);
|
||||
console.log(` Path: ${s.workspacePath}`);
|
||||
console.log(` needsProjectAssociation: ${s.needsProjectAssociation}`);
|
||||
});
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Check projects
|
||||
const projectsSnapshot = await db.collection('projects').get();
|
||||
console.log(`📁 Total projects: ${projectsSnapshot.size}\n`);
|
||||
|
||||
projectsSnapshot.docs.forEach(doc => {
|
||||
const data = doc.data();
|
||||
console.log(` - ${data.productName || data.name || 'Unnamed'}`);
|
||||
console.log(` ID: ${doc.id}`);
|
||||
console.log(` Workspace: ${data.workspacePath || 'Not set'}`);
|
||||
console.log('');
|
||||
});
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
checkSessionLinks().catch(error => {
|
||||
console.error('Error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
7
vibn-frontend/scripts/db-setup.sh
Executable file
7
vibn-frontend/scripts/db-setup.sh
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
# Run database migrations at container startup
|
||||
# This ensures DATABASE_URL is available from Coolify env vars
|
||||
|
||||
echo "Running Prisma DB setup..."
|
||||
npx prisma db push --accept-data-loss --skip-generate
|
||||
echo "Database setup complete!"
|
||||
25
vibn-frontend/scripts/migrate-api-keys-revealable.sql
Normal file
25
vibn-frontend/scripts/migrate-api-keys-revealable.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- =============================================================================
|
||||
-- Make workspace API keys revealable.
|
||||
--
|
||||
-- Adds `key_encrypted` — base64 of secret-box(VIBN_SECRETS_KEY, plaintext token).
|
||||
-- Existing rows keep `key_encrypted = NULL` and are therefore NOT revealable;
|
||||
-- only the hash was stored at mint time and the plaintext is unrecoverable by
|
||||
-- design. Those keys still work for auth (hash lookup is unchanged); they just
|
||||
-- can't surface the plaintext again — the UI will flag them as legacy.
|
||||
--
|
||||
-- New keys minted after this migration will populate `key_encrypted` and can
|
||||
-- be revealed on demand by session-authenticated users (never by API-key
|
||||
-- principals — prevents lateral movement).
|
||||
--
|
||||
-- Safe to re-run.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE vibn_workspace_api_keys
|
||||
ADD COLUMN IF NOT EXISTS key_encrypted TEXT;
|
||||
|
||||
COMMENT ON COLUMN vibn_workspace_api_keys.key_encrypted IS
|
||||
'base64( AES-256-GCM encrypt(VIBN_SECRETS_KEY, plaintext vibn_sk_...) ). '
|
||||
'NULL for legacy rows minted before this column existed — those keys '
|
||||
'remain valid for auth but cannot be revealed.';
|
||||
|
||||
SELECT 'API-key revealability migration complete' AS status;
|
||||
161
vibn-frontend/scripts/migrate-existing-knowledge-to-alloydb.ts
Executable file
161
vibn-frontend/scripts/migrate-existing-knowledge-to-alloydb.ts
Executable file
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* One-time migration script to process existing knowledge_items into AlloyDB
|
||||
*
|
||||
* This script:
|
||||
* 1. Fetches all knowledge_items from Firestore
|
||||
* 2. For each item, chunks and embeds it into AlloyDB
|
||||
* 3. Shows progress and handles errors gracefully
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/migrate-existing-knowledge-to-alloydb.ts [projectId]
|
||||
*
|
||||
* - If projectId is provided, processes only that project
|
||||
* - If omitted, processes ALL projects
|
||||
*/
|
||||
|
||||
import { getAdminDb } from '../lib/firebase/admin';
|
||||
import { writeKnowledgeChunksForItem, getChunkCountForKnowledgeItem } from '../lib/server/vector-memory';
|
||||
|
||||
interface KnowledgeItem {
|
||||
id: string;
|
||||
projectId: string;
|
||||
content: string;
|
||||
sourceMeta?: {
|
||||
sourceType?: string;
|
||||
importance?: 'primary' | 'supporting' | 'irrelevant';
|
||||
};
|
||||
}
|
||||
|
||||
async function getAllKnowledgeItems(projectId?: string): Promise<KnowledgeItem[]> {
|
||||
const adminDb = getAdminDb();
|
||||
const items: KnowledgeItem[] = [];
|
||||
|
||||
if (projectId) {
|
||||
// Single project
|
||||
console.log(`[Migration] Fetching knowledge items for project ${projectId}...`);
|
||||
const snapshot = await adminDb
|
||||
.collection('knowledge_items')
|
||||
.where('projectId', '==', projectId)
|
||||
.get();
|
||||
|
||||
snapshot.forEach((doc) => {
|
||||
const data = doc.data();
|
||||
items.push({
|
||||
id: doc.id,
|
||||
projectId: data.projectId,
|
||||
content: data.content || '',
|
||||
sourceMeta: data.sourceMeta,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// All projects
|
||||
console.log(`[Migration] Fetching ALL knowledge items...`);
|
||||
const snapshot = await adminDb.collection('knowledge_items').get();
|
||||
|
||||
snapshot.forEach((doc) => {
|
||||
const data = doc.data();
|
||||
items.push({
|
||||
id: doc.id,
|
||||
projectId: data.projectId,
|
||||
content: data.content || '',
|
||||
sourceMeta: data.sourceMeta,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async function migrateKnowledgeItems(projectId?: string) {
|
||||
console.log('');
|
||||
console.log('='.repeat(60));
|
||||
console.log('🚀 AlloyDB Knowledge Migration');
|
||||
console.log('='.repeat(60));
|
||||
console.log('');
|
||||
|
||||
try {
|
||||
// Fetch all items
|
||||
const items = await getAllKnowledgeItems(projectId);
|
||||
console.log(`✅ Found ${items.length} knowledge items to process`);
|
||||
console.log('');
|
||||
|
||||
if (items.length === 0) {
|
||||
console.log('ℹ️ No knowledge items found. Nothing to migrate.');
|
||||
return;
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
let skipCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
// Process each item
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
const progress = `[${i + 1}/${items.length}]`;
|
||||
|
||||
try {
|
||||
// Check if already processed (skip if chunks exist)
|
||||
const existingChunks = await getChunkCountForKnowledgeItem(item.id);
|
||||
if (existingChunks > 0) {
|
||||
console.log(`${progress} ⏭️ Skipping ${item.id} (already has ${existingChunks} chunks)`);
|
||||
skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`${progress} 🔄 Processing ${item.id}...`);
|
||||
|
||||
// Chunk and embed
|
||||
await writeKnowledgeChunksForItem(item);
|
||||
|
||||
const newChunks = await getChunkCountForKnowledgeItem(item.id);
|
||||
console.log(`${progress} ✅ Success! Created ${newChunks} chunks`);
|
||||
successCount++;
|
||||
|
||||
// Small delay to avoid rate limiting
|
||||
if (i < items.length - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`${progress} ❌ Failed to process ${item.id}:`, error);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log('');
|
||||
console.log('='.repeat(60));
|
||||
console.log('📊 Migration Complete');
|
||||
console.log('='.repeat(60));
|
||||
console.log(`✅ Processed: ${successCount}`);
|
||||
console.log(`⏭️ Skipped (already exists): ${skipCount}`);
|
||||
console.log(`❌ Errors: ${errorCount}`);
|
||||
console.log(`📦 Total: ${items.length}`);
|
||||
console.log('');
|
||||
|
||||
if (errorCount > 0) {
|
||||
console.log('⚠️ Some items failed. Check logs above for details.');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('🎉 All knowledge items successfully migrated to AlloyDB!');
|
||||
process.exit(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('');
|
||||
console.error('❌ Migration failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
const projectId = process.argv[2];
|
||||
|
||||
if (projectId) {
|
||||
console.log(`ℹ️ Processing single project: ${projectId}`);
|
||||
} else {
|
||||
console.log('ℹ️ No projectId provided - processing ALL projects');
|
||||
}
|
||||
|
||||
// Run migration
|
||||
migrateKnowledgeItems(projectId);
|
||||
|
||||
373
vibn-frontend/scripts/migrate-from-postgres.ts
Normal file
373
vibn-frontend/scripts/migrate-from-postgres.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
// MUST load environment variables BEFORE any other imports
|
||||
require('dotenv').config({ path: require('path').resolve(__dirname, '../.env.local') });
|
||||
|
||||
import { Client } from 'pg';
|
||||
import admin from 'firebase-admin';
|
||||
import { FieldValue } from 'firebase-admin/firestore';
|
||||
|
||||
const PG_CONNECTION_STRING = 'postgresql://postgres:jhsRNOIyjjVfrdvDXnUVcXXXsuzjvcFc@metro.proxy.rlwy.net:30866/railway';
|
||||
|
||||
// Initialize Firebase Admin directly
|
||||
if (!admin.apps.length) {
|
||||
const privateKey = process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n');
|
||||
|
||||
if (!process.env.FIREBASE_PROJECT_ID || !process.env.FIREBASE_CLIENT_EMAIL || !privateKey) {
|
||||
throw new Error('Missing Firebase Admin credentials. Check your .env.local file.');
|
||||
}
|
||||
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert({
|
||||
projectId: process.env.FIREBASE_PROJECT_ID,
|
||||
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
|
||||
privateKey: privateKey,
|
||||
}),
|
||||
});
|
||||
|
||||
console.log('✅ Firebase Admin initialized successfully');
|
||||
}
|
||||
|
||||
const adminDb = admin.firestore();
|
||||
const adminAuth = admin.auth();
|
||||
|
||||
interface PgUser {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string;
|
||||
created_at: Date;
|
||||
settings: any;
|
||||
}
|
||||
|
||||
interface PgProject {
|
||||
id: number;
|
||||
client_id: number;
|
||||
name: string;
|
||||
workspace_path: string;
|
||||
status: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
metadata: any;
|
||||
}
|
||||
|
||||
interface PgSession {
|
||||
id: number;
|
||||
session_id: string;
|
||||
project_id: number;
|
||||
user_id: number;
|
||||
started_at: Date;
|
||||
last_updated: Date;
|
||||
ended_at: Date | null;
|
||||
status: string;
|
||||
conversation: any[];
|
||||
file_changes: any[];
|
||||
message_count: number;
|
||||
user_message_count: number;
|
||||
assistant_message_count: number;
|
||||
file_change_count: number;
|
||||
duration_minutes: number;
|
||||
summary: string | null;
|
||||
tasks_identified: any[];
|
||||
decisions_made: any[];
|
||||
technologies_used: any[];
|
||||
metadata: any;
|
||||
total_tokens: number;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
estimated_cost_usd: number;
|
||||
model: string;
|
||||
}
|
||||
|
||||
interface PgWorkCompleted {
|
||||
id: number;
|
||||
project_id: number;
|
||||
session_id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
completed_at: Date;
|
||||
files_modified: any[];
|
||||
lines_added: number;
|
||||
lines_removed: number;
|
||||
metadata: any;
|
||||
}
|
||||
|
||||
interface PgClient {
|
||||
id: number;
|
||||
owner_user_id: number;
|
||||
name: string;
|
||||
email: string | null;
|
||||
created_at: Date;
|
||||
metadata: any;
|
||||
}
|
||||
|
||||
async function migrateUsers(pgClient: Client, userMapping: Map<number, string>) {
|
||||
console.log('\n📋 Migrating Users...');
|
||||
const result = await pgClient.query<PgUser>('SELECT * FROM users');
|
||||
|
||||
for (const pgUser of result.rows) {
|
||||
try {
|
||||
// Create Firebase Auth user
|
||||
let firebaseUser;
|
||||
try {
|
||||
firebaseUser = await adminAuth.getUserByEmail(pgUser.email);
|
||||
console.log(` ✅ User already exists: ${pgUser.email}`);
|
||||
} catch {
|
||||
// User doesn't exist, create them
|
||||
firebaseUser = await adminAuth.createUser({
|
||||
email: pgUser.email,
|
||||
displayName: pgUser.name,
|
||||
emailVerified: true,
|
||||
});
|
||||
console.log(` ✨ Created Firebase Auth user: ${pgUser.email}`);
|
||||
}
|
||||
|
||||
// Store mapping
|
||||
userMapping.set(pgUser.id, firebaseUser.uid);
|
||||
|
||||
// Create user document in Firestore
|
||||
const workspace = pgUser.email.split('@')[0].replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
||||
await adminDb.collection('users').doc(firebaseUser.uid).set({
|
||||
uid: firebaseUser.uid,
|
||||
email: pgUser.email,
|
||||
displayName: pgUser.name,
|
||||
workspace: workspace,
|
||||
settings: pgUser.settings || {},
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
migratedFrom: 'postgresql',
|
||||
originalPgId: pgUser.id,
|
||||
});
|
||||
|
||||
console.log(` ✅ Migrated user: ${pgUser.email} → ${firebaseUser.uid}`);
|
||||
} catch (error) {
|
||||
console.error(` ❌ Error migrating user ${pgUser.email}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateClients(pgClient: Client, userMapping: Map<number, string>) {
|
||||
console.log('\n📋 Migrating Clients...');
|
||||
const result = await pgClient.query<PgClient>('SELECT * FROM clients');
|
||||
|
||||
for (const pgClient of result.rows) {
|
||||
const firebaseUserId = userMapping.get(pgClient.owner_user_id);
|
||||
if (!firebaseUserId) {
|
||||
console.log(` ⚠️ Skipping client ${pgClient.name} - user not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const clientRef = adminDb.collection('clients').doc();
|
||||
await clientRef.set({
|
||||
id: clientRef.id,
|
||||
ownerId: firebaseUserId,
|
||||
name: pgClient.name,
|
||||
email: pgClient.email || null,
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
metadata: pgClient.metadata || {},
|
||||
migratedFrom: 'postgresql',
|
||||
originalPgId: pgClient.id,
|
||||
});
|
||||
|
||||
console.log(` ✅ Migrated client: ${pgClient.name}`);
|
||||
} catch (error) {
|
||||
console.error(` ❌ Error migrating client ${pgClient.name}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateProjects(pgClient: Client, userMapping: Map<number, string>, projectMapping: Map<number, string>) {
|
||||
console.log('\n📋 Migrating Projects...');
|
||||
const result = await pgClient.query<PgProject>('SELECT * FROM projects');
|
||||
|
||||
for (const pgProject of result.rows) {
|
||||
try {
|
||||
// Get the client to find the owner
|
||||
const clientResult = await pgClient.query('SELECT owner_user_id FROM clients WHERE id = $1', [pgProject.client_id]);
|
||||
const firebaseUserId = userMapping.get(clientResult.rows[0]?.owner_user_id);
|
||||
|
||||
if (!firebaseUserId) {
|
||||
console.log(` ⚠️ Skipping project ${pgProject.name} - user not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get user's workspace
|
||||
const userDoc = await adminDb.collection('users').doc(firebaseUserId).get();
|
||||
const workspace = userDoc.data()?.workspace || 'default-workspace';
|
||||
|
||||
const projectRef = adminDb.collection('projects').doc();
|
||||
await projectRef.set({
|
||||
id: projectRef.id,
|
||||
name: pgProject.name,
|
||||
slug: pgProject.name.toLowerCase().replace(/[^a-z0-9]/g, '-'),
|
||||
userId: firebaseUserId,
|
||||
workspace: workspace,
|
||||
productName: pgProject.name,
|
||||
productVision: pgProject.metadata?.vision || null,
|
||||
workspacePath: pgProject.workspace_path,
|
||||
status: pgProject.status,
|
||||
isForClient: true,
|
||||
hasLogo: false,
|
||||
hasDomain: false,
|
||||
hasWebsite: false,
|
||||
hasGithub: false,
|
||||
hasChatGPT: false,
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
metadata: pgProject.metadata || {},
|
||||
migratedFrom: 'postgresql',
|
||||
originalPgId: pgProject.id,
|
||||
});
|
||||
|
||||
projectMapping.set(pgProject.id, projectRef.id);
|
||||
console.log(` ✅ Migrated project: ${pgProject.name} → ${projectRef.id}`);
|
||||
} catch (error) {
|
||||
console.error(` ❌ Error migrating project ${pgProject.name}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateSessions(pgClient: Client, userMapping: Map<number, string>, projectMapping: Map<number, string>) {
|
||||
console.log('\n📋 Migrating Sessions...');
|
||||
const result = await pgClient.query<PgSession>('SELECT * FROM sessions ORDER BY started_at');
|
||||
|
||||
for (const pgSession of result.rows) {
|
||||
try {
|
||||
const firebaseUserId = userMapping.get(pgSession.user_id);
|
||||
const firebaseProjectId = projectMapping.get(pgSession.project_id);
|
||||
|
||||
if (!firebaseUserId) {
|
||||
console.log(` ⚠️ Skipping session ${pgSession.session_id} - user not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const sessionRef = adminDb.collection('sessions').doc();
|
||||
await sessionRef.set({
|
||||
id: sessionRef.id,
|
||||
userId: firebaseUserId,
|
||||
projectId: firebaseProjectId || null,
|
||||
|
||||
// Session data
|
||||
startTime: pgSession.started_at,
|
||||
endTime: pgSession.ended_at || null,
|
||||
duration: pgSession.duration_minutes * 60, // Convert to seconds
|
||||
|
||||
// Project context
|
||||
workspacePath: null, // Not in old schema
|
||||
workspaceName: null,
|
||||
|
||||
// AI usage
|
||||
model: pgSession.model,
|
||||
tokensUsed: pgSession.total_tokens,
|
||||
promptTokens: pgSession.prompt_tokens,
|
||||
completionTokens: pgSession.completion_tokens,
|
||||
cost: parseFloat(String(pgSession.estimated_cost_usd)),
|
||||
|
||||
// Context
|
||||
filesModified: pgSession.file_changes.map((fc: any) => fc.path || fc.file),
|
||||
conversationSummary: pgSession.summary || null,
|
||||
conversation: pgSession.conversation || [],
|
||||
|
||||
// Additional data from old schema
|
||||
messageCount: pgSession.message_count,
|
||||
userMessageCount: pgSession.user_message_count,
|
||||
assistantMessageCount: pgSession.assistant_message_count,
|
||||
fileChangeCount: pgSession.file_change_count,
|
||||
tasksIdentified: pgSession.tasks_identified || [],
|
||||
decisionsMade: pgSession.decisions_made || [],
|
||||
technologiesUsed: pgSession.technologies_used || [],
|
||||
|
||||
status: pgSession.status,
|
||||
metadata: pgSession.metadata || {},
|
||||
|
||||
createdAt: pgSession.started_at,
|
||||
updatedAt: pgSession.last_updated,
|
||||
|
||||
migratedFrom: 'postgresql',
|
||||
originalPgId: pgSession.id,
|
||||
originalSessionId: pgSession.session_id,
|
||||
});
|
||||
|
||||
console.log(` ✅ Migrated session: ${pgSession.session_id}`);
|
||||
} catch (error) {
|
||||
console.error(` ❌ Error migrating session ${pgSession.session_id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateWorkCompleted(pgClient: Client, projectMapping: Map<number, string>) {
|
||||
console.log('\n📋 Migrating Work Completed...');
|
||||
const result = await pgClient.query<PgWorkCompleted>('SELECT * FROM work_completed ORDER BY completed_at');
|
||||
|
||||
for (const work of result.rows) {
|
||||
try {
|
||||
const firebaseProjectId = projectMapping.get(work.project_id);
|
||||
|
||||
if (!firebaseProjectId) {
|
||||
console.log(` ⚠️ Skipping work ${work.title} - project not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const workRef = adminDb.collection('workCompleted').doc();
|
||||
await workRef.set({
|
||||
id: workRef.id,
|
||||
projectId: firebaseProjectId,
|
||||
sessionId: work.session_id ? `pg-session-${work.session_id}` : null,
|
||||
title: work.title,
|
||||
description: work.description,
|
||||
completedAt: work.completed_at,
|
||||
filesModified: work.files_modified || [],
|
||||
linesAdded: work.lines_added || 0,
|
||||
linesRemoved: work.lines_removed || 0,
|
||||
metadata: work.metadata || {},
|
||||
createdAt: work.completed_at,
|
||||
migratedFrom: 'postgresql',
|
||||
originalPgId: work.id,
|
||||
});
|
||||
|
||||
console.log(` ✅ Migrated work: ${work.title}`);
|
||||
} catch (error) {
|
||||
console.error(` ❌ Error migrating work ${work.title}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🚀 Starting PostgreSQL to Firebase migration...\n');
|
||||
|
||||
const pgClient = new Client({
|
||||
connectionString: PG_CONNECTION_STRING,
|
||||
});
|
||||
|
||||
try {
|
||||
// Connect to PostgreSQL
|
||||
console.log('📡 Connecting to PostgreSQL...');
|
||||
await pgClient.connect();
|
||||
console.log('✅ Connected to PostgreSQL\n');
|
||||
|
||||
// Mappings to track old ID -> new ID
|
||||
const userMapping = new Map<number, string>();
|
||||
const projectMapping = new Map<number, string>();
|
||||
|
||||
// Migrate in order (respecting foreign keys)
|
||||
await migrateUsers(pgClient, userMapping);
|
||||
await migrateClients(pgClient, userMapping);
|
||||
await migrateProjects(pgClient, userMapping, projectMapping);
|
||||
await migrateSessions(pgClient, userMapping, projectMapping);
|
||||
await migrateWorkCompleted(pgClient, projectMapping);
|
||||
|
||||
console.log('\n✅ Migration completed successfully!');
|
||||
console.log('\n📊 Summary:');
|
||||
console.log(` - Users migrated: ${userMapping.size}`);
|
||||
console.log(` - Projects migrated: ${projectMapping.size}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Migration failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
await pgClient.end();
|
||||
console.log('\n📡 Disconnected from PostgreSQL');
|
||||
}
|
||||
}
|
||||
|
||||
// Run migration
|
||||
main().catch(console.error);
|
||||
|
||||
188
vibn-frontend/scripts/migrate-fs-tables.sql
Normal file
188
vibn-frontend/scripts/migrate-fs-tables.sql
Normal file
@@ -0,0 +1,188 @@
|
||||
-- =============================================================================
|
||||
-- VIBN fs_* tables + agent_sessions migration
|
||||
-- Run once against the production Coolify Postgres database.
|
||||
--
|
||||
-- These tables back the live app (fs_ prefix = "Firestore-shaped" flexible
|
||||
-- JSONB rows that replaced the original Firebase collections).
|
||||
--
|
||||
-- Safe to re-run — all statements use IF NOT EXISTS / ON CONFLICT.
|
||||
-- =============================================================================
|
||||
|
||||
-- Enable uuid support (safe no-op if already enabled)
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- fs_users (mirrors Firebase Auth + Firestore user docs)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS fs_users (
|
||||
id TEXT PRIMARY KEY, -- gen_random_uuid()::text at insert time
|
||||
user_id TEXT, -- NextAuth User.id (cuid)
|
||||
data JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS fs_users_email_idx
|
||||
ON fs_users ((data->>'email'));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS fs_users_user_id_idx
|
||||
ON fs_users (user_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- fs_projects (Firestore projects collection)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS fs_projects (
|
||||
id TEXT PRIMARY KEY, -- randomUUID() at insert time
|
||||
user_id TEXT NOT NULL, -- FK → fs_users.id
|
||||
workspace TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
data JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS fs_projects_user_idx
|
||||
ON fs_projects (user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS fs_projects_workspace_idx
|
||||
ON fs_projects (workspace);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS fs_projects_slug_idx
|
||||
ON fs_projects (slug);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- fs_sessions (AI coding session logs)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS fs_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
data JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS fs_sessions_user_idx
|
||||
ON fs_sessions (user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS fs_sessions_project_idx
|
||||
ON fs_sessions ((data->>'projectId'));
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- agent_sessions (vibn-agent-runner execution records)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id TEXT NOT NULL, -- fs_projects.id (TEXT)
|
||||
app_name TEXT NOT NULL,
|
||||
app_path TEXT NOT NULL,
|
||||
task TEXT NOT NULL,
|
||||
plan JSONB,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
output JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
changed_files JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
error TEXT,
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS agent_sessions_project_idx
|
||||
ON agent_sessions (project_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS agent_sessions_status_idx
|
||||
ON agent_sessions (status);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- agent_session_events (append-only timeline for SSE + replay)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS agent_session_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
||||
project_id TEXT NOT NULL,
|
||||
seq INT NOT NULL,
|
||||
ts TIMESTAMPTZ NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
client_event_id UUID UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(session_id, seq)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS agent_session_events_session_seq_idx
|
||||
ON agent_session_events (session_id, seq);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- NextAuth / Prisma tables (required by PrismaAdapter + strategy:"database")
|
||||
-- Only created if not already present from a prisma migrate run.
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
email TEXT UNIQUE,
|
||||
email_verified TIMESTAMPTZ,
|
||||
image TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
provider_account_id TEXT NOT NULL,
|
||||
refresh_token TEXT,
|
||||
access_token TEXT,
|
||||
expires_at INTEGER,
|
||||
token_type TEXT,
|
||||
scope TEXT,
|
||||
id_token TEXT,
|
||||
session_state TEXT,
|
||||
UNIQUE (provider, provider_account_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_token TEXT UNIQUE NOT NULL,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS verification_tokens (
|
||||
identifier TEXT NOT NULL,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
expires TIMESTAMPTZ NOT NULL,
|
||||
UNIQUE (identifier, token)
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- fs_chat_threads (Vibn AI chat conversation threads)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS fs_chat_threads (
|
||||
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
user_id TEXT NOT NULL,
|
||||
workspace TEXT NOT NULL DEFAULT '',
|
||||
data JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS fs_chat_threads_user_ws_idx
|
||||
ON fs_chat_threads (user_id, workspace, updated_at DESC);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- fs_chat_messages (individual messages within a thread)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS fs_chat_messages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
thread_id TEXT NOT NULL REFERENCES fs_chat_threads(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL,
|
||||
data JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS fs_chat_messages_thread_idx
|
||||
ON fs_chat_messages (thread_id, created_at ASC);
|
||||
|
||||
-- Done
|
||||
SELECT 'Migration complete' AS status;
|
||||
39
vibn-frontend/scripts/migrate-workspace-gcs.sql
Normal file
39
vibn-frontend/scripts/migrate-workspace-gcs.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
-- =============================================================================
|
||||
-- VIBN P5.3 — per-workspace GCS storage columns on vibn_workspaces
|
||||
--
|
||||
-- Adds the columns that ensureWorkspaceGcsProvisioned() persists into:
|
||||
--
|
||||
-- gcp_service_account_email — workspace's dedicated GCP SA, e.g.
|
||||
-- vibn-ws-mark@master-ai-484822.iam.gserviceaccount.com
|
||||
-- gcp_service_account_key_enc — base64( secret-box(SA JSON keyfile) ).
|
||||
-- Currently only used for runtime auth from app
|
||||
-- code (env injection); control-plane auth still
|
||||
-- uses GOOGLE_SERVICE_ACCOUNT_KEY_B64.
|
||||
-- gcs_default_bucket_name — globally-unique GCS bucket created on first
|
||||
-- provision, e.g. vibn-ws-mark-a3f9c1.
|
||||
-- gcs_hmac_access_id — S3-compatible HMAC access key id (plain text;
|
||||
-- not a secret on its own).
|
||||
-- gcs_hmac_secret_enc — base64( secret-box(HMAC secret) ). Decrypted
|
||||
-- only when STORAGE_SECRET_ACCESS_KEY needs to be
|
||||
-- injected into a Coolify app.
|
||||
-- gcp_provision_status — independent of provision_status so a partial
|
||||
-- GCP failure does not flip the whole workspace.
|
||||
-- Values: 'pending' | 'partial' | 'ready' | 'error'.
|
||||
-- gcp_provision_error — last error message from the GCP provisioner.
|
||||
--
|
||||
-- Safe to re-run.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE vibn_workspaces
|
||||
ADD COLUMN IF NOT EXISTS gcp_service_account_email TEXT,
|
||||
ADD COLUMN IF NOT EXISTS gcp_service_account_key_enc TEXT,
|
||||
ADD COLUMN IF NOT EXISTS gcs_default_bucket_name TEXT,
|
||||
ADD COLUMN IF NOT EXISTS gcs_hmac_access_id TEXT,
|
||||
ADD COLUMN IF NOT EXISTS gcs_hmac_secret_enc TEXT,
|
||||
ADD COLUMN IF NOT EXISTS gcp_provision_status TEXT NOT NULL DEFAULT 'pending',
|
||||
ADD COLUMN IF NOT EXISTS gcp_provision_error TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS vibn_workspaces_gcp_status_idx
|
||||
ON vibn_workspaces (gcp_provision_status);
|
||||
|
||||
SELECT 'P5.3 workspace-GCS migration complete' AS status;
|
||||
22
vibn-frontend/scripts/prisma-db-push.mjs
Normal file
22
vibn-frontend/scripts/prisma-db-push.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Prisma CLI only auto-loads `.env`, not `.env.local` (Next.js does load .env.local).
|
||||
* This loads both so `npm run db:push` targets the same DATABASE_URL as `next dev`.
|
||||
*/
|
||||
import { config } from "dotenv";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
config({ path: path.join(root, ".env") });
|
||||
config({ path: path.join(root, ".env.local"), override: true });
|
||||
|
||||
const extra = process.argv.slice(2);
|
||||
const r = spawnSync("npx", ["prisma", "db", "push", ...extra], {
|
||||
stdio: "inherit",
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
process.exit(r.status ?? 1);
|
||||
86
vibn-frontend/scripts/provision-workspace-gcs.ts
Normal file
86
vibn-frontend/scripts/provision-workspace-gcs.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* One-shot: run ensureWorkspaceGcsProvisioned() for a specific workspace
|
||||
* slug against PROD GCP + PROD Postgres. Idempotent — safe to re-run.
|
||||
*
|
||||
* Unlike scripts/smoke-storage-e2e.ts this does NOT clean up; the whole
|
||||
* point is to persist the workspace's provisioned state into the DB.
|
||||
*
|
||||
* Usage:
|
||||
* cd vibn-frontend
|
||||
* npx -y dotenv-cli -e ../.google.env -e .env.local -- \
|
||||
* npx tsx scripts/provision-workspace-gcs.ts <slug>
|
||||
*
|
||||
* Required env:
|
||||
* GOOGLE_SERVICE_ACCOUNT_KEY_B64 (from ../.google.env)
|
||||
* DATABASE_URL (from .env.local, points at prod vibn-postgres)
|
||||
* VIBN_SECRETS_KEY (from .env.local, ≥16 chars)
|
||||
*/
|
||||
|
||||
import { queryOne } from '../lib/db-postgres';
|
||||
import { ensureWorkspaceGcsProvisioned } from '../lib/workspace-gcs';
|
||||
import type { VibnWorkspace } from '../lib/workspaces';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const slug = process.argv[2];
|
||||
if (!slug) {
|
||||
console.error('Usage: tsx scripts/provision-workspace-gcs.ts <workspace-slug>');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
console.log('━'.repeat(72));
|
||||
console.log(` Provision GCS for workspace: ${slug}`);
|
||||
console.log('━'.repeat(72));
|
||||
|
||||
// Fetch the current row.
|
||||
const ws = await queryOne<VibnWorkspace>(
|
||||
`SELECT * FROM vibn_workspaces WHERE slug = $1`,
|
||||
[slug],
|
||||
);
|
||||
if (!ws) {
|
||||
console.error(`No vibn_workspaces row found for slug=${slug}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(` id : ${ws.id}`);
|
||||
console.log(` name : ${ws.name}`);
|
||||
console.log(` owner_user_id : ${ws.owner_user_id}`);
|
||||
// @ts-expect-error — new columns not yet in VibnWorkspace type
|
||||
console.log(` gcp_status : ${ws.gcp_provision_status ?? 'pending'}`);
|
||||
console.log('');
|
||||
|
||||
console.log('Running ensureWorkspaceGcsProvisioned()…');
|
||||
const result = await ensureWorkspaceGcsProvisioned(ws);
|
||||
|
||||
console.log('');
|
||||
console.log('━'.repeat(72));
|
||||
console.log(' RESULT');
|
||||
console.log('━'.repeat(72));
|
||||
console.log(` status : ${result.status}`);
|
||||
console.log(` SA : ${result.serviceAccountEmail}`);
|
||||
console.log(` bucket : ${result.bucket.name}`);
|
||||
console.log(` location : ${result.bucket.location}`);
|
||||
console.log(` created : ${result.bucket.timeCreated ?? '(pre-existing)'}`);
|
||||
console.log(` HMAC accessId : ${result.hmac.accessId}`);
|
||||
console.log('');
|
||||
|
||||
// Re-read to confirm persistence.
|
||||
const after = await queryOne<Record<string, unknown>>(
|
||||
`SELECT gcp_service_account_email,
|
||||
CASE WHEN gcp_service_account_key_enc IS NOT NULL THEN '<enc '||length(gcp_service_account_key_enc)||' b64>' ELSE 'null' END AS sa_key,
|
||||
gcs_default_bucket_name,
|
||||
gcs_hmac_access_id,
|
||||
CASE WHEN gcs_hmac_secret_enc IS NOT NULL THEN '<enc '||length(gcs_hmac_secret_enc)||' b64>' ELSE 'null' END AS hmac_secret,
|
||||
gcp_provision_status,
|
||||
gcp_provision_error
|
||||
FROM vibn_workspaces WHERE id = $1`,
|
||||
[ws.id],
|
||||
);
|
||||
console.log('DB row after:');
|
||||
console.log(JSON.stringify(after, null, 2));
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('[provision-workspace-gcs] FAILED:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
133
vibn-frontend/scripts/reassign-migrated-data.ts
Normal file
133
vibn-frontend/scripts/reassign-migrated-data.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// MUST load environment variables BEFORE any other imports
|
||||
require('dotenv').config({ path: require('path').resolve(__dirname, '../.env.local') });
|
||||
|
||||
import admin from 'firebase-admin';
|
||||
import { FieldValue } from 'firebase-admin/firestore';
|
||||
|
||||
// Initialize Firebase Admin directly
|
||||
if (!admin.apps.length) {
|
||||
const privateKey = process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n');
|
||||
|
||||
if (!process.env.FIREBASE_PROJECT_ID || !process.env.FIREBASE_CLIENT_EMAIL || !privateKey) {
|
||||
throw new Error('Missing Firebase Admin credentials. Check your .env.local file.');
|
||||
}
|
||||
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert({
|
||||
projectId: process.env.FIREBASE_PROJECT_ID,
|
||||
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
|
||||
privateKey: privateKey,
|
||||
}),
|
||||
});
|
||||
|
||||
console.log('✅ Firebase Admin initialized successfully');
|
||||
}
|
||||
|
||||
const adminDb = admin.firestore();
|
||||
const adminAuth = admin.auth();
|
||||
|
||||
async function reassignMigratedData() {
|
||||
console.log('🚀 Starting data reassignment...\n');
|
||||
|
||||
try {
|
||||
// Get the current user (mark@getacquired.com)
|
||||
console.log('📋 Finding current user: mark@getacquired.com');
|
||||
const currentUser = await adminAuth.getUserByEmail('mark@getacquired.com');
|
||||
console.log(`✅ Current user found: ${currentUser.uid}\n`);
|
||||
|
||||
// Get the migrated user (mark@example.com)
|
||||
console.log('📋 Finding migrated user: mark@example.com');
|
||||
let migratedUser;
|
||||
try {
|
||||
migratedUser = await adminAuth.getUserByEmail('mark@example.com');
|
||||
console.log(`✅ Migrated user found: ${migratedUser.uid}\n`);
|
||||
} catch (error) {
|
||||
console.log('⚠️ Migrated user not found, will look for any migrated data by flag\n');
|
||||
}
|
||||
|
||||
// Reassign all collections
|
||||
const collections = ['sessions', 'projects', 'clients', 'workCompleted'];
|
||||
|
||||
for (const collectionName of collections) {
|
||||
console.log(`\n📋 Processing ${collectionName}...`);
|
||||
|
||||
// Query for migrated documents
|
||||
let query = adminDb.collection(collectionName).where('migratedFrom', '==', 'postgresql');
|
||||
|
||||
const snapshot = await query.get();
|
||||
|
||||
if (snapshot.empty) {
|
||||
console.log(` ℹ️ No migrated ${collectionName} found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(` Found ${snapshot.size} migrated ${collectionName}`);
|
||||
|
||||
// Update each document
|
||||
const batch = adminDb.batch();
|
||||
let count = 0;
|
||||
|
||||
for (const doc of snapshot.docs) {
|
||||
const data = doc.data();
|
||||
const updates: any = {
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
};
|
||||
|
||||
// Update userId field
|
||||
if (data.userId) {
|
||||
updates.userId = currentUser.uid;
|
||||
}
|
||||
|
||||
// Update ownerId field (for clients)
|
||||
if (data.ownerId) {
|
||||
updates.ownerId = currentUser.uid;
|
||||
}
|
||||
|
||||
batch.update(doc.ref, updates);
|
||||
count++;
|
||||
|
||||
// Commit batch every 500 documents (Firestore limit)
|
||||
if (count % 500 === 0) {
|
||||
await batch.commit();
|
||||
console.log(` ✅ Committed ${count} updates...`);
|
||||
}
|
||||
}
|
||||
|
||||
// Commit remaining
|
||||
if (count % 500 !== 0) {
|
||||
await batch.commit();
|
||||
}
|
||||
|
||||
console.log(` ✅ Reassigned ${count} ${collectionName} to ${currentUser.email}`);
|
||||
}
|
||||
|
||||
// Delete the temporary migrated user if it exists
|
||||
if (migratedUser) {
|
||||
console.log('\n📋 Cleaning up temporary migrated user account...');
|
||||
|
||||
// Delete the user document
|
||||
await adminDb.collection('users').doc(migratedUser.uid).delete();
|
||||
|
||||
// Delete the Auth account
|
||||
await adminAuth.deleteUser(migratedUser.uid);
|
||||
|
||||
console.log('✅ Temporary user account deleted');
|
||||
}
|
||||
|
||||
console.log('\n✅ Data reassignment completed successfully!');
|
||||
console.log(`\n🎉 All migrated data is now assigned to: ${currentUser.email}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Data reassignment failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Run reassignment
|
||||
reassignMigratedData()
|
||||
.then(() => process.exit(0))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
74
vibn-frontend/scripts/setup-alloydb-cloudshell.sh
Normal file
74
vibn-frontend/scripts/setup-alloydb-cloudshell.sh
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/bin/bash
|
||||
# Run this script in Google Cloud Shell to set up AlloyDB
|
||||
# Cloud Shell has VPC access, so it can connect to AlloyDB directly
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 AlloyDB Setup via Cloud Shell"
|
||||
echo "=================================="
|
||||
echo ""
|
||||
|
||||
# Set project
|
||||
PROJECT_ID="gen-lang-client-0980079410"
|
||||
REGION="northamerica-northeast1"
|
||||
CLUSTER="vibn"
|
||||
INSTANCE="vibn-primary"
|
||||
USER="mark@getacquired.com"
|
||||
|
||||
echo "Project: $PROJECT_ID"
|
||||
echo "Cluster: $CLUSTER"
|
||||
echo "Instance: $INSTANCE"
|
||||
echo ""
|
||||
|
||||
# Get AlloyDB private IP
|
||||
echo "📍 Getting AlloyDB private IP..."
|
||||
ALLOYDB_IP=$(gcloud alloydb instances describe $INSTANCE \
|
||||
--cluster=$CLUSTER \
|
||||
--region=$REGION \
|
||||
--project=$PROJECT_ID \
|
||||
--format="value(ipAddress)")
|
||||
|
||||
echo "✅ AlloyDB IP: $ALLOYDB_IP"
|
||||
echo ""
|
||||
|
||||
# Install psql if needed
|
||||
if ! command -v psql &> /dev/null; then
|
||||
echo "📦 Installing PostgreSQL client..."
|
||||
sudo apt-get update && sudo apt-get install -y postgresql-client
|
||||
fi
|
||||
|
||||
echo "✅ PostgreSQL client ready"
|
||||
echo ""
|
||||
|
||||
# Connect and check databases
|
||||
echo "🔍 Checking existing databases..."
|
||||
psql "host=$ALLOYDB_IP port=5432 user=$USER dbname=postgres sslmode=disable" -c "\l"
|
||||
|
||||
echo ""
|
||||
read -p "Create 'vibn' database? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "📝 Creating vibn database..."
|
||||
psql "host=$ALLOYDB_IP port=5432 user=$USER dbname=postgres sslmode=disable" \
|
||||
-c "CREATE DATABASE vibn;"
|
||||
echo "✅ Database created"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🔌 Enabling extensions..."
|
||||
psql "host=$ALLOYDB_IP port=5432 user=$USER dbname=vibn sslmode=disable" <<EOF
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
SELECT extname, extversion FROM pg_extension WHERE extname IN ('vector', 'uuid-ossp');
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
echo "✅ Extensions installed!"
|
||||
echo ""
|
||||
echo "📋 Next: Upload and run the schema file"
|
||||
echo ""
|
||||
echo "1. Upload lib/db/knowledge-chunks-schema.sql to Cloud Shell"
|
||||
echo "2. Run: psql \"host=$ALLOYDB_IP port=5432 user=$USER dbname=vibn sslmode=disable\" -f knowledge-chunks-schema.sql"
|
||||
echo ""
|
||||
echo "Or copy-paste the SQL manually into psql"
|
||||
|
||||
180
vibn-frontend/scripts/setup-alloydb.sh
Executable file
180
vibn-frontend/scripts/setup-alloydb.sh
Executable file
@@ -0,0 +1,180 @@
|
||||
#!/bin/bash
|
||||
# AlloyDB Setup Script for Vibn
|
||||
# This script helps you configure AlloyDB with a service account
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
echo "🚀 AlloyDB Setup for Vibn"
|
||||
echo "=========================="
|
||||
echo ""
|
||||
|
||||
# Get project ID
|
||||
PROJECT_ID=$(gcloud config get-value project 2>/dev/null)
|
||||
if [ -z "$PROJECT_ID" ]; then
|
||||
echo "❌ No GCP project configured. Run: gcloud config set project YOUR_PROJECT_ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📋 Project: $PROJECT_ID"
|
||||
echo ""
|
||||
|
||||
# Prompt for cluster details
|
||||
read -p "Enter your AlloyDB cluster name: " CLUSTER_NAME
|
||||
read -p "Enter your AlloyDB region [us-central1]: " REGION
|
||||
REGION=${REGION:-us-central1}
|
||||
read -p "Enter your AlloyDB instance name [${CLUSTER_NAME}-primary]: " INSTANCE_NAME
|
||||
INSTANCE_NAME=${INSTANCE_NAME:-${CLUSTER_NAME}-primary}
|
||||
|
||||
echo ""
|
||||
echo "Configuration:"
|
||||
echo " Cluster: $CLUSTER_NAME"
|
||||
echo " Region: $REGION"
|
||||
echo " Instance: $INSTANCE_NAME"
|
||||
echo ""
|
||||
|
||||
read -p "Continue? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create service account
|
||||
echo ""
|
||||
echo "📝 Step 1: Creating service account..."
|
||||
SA_NAME="vibn-alloydb-client"
|
||||
SA_EMAIL="${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com"
|
||||
|
||||
if gcloud iam service-accounts describe $SA_EMAIL &>/dev/null; then
|
||||
echo "✅ Service account already exists: $SA_EMAIL"
|
||||
else
|
||||
gcloud iam service-accounts create $SA_NAME \
|
||||
--display-name="Vibn AlloyDB Client" \
|
||||
--description="Service account for Vibn app to access AlloyDB"
|
||||
echo "✅ Created service account: $SA_EMAIL"
|
||||
fi
|
||||
|
||||
# Grant permissions
|
||||
echo ""
|
||||
echo "🔑 Step 2: Granting permissions..."
|
||||
|
||||
gcloud projects add-iam-policy-binding $PROJECT_ID \
|
||||
--member="serviceAccount:${SA_EMAIL}" \
|
||||
--role="roles/alloydb.client" \
|
||||
--condition=None \
|
||||
--quiet
|
||||
|
||||
gcloud projects add-iam-policy-binding $PROJECT_ID \
|
||||
--member="serviceAccount:${SA_EMAIL}" \
|
||||
--role="roles/compute.networkUser" \
|
||||
--condition=None \
|
||||
--quiet
|
||||
|
||||
echo "✅ Granted AlloyDB client and network user roles"
|
||||
|
||||
# Create IAM database user
|
||||
echo ""
|
||||
echo "👤 Step 3: Creating IAM database user..."
|
||||
|
||||
if gcloud alloydb users list \
|
||||
--cluster=$CLUSTER_NAME \
|
||||
--instance=$INSTANCE_NAME \
|
||||
--region=$REGION \
|
||||
--filter="name:${SA_EMAIL}" \
|
||||
--format="value(name)" 2>/dev/null | grep -q "${SA_EMAIL}"; then
|
||||
echo "✅ IAM user already exists"
|
||||
else
|
||||
gcloud alloydb users create $SA_EMAIL \
|
||||
--instance=$INSTANCE_NAME \
|
||||
--cluster=$CLUSTER_NAME \
|
||||
--region=$REGION \
|
||||
--type=IAM_BASED
|
||||
echo "✅ Created IAM database user"
|
||||
fi
|
||||
|
||||
# Download service account key
|
||||
echo ""
|
||||
echo "🔐 Step 4: Downloading service account key..."
|
||||
KEY_FILE="$HOME/vibn-alloydb-key.json"
|
||||
|
||||
if [ -f "$KEY_FILE" ]; then
|
||||
read -p "Key file already exists. Overwrite? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Skipping key download"
|
||||
else
|
||||
gcloud iam service-accounts keys create $KEY_FILE \
|
||||
--iam-account=$SA_EMAIL
|
||||
chmod 600 $KEY_FILE
|
||||
echo "✅ Key saved to: $KEY_FILE"
|
||||
fi
|
||||
else
|
||||
gcloud iam service-accounts keys create $KEY_FILE \
|
||||
--iam-account=$SA_EMAIL
|
||||
chmod 600 $KEY_FILE
|
||||
echo "✅ Key saved to: $KEY_FILE"
|
||||
fi
|
||||
|
||||
# Get AlloyDB instance URI
|
||||
INSTANCE_URI="projects/${PROJECT_ID}/locations/${REGION}/clusters/${CLUSTER_NAME}/instances/${INSTANCE_NAME}"
|
||||
|
||||
echo ""
|
||||
echo "🎉 Setup Complete!"
|
||||
echo "=================="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo ""
|
||||
echo "1. Add to your .env.local:"
|
||||
echo " ALLOYDB_HOST=127.0.0.1"
|
||||
echo " ALLOYDB_PORT=5432"
|
||||
echo " ALLOYDB_USER=${SA_EMAIL}"
|
||||
echo " ALLOYDB_PASSWORD="
|
||||
echo " ALLOYDB_DATABASE=vibn"
|
||||
echo " ALLOYDB_SSL=false"
|
||||
echo " GOOGLE_APPLICATION_CREDENTIALS=${KEY_FILE}"
|
||||
echo ""
|
||||
echo "2. Start AlloyDB Auth Proxy (in a separate terminal):"
|
||||
echo " alloydb-auth-proxy \\"
|
||||
echo " --credentials-file=${KEY_FILE} \\"
|
||||
echo " --port=5432 \\"
|
||||
echo " ${INSTANCE_URI}"
|
||||
echo ""
|
||||
echo "3. Create database and run schema:"
|
||||
echo " psql \"host=127.0.0.1 port=5432 user=${SA_EMAIL}\" -c 'CREATE DATABASE vibn;'"
|
||||
echo " psql \"host=127.0.0.1 port=5432 dbname=vibn user=${SA_EMAIL}\" \\"
|
||||
echo " -f lib/db/knowledge-chunks-schema.sql"
|
||||
echo ""
|
||||
echo "4. Test connection:"
|
||||
echo " npm run test:db"
|
||||
echo ""
|
||||
|
||||
# Optionally create .env.local entry
|
||||
read -p "Add these to .env.local now? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
ENV_FILE=".env.local"
|
||||
|
||||
# Backup existing .env.local
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
cp $ENV_FILE "${ENV_FILE}.backup"
|
||||
echo "📦 Backed up existing .env.local"
|
||||
fi
|
||||
|
||||
# Append AlloyDB config
|
||||
cat >> $ENV_FILE << EOF
|
||||
|
||||
# AlloyDB Configuration (added by setup script)
|
||||
ALLOYDB_HOST=127.0.0.1
|
||||
ALLOYDB_PORT=5432
|
||||
ALLOYDB_USER=${SA_EMAIL}
|
||||
ALLOYDB_PASSWORD=
|
||||
ALLOYDB_DATABASE=vibn
|
||||
ALLOYDB_SSL=false
|
||||
GOOGLE_APPLICATION_CREDENTIALS=${KEY_FILE}
|
||||
EOF
|
||||
|
||||
echo "✅ Added AlloyDB config to .env.local"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "📚 For full guide, see: SETUP_ALLOYDB_SERVICE_ACCOUNT.md"
|
||||
|
||||
296
vibn-frontend/scripts/smoke-attach-e2e.ts
Normal file
296
vibn-frontend/scripts/smoke-attach-e2e.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* End-to-end attach smoke against PROD infrastructure.
|
||||
*
|
||||
* What this exercises (against real Cloud DNS + sandbox OpenSRS + real Coolify):
|
||||
* 1. registerDomain → OpenSRS Horizon sandbox creates a fake but
|
||||
* protocol-correct registration (no $$).
|
||||
* 2. cloudDnsProvider.createZone → real public managed zone in
|
||||
* master-ai-484822 (anycast DNS, costs ~$0.20/mo
|
||||
* while it lives — we delete on cleanup).
|
||||
* 3. cloudDnsProvider.setRecords → A records for apex + www → 34.19.250.135.
|
||||
* 4. updateDomainNameservers → tells the OpenSRS sandbox to point the test
|
||||
* domain at the Cloud DNS NS set. Triggers the
|
||||
* unlock/relock fallback if the registry returns
|
||||
* 405 — proves the recovery path works.
|
||||
* 5. Coolify PATCH → adds the new fqdn to a real Coolify app's
|
||||
* domain list (target: missinglettr-test, which
|
||||
* is already on a sslip.io URL so no SSL/Traefik
|
||||
* fallout from a vanity hostname).
|
||||
*
|
||||
* What it deliberately SKIPS:
|
||||
* - DB writes (vibn_domains row + events) — those require prod DB which is
|
||||
* on the internal Docker network. The lib code that does those writes is
|
||||
* covered by unit-style call sites; what we're proving here is the
|
||||
* external-side-effect surface that's hard to test from CI.
|
||||
*
|
||||
* Cleanup (best-effort) at the end:
|
||||
* - Removes the test fqdn from the Coolify app's domain list.
|
||||
* - Deletes the Cloud DNS managed zone.
|
||||
*
|
||||
* The sandbox OpenSRS domain expires on its own — no cleanup needed.
|
||||
*
|
||||
* Required env (load from .opensrs.env + .coolify.env + .gcp.env):
|
||||
* OPENSRS_RESELLER_USERNAME, OPENSRS_API_KEY_TEST, OPENSRS_MODE=test
|
||||
* COOLIFY_URL, COOLIFY_API_TOKEN
|
||||
* GOOGLE_SERVICE_ACCOUNT_KEY_B64 (or GOOGLE_APPLICATION_CREDENTIALS pointing at the JSON)
|
||||
* GCP_PROJECT_ID (defaults to master-ai-484822)
|
||||
*
|
||||
* Usage:
|
||||
* set -a && source ../.opensrs.env && source ../.coolify.env && source ../.gcp.env && set +a
|
||||
* npx tsx scripts/smoke-attach-e2e.ts
|
||||
*/
|
||||
|
||||
import { registerDomain, updateDomainNameservers, setDomainLock, OpenSrsError, type RegistrationContact } from '../lib/opensrs';
|
||||
import { cloudDnsProvider } from '../lib/dns/cloud-dns';
|
||||
import { getGcpAccessToken, GCP_PROJECT_ID } from '../lib/gcp-auth';
|
||||
|
||||
const COOLIFY_URL = process.env.COOLIFY_URL ?? '';
|
||||
const COOLIFY_API_TOKEN = process.env.COOLIFY_API_TOKEN ?? '';
|
||||
|
||||
// missinglettr-test — pre-existing sslip.io test app on the Vibn server.
|
||||
// Safe target: no public DNS, no Let's Encrypt issuance for vanity hostnames.
|
||||
const TARGET_APP_UUID = 'nksoo0cscw48gwo4ggc4g4kk';
|
||||
const TARGET_APP_PUBLIC_IP = '34.19.250.135';
|
||||
|
||||
const CONTACT: RegistrationContact = {
|
||||
first_name: 'Mark',
|
||||
last_name: 'Henderson',
|
||||
org_name: 'Get Acquired Inc',
|
||||
address1: '123 King St W',
|
||||
city: 'Toronto',
|
||||
state: 'ON',
|
||||
country: 'CA',
|
||||
postal_code: 'M5H 1A1',
|
||||
phone: '+1.4165551234',
|
||||
email: 'mark@getacquired.com',
|
||||
};
|
||||
|
||||
interface CoolifyApp {
|
||||
uuid: string;
|
||||
name: string;
|
||||
fqdn?: string;
|
||||
domains?: string;
|
||||
}
|
||||
|
||||
async function coolifyGetApp(uuid: string): Promise<CoolifyApp> {
|
||||
const res = await fetch(`${COOLIFY_URL}/api/v1/applications/${uuid}`, {
|
||||
headers: { Authorization: `Bearer ${COOLIFY_API_TOKEN}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`coolify GET app ${uuid} → ${res.status}: ${await res.text()}`);
|
||||
return res.json() as Promise<CoolifyApp>;
|
||||
}
|
||||
|
||||
async function coolifySetAppDomains(uuid: string, domainsCsv: string): Promise<void> {
|
||||
// Mirror the production lib/coolify.ts setApplicationDomains() body.
|
||||
// Send `domains` (the controller maps it to the DB's `fqdn` column when the
|
||||
// destination server has Server::isProxyShouldRun() === true — i.e.
|
||||
// proxy.type=TRAEFIK|CADDY AND is_build_server=false). If the PATCH
|
||||
// returns 200 but the fqdn doesn't change, the destination server's
|
||||
// proxy/build-server config is the most likely cause.
|
||||
const res = await fetch(`${COOLIFY_URL}/api/v1/applications/${uuid}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${COOLIFY_API_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
domains: domainsCsv,
|
||||
force_domain_override: true,
|
||||
is_force_https_enabled: true,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`coolify PATCH app ${uuid} → ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
|
||||
function parseDomains(s: string | undefined): string[] {
|
||||
if (!s) return [];
|
||||
return s
|
||||
.split(/[,\s]+/)
|
||||
.map(x => x.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// Reject anything that would let us hit live OpenSRS by accident.
|
||||
const mode = process.env.OPENSRS_MODE ?? 'test';
|
||||
if (mode !== 'test') {
|
||||
throw new Error(`Refusing to run with OPENSRS_MODE=${mode}. Set OPENSRS_MODE=test for sandbox-proof.`);
|
||||
}
|
||||
if (!COOLIFY_URL || !COOLIFY_API_TOKEN) throw new Error('COOLIFY_URL + COOLIFY_API_TOKEN required');
|
||||
if (!process.env.GOOGLE_SERVICE_ACCOUNT_KEY_B64 && !process.env.GOOGLE_APPLICATION_CREDENTIALS) {
|
||||
throw new Error('GOOGLE_SERVICE_ACCOUNT_KEY_B64 (or GOOGLE_APPLICATION_CREDENTIALS) required');
|
||||
}
|
||||
|
||||
const apex = `vibnai-e2e-${Date.now()}.com`;
|
||||
const subs = ['@', 'www'];
|
||||
const fqdns = subs.map(s => (s === '@' ? `https://${apex}` : `https://${s}.${apex}`));
|
||||
|
||||
console.log('━'.repeat(70));
|
||||
console.log(' VIBN P5.1 attach end-to-end (PROD GCP + sandbox OpenSRS + PROD Coolify)');
|
||||
console.log('━'.repeat(70));
|
||||
console.log(' apex :', apex);
|
||||
console.log(' target : Coolify app', TARGET_APP_UUID, '(missinglettr-test)');
|
||||
console.log(' target IP :', TARGET_APP_PUBLIC_IP);
|
||||
console.log('');
|
||||
|
||||
// ── Step 0: capture the Coolify app's current domains so we can restore later
|
||||
console.log('[0/6] Snapshot Coolify app current state…');
|
||||
const appBefore = await coolifyGetApp(TARGET_APP_UUID);
|
||||
const domainsBefore = parseDomains(appBefore.domains ?? appBefore.fqdn ?? '');
|
||||
console.log(' app name :', appBefore.name);
|
||||
console.log(' domains before :', domainsBefore.length, '→', JSON.stringify(domainsBefore.slice(0, 3)));
|
||||
|
||||
// ── Step 1: register sandbox domain
|
||||
console.log('\n[1/6] Register sandbox domain via OpenSRS Horizon…');
|
||||
const reg = await registerDomain({ domain: apex, period: 1, contact: CONTACT, whoisPrivacy: true });
|
||||
console.log(' ✓ orderId :', reg.orderId);
|
||||
console.log(' ✓ responseCode :', reg.responseCode, '/', reg.responseText);
|
||||
|
||||
// ── Step 2: create Cloud DNS managed zone
|
||||
console.log('\n[2/6] Create Cloud DNS managed zone (PROD master-ai-484822)…');
|
||||
const zone = await cloudDnsProvider.createZone(apex);
|
||||
console.log(' ✓ zoneId :', zone.zoneId);
|
||||
console.log(' ✓ nameservers :', zone.nameservers.join(', '));
|
||||
|
||||
// ── Step 3: write A records (apex + www → 34.19.250.135)
|
||||
console.log('\n[3/6] Write A records to Cloud DNS…');
|
||||
await cloudDnsProvider.setRecords(apex, [
|
||||
{ name: '@', type: 'A', rrdatas: [TARGET_APP_PUBLIC_IP], ttl: 300 },
|
||||
{ name: 'www', type: 'A', rrdatas: [TARGET_APP_PUBLIC_IP], ttl: 300 },
|
||||
]);
|
||||
console.log(' ✓ A @ →', TARGET_APP_PUBLIC_IP);
|
||||
console.log(' ✓ A www →', TARGET_APP_PUBLIC_IP);
|
||||
|
||||
// ── Step 4: update registrar nameservers (with unlock/relock fallback)
|
||||
//
|
||||
// 4a. Try with real Cloud DNS nameservers. In sandbox mode this is expected
|
||||
// to fail with 480 ("nameserver doesn't exist at the registry") because
|
||||
// OpenSRS Horizon mocks a registry that only knows pre-seeded NS hosts.
|
||||
// The point of trying anyway is to prove the trailing-dot normalization
|
||||
// works (otherwise we'd see a 460 "Invalid nameserver host" before the
|
||||
// registry even gets consulted). 480 == validation passed, registry
|
||||
// check failed → in live mode, real registries accept any resolvable NS.
|
||||
//
|
||||
// 4b. Re-run against `ns{1,2}.systemdns.com` (which Horizon recognizes) so
|
||||
// we also exercise the success path including the unlock/relock fallback.
|
||||
console.log('\n[4/6] Update registrar nameservers via OpenSRS…');
|
||||
let cloudNsResult: { responseCode: string; responseText: string } | string;
|
||||
try {
|
||||
cloudNsResult = await updateDomainNameservers(apex, zone.nameservers);
|
||||
console.log(' ✓ 4a real Cloud DNS NS accepted:', cloudNsResult.responseCode);
|
||||
} catch (err) {
|
||||
if (err instanceof OpenSrsError && err.code === '480') {
|
||||
cloudNsResult = '480-sandbox-mock-registry-rejected (expected — live mode uses real registry)';
|
||||
console.log(' ✓ 4a Cloud DNS NS validation passed; sandbox mock-registry rejection (480) is expected');
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(' → 4b retry with sandbox-recognized NS (ns{1,2}.systemdns.com) to exercise success path…');
|
||||
const sandboxNs = ['ns1.systemdns.com', 'ns2.systemdns.com'];
|
||||
let nsResult: { responseCode: string; responseText: string } | null = null;
|
||||
let nsPath: 'direct' | 'unlock-retry-relock' = 'direct';
|
||||
try {
|
||||
nsResult = await updateDomainNameservers(apex, sandboxNs);
|
||||
console.log(' ✓ 4b direct NS update succeeded:', nsResult.responseCode);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof OpenSrsError &&
|
||||
err.code === '405' &&
|
||||
/status prohibits operation/i.test(err.message)
|
||||
) {
|
||||
console.log(' ↻ 4b 405 lock conflict → unlocking, retrying, then relocking');
|
||||
nsPath = 'unlock-retry-relock';
|
||||
await setDomainLock(apex, false);
|
||||
nsResult = await updateDomainNameservers(apex, sandboxNs);
|
||||
console.log(' ✓ 4b retry NS update succeeded:', nsResult.responseCode);
|
||||
try {
|
||||
const relock = await setDomainLock(apex, true);
|
||||
console.log(' ✓ 4b relocked :', relock.responseCode);
|
||||
} catch (relockErr) {
|
||||
console.warn(' ⚠ relock failed (non-fatal):', relockErr instanceof Error ? relockErr.message : relockErr);
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 5: PATCH Coolify app domain list
|
||||
console.log('\n[5/6] Add fqdns to Coolify app domain list…');
|
||||
const merged = Array.from(new Set([...domainsBefore, ...fqdns]));
|
||||
await coolifySetAppDomains(TARGET_APP_UUID, merged.join(','));
|
||||
const appAfter = await coolifyGetApp(TARGET_APP_UUID);
|
||||
const domainsAfter = parseDomains(appAfter.domains ?? appAfter.fqdn ?? '');
|
||||
console.log(' ✓ PATCH accepted (HTTP 200, no error)');
|
||||
console.log(' ✓ app domains now:', domainsAfter.length);
|
||||
let coolifyApplied = true;
|
||||
for (const fq of fqdns) {
|
||||
const present = domainsAfter.includes(fq);
|
||||
console.log(' ', present ? '✓' : '✗', fq);
|
||||
if (!present) coolifyApplied = false;
|
||||
}
|
||||
if (!coolifyApplied) {
|
||||
console.log(' ✗ Coolify still did not persist the fqdn — unexpected. Previously this was');
|
||||
console.log(' caused by sending `domains` instead of `fqdn` (API alias vs model column).');
|
||||
console.log(' We now send `fqdn`; if this branch fires, dig into the PATCH response body.');
|
||||
}
|
||||
|
||||
// ── Step 6: cleanup
|
||||
console.log('\n[6/6] Cleanup (remove fqdns from Coolify, delete Cloud DNS zone)…');
|
||||
await coolifySetAppDomains(TARGET_APP_UUID, domainsBefore.join(','));
|
||||
console.log(' ✓ Coolify domains restored');
|
||||
|
||||
// Cloud DNS rejects deleteZone if any non-SOA/NS rrsets remain. List them
|
||||
// and submit a deletion change for everything except SOA + the apex NS.
|
||||
const zoneApiName = `vibn-${apex.toLowerCase().replace(/[^a-z0-9-]/g, '-')}`;
|
||||
const token = await getGcpAccessToken();
|
||||
const listRes = await fetch(
|
||||
`https://dns.googleapis.com/dns/v1/projects/${GCP_PROJECT_ID}/managedZones/${zoneApiName}/rrsets`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
const listJson = (await listRes.json()) as { rrsets?: Array<{ name: string; type: string; ttl?: number; rrdatas: string[] }> };
|
||||
const dnsName = `${apex}.`;
|
||||
const toDelete = (listJson.rrsets ?? []).filter(rs => !(rs.name === dnsName && (rs.type === 'SOA' || rs.type === 'NS')));
|
||||
if (toDelete.length > 0) {
|
||||
const dropRes = await fetch(
|
||||
`https://dns.googleapis.com/dns/v1/projects/${GCP_PROJECT_ID}/managedZones/${zoneApiName}/changes`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ kind: 'dns#change', deletions: toDelete }),
|
||||
},
|
||||
);
|
||||
if (!dropRes.ok) console.warn(' ⚠ rrset cleanup failed:', dropRes.status, (await dropRes.text()).slice(0, 200));
|
||||
else console.log(' ✓ dropped', toDelete.length, 'rrset(s)');
|
||||
}
|
||||
await cloudDnsProvider.deleteZone(apex);
|
||||
console.log(' ✓ Cloud DNS zone deleted');
|
||||
|
||||
console.log('\n━'.repeat(35));
|
||||
console.log(' SUMMARY');
|
||||
console.log('━'.repeat(70));
|
||||
console.log(' OpenSRS register : ✓ orderId', reg.orderId);
|
||||
console.log(' Cloud DNS zone : ✓', zone.zoneId);
|
||||
console.log(' Cloud DNS rrsets : ✓ A @ + A www');
|
||||
console.log(' Registrar NS (real) : ' + (typeof cloudNsResult === 'string' ? cloudNsResult : '✓ ' + cloudNsResult.responseCode));
|
||||
console.log(' Registrar NS (mock) : ✓ via', nsPath, '(', nsResult?.responseCode, ')');
|
||||
console.log(' Coolify domain PATCH : ' + (coolifyApplied
|
||||
? '✓ added ' + fqdns.length + ' fqdns'
|
||||
: '✗ fqdn did not persist — PATCH returned 200 but column unchanged. '
|
||||
+ 'Check destination server: proxy.type must be TRAEFIK|CADDY and '
|
||||
+ 'is_build_server must be false (Server::isProxyShouldRun()).'));
|
||||
console.log(' Cleanup : ✓ Coolify restored, zone deleted');
|
||||
console.log('');
|
||||
if (coolifyApplied) {
|
||||
console.log(' ALL 5 PROD SUB-SYSTEMS PROVEN END-TO-END.');
|
||||
} else {
|
||||
console.log(' 4 of 5 PROD SUB-SYSTEMS PROVEN. Coolify fqdn update still failing —');
|
||||
console.log(' inspect the PATCH response body + Application.$fillable in the running image.');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('\n[smoke-attach-e2e] FAILED:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
96
vibn-frontend/scripts/smoke-chat-tools.ts
Normal file
96
vibn-frontend/scripts/smoke-chat-tools.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Smoke test: validate VIBN_TOOL_DEFINITIONS against the live Gemini API.
|
||||
*
|
||||
* Sends the full tool list with a trivial prompt and checks whether Gemini
|
||||
* accepts the schemas. Catches schema validation errors (ARRAY without items,
|
||||
* free OBJECT params, etc.) before we deploy.
|
||||
*
|
||||
* Usage: GOOGLE_API_KEY=... npx tsx scripts/smoke-chat-tools.ts
|
||||
* (also picks up GOOGLE_API_KEY from .env.local automatically)
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { VIBN_TOOL_DEFINITIONS } from '../lib/ai/vibn-tools';
|
||||
|
||||
// Load .env.local manually to avoid needing dotenv as a dep
|
||||
try {
|
||||
const envPath = join(process.cwd(), '.env.local');
|
||||
const envText = readFileSync(envPath, 'utf-8');
|
||||
for (const line of envText.split('\n')) {
|
||||
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
||||
if (m && !process.env[m[1]]) process.env[m[1]] = m[2].trim();
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const API_KEY = process.env.GOOGLE_API_KEY;
|
||||
const MODEL = process.env.VIBN_CHAT_MODEL || 'gemini-3.1-pro-preview';
|
||||
|
||||
if (!API_KEY) {
|
||||
console.error('Missing GOOGLE_API_KEY');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function validateAll() {
|
||||
console.log(`Validating ${VIBN_TOOL_DEFINITIONS.length} tool definitions against ${MODEL}...\n`);
|
||||
|
||||
const url = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${API_KEY}`;
|
||||
const body = {
|
||||
contents: [{ role: 'user', parts: [{ text: 'Show me what is running in my workspace.' }] }],
|
||||
tools: [{ functionDeclarations: VIBN_TOOL_DEFINITIONS }],
|
||||
generationConfig: { temperature: 0.0, maxOutputTokens: 200 },
|
||||
};
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(`\n❌ FAIL: HTTP ${res.status}`);
|
||||
console.error(JSON.stringify(data, null, 2));
|
||||
|
||||
// Try to identify which tools are bad by sending them one at a time
|
||||
console.log('\n🔍 Bisecting to find broken tools...\n');
|
||||
const bad: { name: string; error: string }[] = [];
|
||||
for (const tool of VIBN_TOOL_DEFINITIONS) {
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contents: [{ role: 'user', parts: [{ text: 'hi' }] }],
|
||||
tools: [{ functionDeclarations: [tool] }],
|
||||
generationConfig: { temperature: 0.0, maxOutputTokens: 10 },
|
||||
}),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const err = await r.json();
|
||||
const msg = err?.error?.message || JSON.stringify(err).slice(0, 200);
|
||||
bad.push({ name: tool.name, error: msg });
|
||||
console.log(` ❌ ${tool.name}: ${msg.slice(0, 150)}`);
|
||||
} else {
|
||||
console.log(` ✓ ${tool.name}`);
|
||||
}
|
||||
}
|
||||
console.log(`\n${bad.length} broken tool(s) out of ${VIBN_TOOL_DEFINITIONS.length}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('✅ All tool definitions accepted by Gemini.');
|
||||
|
||||
const calls = data?.candidates?.[0]?.content?.parts
|
||||
?.filter((p: any) => p.functionCall)
|
||||
.map((p: any) => `${p.functionCall.name}(${Object.keys(p.functionCall.args || {}).join(',')})`);
|
||||
if (calls?.length) {
|
||||
console.log(`\nGemini chose to call: ${calls.join(', ')}`);
|
||||
} else {
|
||||
console.log('\n(No tool calls produced — model responded with text)');
|
||||
}
|
||||
}
|
||||
|
||||
validateAll().catch((e) => {
|
||||
console.error('Test crashed:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
21
vibn-frontend/scripts/smoke-opensrs-lock.ts
Normal file
21
vibn-frontend/scripts/smoke-opensrs-lock.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Smoke: register unlocked, lock it, then unlock. Proves setDomainLock().
|
||||
*/
|
||||
import { registerDomain, setDomainLock, type RegistrationContact } from '../lib/opensrs';
|
||||
|
||||
const CONTACT: RegistrationContact = {
|
||||
first_name: 'Mark', last_name: 'Henderson', org_name: 'Get Acquired Inc',
|
||||
address1: '123 King St W', city: 'Toronto', state: 'ON',
|
||||
country: 'CA', postal_code: 'M5H 1A1',
|
||||
phone: '+1.4165551234', email: 'mark@getacquired.com',
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const domain = `vibnai-lock-${Date.now()}.com`;
|
||||
const reg = await registerDomain({ domain, period: 1, contact: CONTACT });
|
||||
console.log('[lock-smoke] registered', domain, 'order=', reg.orderId);
|
||||
console.log('[lock-smoke] lock ->', await setDomainLock(domain, true));
|
||||
console.log('[lock-smoke] unlock ->', await setDomainLock(domain, false));
|
||||
}
|
||||
|
||||
main().catch(err => { console.error('[lock-smoke] FAILED:', err); process.exit(1); });
|
||||
45
vibn-frontend/scripts/smoke-opensrs-ns-update.ts
Normal file
45
vibn-frontend/scripts/smoke-opensrs-ns-update.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Smoke: register a sandbox domain, then update its nameservers to a
|
||||
* fake-but-valid set. Proves the full attach-time registrar flow.
|
||||
*
|
||||
* Usage: source .opensrs.env && npx tsx scripts/smoke-opensrs-ns-update.ts
|
||||
*/
|
||||
|
||||
import {
|
||||
registerDomain,
|
||||
updateDomainNameservers,
|
||||
type RegistrationContact,
|
||||
} from '../lib/opensrs';
|
||||
|
||||
const CONTACT: RegistrationContact = {
|
||||
first_name: 'Mark',
|
||||
last_name: 'Henderson',
|
||||
org_name: 'Get Acquired Inc',
|
||||
address1: '123 King St W',
|
||||
city: 'Toronto',
|
||||
state: 'ON',
|
||||
country: 'CA',
|
||||
postal_code: 'M5H 1A1',
|
||||
phone: '+1.4165551234',
|
||||
email: 'mark@getacquired.com',
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const domain = `vibnai-ns-${Date.now()}.com`;
|
||||
console.log('[ns-smoke] register', domain);
|
||||
const reg = await registerDomain({ domain, period: 1, contact: CONTACT });
|
||||
console.log('[ns-smoke] registered. order=', reg.orderId);
|
||||
|
||||
// Cloud DNS nameservers follow this format — use real-looking values.
|
||||
// Horizon only knows about nameservers that already exist at the registry
|
||||
// it talks to. Its built-in ones are ns1/ns2.systemdns.com, and they're
|
||||
// the ones every sandbox domain registers with by default. In production
|
||||
// we'll point at the Cloud DNS nameservers returned from createZone, which
|
||||
// are publicly resolvable and accepted by every upstream registry.
|
||||
const ns = ['ns1.systemdns.com', 'ns2.systemdns.com', 'ns3.systemdns.com', 'ns4.systemdns.com'];
|
||||
console.log('[ns-smoke] updating NS to', ns);
|
||||
const upd = await updateDomainNameservers(domain, ns);
|
||||
console.log('[ns-smoke] NS update response:', upd);
|
||||
}
|
||||
|
||||
main().catch(err => { console.error('[ns-smoke] FAILED:', err); process.exit(1); });
|
||||
38
vibn-frontend/scripts/smoke-opensrs-register.ts
Normal file
38
vibn-frontend/scripts/smoke-opensrs-register.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Live sandbox registration smoke.
|
||||
*
|
||||
* Runs sw_register against Horizon (test mode) with the Vibn corporate
|
||||
* contact. Horizon doesn't actually allocate a name at any registry — it's
|
||||
* purely a protocol simulator — so this is safe to run repeatedly.
|
||||
*
|
||||
* Usage: source .opensrs.env && npx tsx scripts/smoke-opensrs-register.ts
|
||||
*/
|
||||
|
||||
import { registerDomain, type RegistrationContact } from '../lib/opensrs';
|
||||
|
||||
const CONTACT: RegistrationContact = {
|
||||
first_name: 'Mark',
|
||||
last_name: 'Henderson',
|
||||
org_name: 'Get Acquired Inc',
|
||||
address1: '123 King St W',
|
||||
city: 'Toronto',
|
||||
state: 'ON',
|
||||
country: 'CA',
|
||||
postal_code: 'M5H 1A1',
|
||||
phone: '+1.4165551234',
|
||||
email: 'mark@getacquired.com',
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const domain = `vibnai-smoke-${Date.now()}.com`;
|
||||
console.log('[register-smoke] registering', domain);
|
||||
const result = await registerDomain({
|
||||
domain,
|
||||
period: 1,
|
||||
contact: CONTACT,
|
||||
whoisPrivacy: true,
|
||||
});
|
||||
console.log('[register-smoke] result:', result);
|
||||
}
|
||||
|
||||
main().catch(err => { console.error('[register-smoke] FAILED:', err); process.exit(1); });
|
||||
28
vibn-frontend/scripts/smoke-opensrs-tlds.ts
Normal file
28
vibn-frontend/scripts/smoke-opensrs-tlds.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* TLD-quirk smoke test: verify that .ca, .ai, .io all return sensible
|
||||
* availability + price through lib/opensrs.ts.
|
||||
*
|
||||
* Usage: source .opensrs.env && npx tsx scripts/smoke-opensrs-tlds.ts
|
||||
*/
|
||||
|
||||
import { checkDomain } from '../lib/opensrs';
|
||||
|
||||
async function main() {
|
||||
const stamp = Date.now();
|
||||
const cases = [
|
||||
{ domain: `vibnai-${stamp}.ca`, period: 1 },
|
||||
{ domain: `vibnai-${stamp}.ai`, period: 1 }, // should auto-bump to 2y
|
||||
{ domain: `vibnai-${stamp}.io`, period: 1 },
|
||||
{ domain: `vibnai-${stamp}.com`, period: 1 },
|
||||
];
|
||||
for (const c of cases) {
|
||||
try {
|
||||
const r = await checkDomain(c.domain, c.period);
|
||||
console.log(c.domain.padEnd(32), '->', JSON.stringify(r));
|
||||
} catch (err) {
|
||||
console.log(c.domain.padEnd(32), '-> ERR', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => { console.error(err); process.exit(1); });
|
||||
31
vibn-frontend/scripts/smoke-opensrs.ts
Normal file
31
vibn-frontend/scripts/smoke-opensrs.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Smoke test for lib/opensrs.ts against the OpenSRS Horizon sandbox.
|
||||
*
|
||||
* Usage (from repo root):
|
||||
* source .opensrs.env
|
||||
* cd vibn-frontend && npx tsx scripts/smoke-opensrs.ts vibnai-smoke-$(date +%s).com
|
||||
*
|
||||
* - Prints availability + price for the given domain.
|
||||
* - Does NOT attempt registration (that belongs to a separate manual test).
|
||||
*/
|
||||
|
||||
import { checkDomain, getResellerBalance, lookupDomain } from '../lib/opensrs';
|
||||
|
||||
async function main() {
|
||||
const domain = process.argv[2] ?? `vibnai-smoke-${Date.now()}.com`;
|
||||
console.log(`[smoke-opensrs] mode=${process.env.OPENSRS_MODE ?? 'test'} domain=${domain}`);
|
||||
|
||||
const balance = await getResellerBalance();
|
||||
console.log('[smoke-opensrs] reseller balance:', balance);
|
||||
|
||||
const avail = await lookupDomain(domain);
|
||||
console.log('[smoke-opensrs] lookup:', avail);
|
||||
|
||||
const check = await checkDomain(domain, 1);
|
||||
console.log('[smoke-opensrs] check (availability + price):', check);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('[smoke-opensrs] FAILED:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
415
vibn-frontend/scripts/smoke-storage-e2e.ts
Normal file
415
vibn-frontend/scripts/smoke-storage-e2e.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* P5.3 — End-to-end smoke for per-workspace GCS provisioning.
|
||||
*
|
||||
* What this exercises (against PROD GCP — master-ai-484822):
|
||||
* 1. ensureWorkspaceServiceAccount → creates a throwaway SA
|
||||
* (vibn-ws-smoke-{ts}@…). Idempotent.
|
||||
* 2. createServiceAccountKey → mints + base64-encodes a JSON key.
|
||||
* 3. createBucket → creates vibn-ws-smoke-{ts}-{6char}
|
||||
* in northamerica-northeast1 with uniform bucket-level access ON
|
||||
* and public access prevention enforced.
|
||||
* 4. addBucketIamBinding → grants the throwaway SA
|
||||
* roles/storage.objectAdmin on the bucket only.
|
||||
* 5. createHmacKey → mints S3-compatible HMAC creds
|
||||
* tied to the throwaway SA.
|
||||
* 6. (verify) HMAC PUT/GET → uploads a 12-byte object via the
|
||||
* GCS XML API using AWS SigV4 with the HMAC creds, reads it back,
|
||||
* deletes it. Proves the credentials actually work.
|
||||
*
|
||||
* Cleanup (best-effort, runs even on failure):
|
||||
* - Deletes the test object.
|
||||
* - Deactivates + deletes the HMAC key.
|
||||
* - Deletes all keys on the SA (so the SA itself can be removed).
|
||||
* - Deletes the bucket.
|
||||
* - Deletes the SA.
|
||||
*
|
||||
* NO Postgres writes. NO Coolify writes. NO project-level IAM changes.
|
||||
* Everything created has a "smoke-" prefix and a "purpose=smoke" label
|
||||
* so leftovers are obvious in the GCP console.
|
||||
*
|
||||
* Required env (load from /Users/markhenderson/master-ai/.google.env):
|
||||
* GOOGLE_SERVICE_ACCOUNT_KEY_B64 base64 of vibn-workspace-provisioner SA JSON
|
||||
* GCP_PROJECT_ID defaults to master-ai-484822
|
||||
*
|
||||
* Usage:
|
||||
* cd vibn-frontend
|
||||
* npx -y dotenv-cli -e ../.google.env -- npx tsx scripts/smoke-storage-e2e.ts
|
||||
*/
|
||||
|
||||
import { createHash, createHmac } from 'crypto';
|
||||
import { GCP_PROJECT_ID } from '../lib/gcp-auth';
|
||||
import {
|
||||
ensureWorkspaceServiceAccount,
|
||||
createServiceAccountKey,
|
||||
workspaceServiceAccountEmail,
|
||||
workspaceServiceAccountId,
|
||||
} from '../lib/gcp/iam';
|
||||
import {
|
||||
createBucket,
|
||||
deleteBucket,
|
||||
addBucketIamBinding,
|
||||
getBucketIamPolicy,
|
||||
createHmacKey,
|
||||
deleteHmacKey,
|
||||
workspaceDefaultBucketName,
|
||||
VIBN_GCS_LOCATION,
|
||||
} from '../lib/gcp/storage';
|
||||
|
||||
const ts = Date.now().toString(36);
|
||||
const SLUG = `smoke-${ts}`;
|
||||
const SA_EMAIL = workspaceServiceAccountEmail(SLUG);
|
||||
const SA_ID = workspaceServiceAccountId(SLUG);
|
||||
const BUCKET = workspaceDefaultBucketName(SLUG);
|
||||
const TEST_OBJECT_KEY = 'smoke/hello.txt';
|
||||
const TEST_OBJECT_BODY = 'vibn smoke ✓';
|
||||
|
||||
function banner(): void {
|
||||
console.log('━'.repeat(72));
|
||||
console.log(' VIBN P5.3 GCS provisioning smoke (PROD GCP — master-ai-484822)');
|
||||
console.log('━'.repeat(72));
|
||||
console.log(` project : ${GCP_PROJECT_ID}`);
|
||||
console.log(` slug : ${SLUG}`);
|
||||
console.log(` SA : ${SA_EMAIL}`);
|
||||
console.log(` bucket : ${BUCKET}`);
|
||||
console.log(` location : ${VIBN_GCS_LOCATION}`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
interface State {
|
||||
saCreated: boolean;
|
||||
saKeyName?: string;
|
||||
bucketCreated: boolean;
|
||||
hmacAccessId?: string;
|
||||
uploadedObject: boolean;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
banner();
|
||||
const state: State = { saCreated: false, bucketCreated: false, uploadedObject: false };
|
||||
|
||||
try {
|
||||
// ── 1. Service account ────────────────────────────────────────────
|
||||
console.log('[1/6] Ensure service account…');
|
||||
const sa = await ensureWorkspaceServiceAccount({ slug: SLUG, workspaceName: SLUG });
|
||||
state.saCreated = true;
|
||||
console.log(` ✓ ${sa.email}`);
|
||||
|
||||
// ── 2. Service-account key ────────────────────────────────────────
|
||||
console.log('[2/6] Mint service-account JSON key…');
|
||||
const key = await createServiceAccountKey(sa.email);
|
||||
state.saKeyName = key.name;
|
||||
console.log(` ✓ key.name=${key.name.split('/').slice(-1)[0]} (privateKeyData ${key.privateKeyData.length} chars b64)`);
|
||||
|
||||
// ── 3. Bucket ────────────────────────────────────────────────────
|
||||
console.log('[3/6] Create bucket (uniform BLA on, public-access prevention enforced)…');
|
||||
const bucket = await createBucket({
|
||||
name: BUCKET,
|
||||
location: VIBN_GCS_LOCATION,
|
||||
enforcePublicAccessPrevention: true,
|
||||
workspaceSlug: SLUG,
|
||||
});
|
||||
state.bucketCreated = true;
|
||||
console.log(` ✓ ${bucket.name} in ${bucket.location}`);
|
||||
|
||||
// ── 4. Bucket IAM binding ────────────────────────────────────────
|
||||
console.log('[4/6] Add roles/storage.objectAdmin binding for the workspace SA…');
|
||||
await addBucketIamBinding({
|
||||
bucketName: bucket.name,
|
||||
role: 'roles/storage.objectAdmin',
|
||||
member: `serviceAccount:${sa.email}`,
|
||||
});
|
||||
const policy = await getBucketIamPolicy(bucket.name);
|
||||
const binding = policy.bindings?.find(
|
||||
b => b.role === 'roles/storage.objectAdmin' && b.members.includes(`serviceAccount:${sa.email}`),
|
||||
);
|
||||
if (!binding) {
|
||||
throw new Error('IAM binding did not stick — workspace SA not in objectAdmin members');
|
||||
}
|
||||
console.log(` ✓ binding present (${binding.members.length} member(s) on ${binding.role})`);
|
||||
|
||||
// ── 5. HMAC key ──────────────────────────────────────────────────
|
||||
console.log('[5/6] Mint HMAC key for the workspace SA…');
|
||||
const hmac = await createHmacKey(sa.email);
|
||||
state.hmacAccessId = hmac.accessId;
|
||||
console.log(` ✓ accessId=${hmac.accessId} state=${hmac.state}`);
|
||||
|
||||
// HMAC keys take a few seconds to become usable on the GCS XML API.
|
||||
// Without this delay we usually get "InvalidAccessKeyId" on the
|
||||
// very first request.
|
||||
console.log(' … waiting 6s for HMAC propagation');
|
||||
await sleep(6000);
|
||||
|
||||
// ── 6. Verify HMAC creds work via S3-compatible XML API ─────────
|
||||
console.log('[6/6] PUT / GET / DELETE a tiny object via the XML API using HMAC creds…');
|
||||
await s3PutObject({
|
||||
accessKeyId: hmac.accessId,
|
||||
secretAccessKey: hmac.secret,
|
||||
bucket: bucket.name,
|
||||
key: TEST_OBJECT_KEY,
|
||||
body: Buffer.from(TEST_OBJECT_BODY, 'utf-8'),
|
||||
contentType: 'text/plain; charset=utf-8',
|
||||
});
|
||||
state.uploadedObject = true;
|
||||
console.log(` ✓ PUT ${TEST_OBJECT_KEY}`);
|
||||
|
||||
const got = await s3GetObject({
|
||||
accessKeyId: hmac.accessId,
|
||||
secretAccessKey: hmac.secret,
|
||||
bucket: bucket.name,
|
||||
key: TEST_OBJECT_KEY,
|
||||
});
|
||||
if (got.toString('utf-8') !== TEST_OBJECT_BODY) {
|
||||
throw new Error(`GET body mismatch: ${JSON.stringify(got.toString('utf-8'))}`);
|
||||
}
|
||||
console.log(` ✓ GET round-trip body matches`);
|
||||
|
||||
await s3DeleteObject({
|
||||
accessKeyId: hmac.accessId,
|
||||
secretAccessKey: hmac.secret,
|
||||
bucket: bucket.name,
|
||||
key: TEST_OBJECT_KEY,
|
||||
});
|
||||
state.uploadedObject = false;
|
||||
console.log(` ✓ DELETE`);
|
||||
|
||||
console.log('');
|
||||
console.log('━'.repeat(72));
|
||||
console.log(' SUMMARY');
|
||||
console.log('━'.repeat(72));
|
||||
console.log(' SA create+key : ✓');
|
||||
console.log(' Bucket create : ✓');
|
||||
console.log(' Bucket IAM binding : ✓');
|
||||
console.log(' HMAC key + S3 round-trip : ✓');
|
||||
console.log('');
|
||||
console.log(' All 4 building blocks of P5.3 vertical slice proven against PROD GCP.');
|
||||
} catch (err) {
|
||||
console.error('');
|
||||
console.error('[smoke-storage-e2e] FAILED:', err);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
console.log('');
|
||||
console.log('Cleanup…');
|
||||
await cleanup(state).catch(err => {
|
||||
console.error('[cleanup] non-fatal error:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup(state: State): Promise<void> {
|
||||
// Object (best-effort; usually already deleted on the happy path).
|
||||
if (state.uploadedObject && state.hmacAccessId) {
|
||||
// The credential needed to delete the object lives only in the
|
||||
// smoke run's memory; if we crashed before saving the secret,
|
||||
// we can't delete it as the workspace SA. Fall back to deleting
|
||||
// the bucket which atomically removes contents (deleteBucket
|
||||
// requires an empty bucket — use force-delete via objects.delete
|
||||
// listing if it ever matters).
|
||||
}
|
||||
|
||||
// HMAC key.
|
||||
if (state.hmacAccessId) {
|
||||
try {
|
||||
await deleteHmacKey(state.hmacAccessId);
|
||||
console.log(` ✓ HMAC ${state.hmacAccessId} deleted`);
|
||||
} catch (err) {
|
||||
console.warn(` ⚠ HMAC delete failed:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Bucket. Must be empty; if a test object survived, list+delete first.
|
||||
if (state.bucketCreated) {
|
||||
try {
|
||||
// Try a hard delete; if the bucket has objects we'll get 409.
|
||||
await deleteBucket(BUCKET);
|
||||
console.log(` ✓ bucket ${BUCKET} deleted`);
|
||||
} catch (err) {
|
||||
console.warn(` ⚠ bucket delete failed (objects may remain):`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// SA keys + SA itself.
|
||||
if (state.saCreated) {
|
||||
try {
|
||||
await deleteAllSaKeysAndSa(SA_EMAIL);
|
||||
console.log(` ✓ SA ${SA_EMAIL} + keys deleted`);
|
||||
} catch (err) {
|
||||
console.warn(` ⚠ SA cleanup failed:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// Helpers — SA cleanup using the IAM API directly (the lib only exposes
|
||||
// create paths).
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { getGcpAccessToken } from '../lib/gcp-auth';
|
||||
|
||||
async function deleteAllSaKeysAndSa(email: string): Promise<void> {
|
||||
const token = await getGcpAccessToken();
|
||||
const base = `https://iam.googleapis.com/v1/projects/${GCP_PROJECT_ID}/serviceAccounts/${encodeURIComponent(email)}`;
|
||||
|
||||
// Delete user-managed keys (system-managed keys can't be deleted).
|
||||
const listRes = await fetch(`${base}/keys?keyTypes=USER_MANAGED`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (listRes.ok) {
|
||||
const listJson = (await listRes.json()) as { keys?: { name: string }[] };
|
||||
for (const k of listJson.keys ?? []) {
|
||||
const id = k.name.split('/').pop();
|
||||
if (!id) continue;
|
||||
const delRes = await fetch(`${base}/keys/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!delRes.ok && delRes.status !== 404) {
|
||||
console.warn(` ⚠ key ${id} delete → ${delRes.status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the SA.
|
||||
const delRes = await fetch(base, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!delRes.ok && delRes.status !== 404) {
|
||||
throw new Error(`SA delete → ${delRes.status} ${await delRes.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// AWS SigV4 against the GCS XML API
|
||||
//
|
||||
// We re-implement SigV4 here rather than pulling in @aws-sdk to keep
|
||||
// this script dependency-light. GCS treats the bucket as a virtual host
|
||||
// (https://{bucket}.storage.googleapis.com/{key}) and uses region
|
||||
// "auto" with service "s3".
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface S3Creds {
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
}
|
||||
|
||||
async function s3PutObject(opts: S3Creds & {
|
||||
bucket: string;
|
||||
key: string;
|
||||
body: Buffer;
|
||||
contentType?: string;
|
||||
}): Promise<void> {
|
||||
const url = `https://${opts.bucket}.storage.googleapis.com/${encodeURIComponent(opts.key)}`;
|
||||
const res = await sigv4Fetch({
|
||||
method: 'PUT',
|
||||
url,
|
||||
body: opts.body,
|
||||
contentType: opts.contentType,
|
||||
accessKeyId: opts.accessKeyId,
|
||||
secretAccessKey: opts.secretAccessKey,
|
||||
});
|
||||
if (!res.ok) throw new Error(`PUT ${opts.key} → ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
async function s3GetObject(opts: S3Creds & { bucket: string; key: string }): Promise<Buffer> {
|
||||
const url = `https://${opts.bucket}.storage.googleapis.com/${encodeURIComponent(opts.key)}`;
|
||||
const res = await sigv4Fetch({
|
||||
method: 'GET',
|
||||
url,
|
||||
accessKeyId: opts.accessKeyId,
|
||||
secretAccessKey: opts.secretAccessKey,
|
||||
});
|
||||
if (!res.ok) throw new Error(`GET ${opts.key} → ${res.status} ${await res.text()}`);
|
||||
return Buffer.from(await res.arrayBuffer());
|
||||
}
|
||||
|
||||
async function s3DeleteObject(opts: S3Creds & { bucket: string; key: string }): Promise<void> {
|
||||
const url = `https://${opts.bucket}.storage.googleapis.com/${encodeURIComponent(opts.key)}`;
|
||||
const res = await sigv4Fetch({
|
||||
method: 'DELETE',
|
||||
url,
|
||||
accessKeyId: opts.accessKeyId,
|
||||
secretAccessKey: opts.secretAccessKey,
|
||||
});
|
||||
if (!res.ok && res.status !== 404) {
|
||||
throw new Error(`DELETE ${opts.key} → ${res.status} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
interface SigV4FetchOpts extends S3Creds {
|
||||
method: 'GET' | 'PUT' | 'DELETE';
|
||||
url: string;
|
||||
body?: Buffer;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
async function sigv4Fetch(opts: SigV4FetchOpts): Promise<Response> {
|
||||
const { method, url, body, contentType, accessKeyId, secretAccessKey } = opts;
|
||||
const u = new URL(url);
|
||||
const host = u.host;
|
||||
const path = u.pathname || '/';
|
||||
const query = u.search.slice(1);
|
||||
|
||||
const now = new Date();
|
||||
const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, '');
|
||||
const dateStamp = amzDate.slice(0, 8);
|
||||
const region = 'auto';
|
||||
const service = 's3';
|
||||
const payloadHash = body
|
||||
? createHash('sha256').update(body).digest('hex')
|
||||
: createHash('sha256').update('').digest('hex');
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
host,
|
||||
'x-amz-date': amzDate,
|
||||
'x-amz-content-sha256': payloadHash,
|
||||
};
|
||||
if (contentType) headers['content-type'] = contentType;
|
||||
if (body) headers['content-length'] = String(body.length);
|
||||
|
||||
const signedHeaders = Object.keys(headers).map(k => k.toLowerCase()).sort().join(';');
|
||||
const canonicalHeaders =
|
||||
Object.keys(headers)
|
||||
.map(k => [k.toLowerCase(), String(headers[k]).trim()] as const)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]) => `${k}:${v}\n`)
|
||||
.join('');
|
||||
|
||||
const canonicalRequest = [
|
||||
method,
|
||||
path,
|
||||
query,
|
||||
canonicalHeaders,
|
||||
signedHeaders,
|
||||
payloadHash,
|
||||
].join('\n');
|
||||
|
||||
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
|
||||
const stringToSign = [
|
||||
'AWS4-HMAC-SHA256',
|
||||
amzDate,
|
||||
credentialScope,
|
||||
createHash('sha256').update(canonicalRequest).digest('hex'),
|
||||
].join('\n');
|
||||
|
||||
const kDate = createHmac('sha256', `AWS4${secretAccessKey}`).update(dateStamp).digest();
|
||||
const kRegion = createHmac('sha256', kDate).update(region).digest();
|
||||
const kService = createHmac('sha256', kRegion).update(service).digest();
|
||||
const kSigning = createHmac('sha256', kService).update('aws4_request').digest();
|
||||
const signature = createHmac('sha256', kSigning).update(stringToSign).digest('hex');
|
||||
|
||||
const authorization =
|
||||
`AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, ` +
|
||||
`SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
||||
|
||||
return fetch(url, {
|
||||
method,
|
||||
headers: { ...headers, Authorization: authorization },
|
||||
body: body ? new Uint8Array(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
main();
|
||||
18
vibn-frontend/scripts/start-with-alloydb.sh
Executable file
18
vibn-frontend/scripts/start-with-alloydb.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
# Start dev server with fresh AlloyDB access token
|
||||
|
||||
echo "🔑 Generating AlloyDB access token..."
|
||||
export ALLOYDB_PASSWORD=$(gcloud auth print-access-token)
|
||||
|
||||
if [ -z "$ALLOYDB_PASSWORD" ]; then
|
||||
echo "❌ Failed to generate access token"
|
||||
echo "Make sure you're logged in: gcloud auth login"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Token generated (expires in 1 hour)"
|
||||
echo "🚀 Starting dev server..."
|
||||
echo ""
|
||||
|
||||
npm run dev
|
||||
|
||||
145
vibn-frontend/scripts/sync-db-url-from-coolify.mjs
Normal file
145
vibn-frontend/scripts/sync-db-url-from-coolify.mjs
Normal file
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Pull postgres external URL from Coolify API and write DATABASE_URL + POSTGRES_URL in .env.local.
|
||||
*
|
||||
* Loads (in order): ../.coolify.env, .env, .env.local (Coolify wins for COOLIFY_* from parent file).
|
||||
*
|
||||
* Identify DB by (first match):
|
||||
* COOLIFY_DATABASE_UUID — exact
|
||||
* COOLIFY_DATABASE_NAME — default: vibn-postgres
|
||||
* argv[2] — uuid or name substring
|
||||
*
|
||||
* Requires: COOLIFY_URL, COOLIFY_API_TOKEN
|
||||
*/
|
||||
import { config } from "dotenv";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const repoRoot = path.join(root, "..");
|
||||
|
||||
config({ path: path.join(repoRoot, ".coolify.env") });
|
||||
config({ path: path.join(root, ".env") });
|
||||
config({ path: path.join(root, ".env.local"), override: true });
|
||||
|
||||
const COOLIFY_URL = (process.env.COOLIFY_URL ?? "").replace(/\/$/, "");
|
||||
const token = process.env.COOLIFY_API_TOKEN ?? "";
|
||||
const arg = process.argv[2];
|
||||
|
||||
function fail(msg) {
|
||||
console.error(msg);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!COOLIFY_URL || !token) {
|
||||
fail("Missing COOLIFY_URL or COOLIFY_API_TOKEN (e.g. set in master-ai/.coolify.env).");
|
||||
}
|
||||
|
||||
function normalizeDatabaseUrl(url) {
|
||||
if (!url || typeof url !== "string") return "";
|
||||
return url.replace(/^postgres:\/\//i, "postgresql://");
|
||||
}
|
||||
|
||||
async function coolifyFetch(path) {
|
||||
const res = await fetch(`${COOLIFY_URL}/api/v1${path}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
fail(`Coolify API ${res.status} ${path}: ${text.slice(0, 500)}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function pickDatabase(list) {
|
||||
const uuid = process.env.COOLIFY_DATABASE_UUID?.trim();
|
||||
const nameDefault = process.env.COOLIFY_DATABASE_NAME || "vibn-postgres";
|
||||
|
||||
if (uuid) {
|
||||
const d = list.find((x) => x.uuid === uuid);
|
||||
if (d) return d;
|
||||
fail(`No database with COOLIFY_DATABASE_UUID=${uuid}.`);
|
||||
}
|
||||
|
||||
if (arg?.trim()) {
|
||||
const a = arg.trim().toLowerCase();
|
||||
const byUuid = list.find((x) => x.uuid === arg.trim());
|
||||
if (byUuid) return byUuid;
|
||||
const byName = list.find(
|
||||
(x) =>
|
||||
String(x.name ?? "")
|
||||
.toLowerCase()
|
||||
.includes(a) ||
|
||||
String(x.uuid ?? "")
|
||||
.toLowerCase()
|
||||
.includes(a)
|
||||
);
|
||||
if (byName) return byName;
|
||||
fail(`No database matching "${arg}".`);
|
||||
}
|
||||
|
||||
const byName = list.find(
|
||||
(x) => String(x.name ?? "").toLowerCase() === nameDefault.toLowerCase()
|
||||
);
|
||||
if (byName) return byName;
|
||||
|
||||
fail(
|
||||
`No database named "${nameDefault}". Set COOLIFY_DATABASE_UUID or pass uuid/name as argument.`
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const list = await coolifyFetch("/databases");
|
||||
if (!Array.isArray(list) || list.length === 0) {
|
||||
fail("Coolify returned no databases.");
|
||||
}
|
||||
|
||||
const picked = pickDatabase(list);
|
||||
const detail = await coolifyFetch(`/databases/${picked.uuid}`);
|
||||
const raw = detail.external_db_url;
|
||||
const databaseUrl = normalizeDatabaseUrl(raw);
|
||||
|
||||
if (!databaseUrl) {
|
||||
fail(
|
||||
"No external_db_url from Coolify. Enable public exposure and set a host port on the Postgres service, then restart."
|
||||
);
|
||||
}
|
||||
|
||||
const envLocalPath = path.join(root, ".env.local");
|
||||
if (!fs.existsSync(envLocalPath)) {
|
||||
fail(`Missing ${envLocalPath}; create it first (copy from .env.example).`);
|
||||
}
|
||||
|
||||
let body = fs.readFileSync(envLocalPath, "utf8");
|
||||
const lines = body.split(/\r?\n/);
|
||||
const setLine = (key, value) => {
|
||||
const next = `${key}=${value}`;
|
||||
let seen = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].startsWith(`${key}=`)) {
|
||||
lines[i] = next;
|
||||
seen = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!seen) lines.push(next);
|
||||
};
|
||||
|
||||
setLine("DATABASE_URL", databaseUrl);
|
||||
setLine("POSTGRES_URL", databaseUrl);
|
||||
|
||||
fs.writeFileSync(envLocalPath, lines.join("\n") + (body.endsWith("\n") ? "\n" : ""), "utf8");
|
||||
console.log(
|
||||
`Updated DATABASE_URL and POSTGRES_URL in .env.local from Coolify (${picked.name}, ${picked.uuid}).`
|
||||
);
|
||||
console.log(`Public port from API: ${detail.public_port ?? "(n/a)"}; is_public: ${detail.is_public ?? "(n/a)"}`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
152
vibn-frontend/scripts/test-alloydb.ts
Normal file
152
vibn-frontend/scripts/test-alloydb.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Test AlloyDB Connection
|
||||
*
|
||||
* Run with: npx tsx scripts/test-alloydb.ts
|
||||
*/
|
||||
|
||||
import { getAlloyDbClient, checkAlloyDbHealth, executeQuery } from '../lib/db/alloydb';
|
||||
|
||||
async function testConnection() {
|
||||
console.log('🧪 Testing AlloyDB Connection\n');
|
||||
console.log('='.repeat(50));
|
||||
|
||||
try {
|
||||
// Test 1: Health check
|
||||
console.log('\n1️⃣ Health Check...');
|
||||
const healthy = await checkAlloyDbHealth();
|
||||
|
||||
if (!healthy) {
|
||||
console.error('❌ Health check failed!');
|
||||
console.log('\nTroubleshooting:');
|
||||
console.log(' 1. Is AlloyDB Auth Proxy running?');
|
||||
console.log(' 2. Check environment variables in .env.local');
|
||||
console.log(' 3. Verify service account has permissions');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('✅ Health check passed!');
|
||||
|
||||
// Test 2: PostgreSQL version
|
||||
console.log('\n2️⃣ PostgreSQL Version...');
|
||||
const versionResult = await executeQuery<{ version: string }>('SELECT version()');
|
||||
console.log('✅ Version:', versionResult.rows[0].version.split(',')[0]);
|
||||
|
||||
// Test 3: Check extensions
|
||||
console.log('\n3️⃣ Checking Extensions...');
|
||||
const extResult = await executeQuery<{ extname: string }>(
|
||||
"SELECT extname FROM pg_extension WHERE extname IN ('vector', 'uuid-ossp')"
|
||||
);
|
||||
|
||||
const installedExts = extResult.rows.map(r => r.extname);
|
||||
|
||||
if (installedExts.includes('vector')) {
|
||||
console.log('✅ pgvector extension installed');
|
||||
} else {
|
||||
console.log('❌ pgvector extension NOT installed');
|
||||
console.log(' Run: CREATE EXTENSION vector;');
|
||||
}
|
||||
|
||||
if (installedExts.includes('uuid-ossp')) {
|
||||
console.log('✅ uuid-ossp extension installed');
|
||||
} else {
|
||||
console.log('❌ uuid-ossp extension NOT installed');
|
||||
console.log(' Run: CREATE EXTENSION "uuid-ossp";');
|
||||
}
|
||||
|
||||
// Test 4: Check for knowledge_chunks table
|
||||
console.log('\n4️⃣ Checking Tables...');
|
||||
const tableResult = await executeQuery<{ table_name: string }>(
|
||||
`SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'knowledge_chunks'`
|
||||
);
|
||||
|
||||
if (tableResult.rows.length > 0) {
|
||||
console.log('✅ knowledge_chunks table exists');
|
||||
|
||||
// Check indexes
|
||||
const indexResult = await executeQuery<{ indexname: string }>(
|
||||
`SELECT indexname
|
||||
FROM pg_indexes
|
||||
WHERE tablename = 'knowledge_chunks'`
|
||||
);
|
||||
|
||||
console.log(`✅ ${indexResult.rows.length} indexes created:`);
|
||||
indexResult.rows.forEach(row => {
|
||||
console.log(` - ${row.indexname}`);
|
||||
});
|
||||
|
||||
// Count chunks
|
||||
const countResult = await executeQuery<{ count: string }>(
|
||||
'SELECT COUNT(*) as count FROM knowledge_chunks'
|
||||
);
|
||||
const count = parseInt(countResult.rows[0].count, 10);
|
||||
console.log(`✅ Total chunks: ${count}`);
|
||||
|
||||
} else {
|
||||
console.log('⚠️ knowledge_chunks table NOT found');
|
||||
console.log(' Run the schema file:');
|
||||
console.log(' psql "host=127.0.0.1 port=5432 dbname=vibn user=YOUR_SA" \\');
|
||||
console.log(' -f lib/db/knowledge-chunks-schema.sql');
|
||||
}
|
||||
|
||||
// Test 5: Test vector operations (if table exists and vector extension installed)
|
||||
if (tableResult.rows.length > 0 && installedExts.includes('vector')) {
|
||||
console.log('\n5️⃣ Testing Vector Operations...');
|
||||
|
||||
try {
|
||||
// Create a test embedding
|
||||
const testEmbedding = Array.from({ length: 768 }, () => Math.random());
|
||||
|
||||
// Test vector similarity query (should not error even with empty table)
|
||||
await executeQuery(
|
||||
`SELECT id
|
||||
FROM knowledge_chunks
|
||||
ORDER BY embedding <=> $1::vector
|
||||
LIMIT 1`,
|
||||
[JSON.stringify(testEmbedding)]
|
||||
);
|
||||
|
||||
console.log('✅ Vector similarity queries working!');
|
||||
} catch (error) {
|
||||
console.log('❌ Vector operations failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(50));
|
||||
console.log('🎉 AlloyDB is ready to use!');
|
||||
console.log('='.repeat(50));
|
||||
console.log('\nNext steps:');
|
||||
console.log(' 1. Start your app: npm run dev');
|
||||
console.log(' 2. Import a knowledge item to test chunking');
|
||||
console.log(' 3. Send a chat message to test vector search');
|
||||
console.log('');
|
||||
|
||||
process.exit(0);
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Connection failed!');
|
||||
console.error('Error:', error instanceof Error ? error.message : String(error));
|
||||
console.log('\nTroubleshooting:');
|
||||
console.log(' 1. Check .env.local has correct values');
|
||||
console.log(' 2. Ensure AlloyDB Auth Proxy is running:');
|
||||
console.log(' alloydb-auth-proxy --credentials-file=~/vibn-alloydb-key.json --port=5432 YOUR_INSTANCE_URI');
|
||||
console.log(' 3. Verify service account permissions');
|
||||
console.log(' 4. Check network connectivity');
|
||||
console.log('');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run test
|
||||
console.log('Starting AlloyDB connection test...\n');
|
||||
console.log('Environment:');
|
||||
console.log(' ALLOYDB_HOST:', process.env.ALLOYDB_HOST);
|
||||
console.log(' ALLOYDB_PORT:', process.env.ALLOYDB_PORT);
|
||||
console.log(' ALLOYDB_DATABASE:', process.env.ALLOYDB_DATABASE);
|
||||
console.log(' ALLOYDB_USER:', process.env.ALLOYDB_USER?.substring(0, 30) + '...');
|
||||
console.log('');
|
||||
|
||||
testConnection();
|
||||
|
||||
128
vibn-frontend/scripts/test-endpoints.sh
Executable file
128
vibn-frontend/scripts/test-endpoints.sh
Executable file
@@ -0,0 +1,128 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Endpoint Health Check Script
|
||||
# Tests all critical API endpoints to ensure they work after refactor
|
||||
|
||||
echo "🧪 Testing Vibn API Endpoints"
|
||||
echo "======================================"
|
||||
echo ""
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Base URL
|
||||
BASE_URL="http://localhost:3000"
|
||||
|
||||
# Test counter
|
||||
TOTAL=0
|
||||
PASSED=0
|
||||
FAILED=0
|
||||
|
||||
# Helper function to test an endpoint
|
||||
test_endpoint() {
|
||||
local METHOD=$1
|
||||
local PATH=$2
|
||||
local EXPECTED_STATUS=$3
|
||||
local DESCRIPTION=$4
|
||||
local AUTH=${5:-""}
|
||||
|
||||
TOTAL=$((TOTAL + 1))
|
||||
|
||||
if [ -z "$AUTH" ]; then
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X $METHOD "$BASE_URL$PATH" 2>/dev/null)
|
||||
else
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X $METHOD -H "Authorization: Bearer $AUTH" "$BASE_URL$PATH" 2>/dev/null)
|
||||
fi
|
||||
|
||||
if [ "$STATUS" == "$EXPECTED_STATUS" ]; then
|
||||
echo -e "${GREEN}✓${NC} $DESCRIPTION"
|
||||
echo " └─ $METHOD $PATH → $STATUS"
|
||||
PASSED=$((PASSED + 1))
|
||||
else
|
||||
echo -e "${RED}✗${NC} $DESCRIPTION"
|
||||
echo " └─ $METHOD $PATH → Expected $EXPECTED_STATUS, got $STATUS"
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "1️⃣ Frontend Pages"
|
||||
echo "-----------------------------------"
|
||||
test_endpoint "GET" "/" "200" "Home page"
|
||||
echo ""
|
||||
|
||||
echo "2️⃣ Project APIs (No Auth Required for Testing Structure)"
|
||||
echo "-----------------------------------"
|
||||
test_endpoint "POST" "/api/projects/create" "401" "Create project (should require auth)"
|
||||
test_endpoint "GET" "/api/projects/phase" "405" "Phase endpoint (POST only)"
|
||||
test_endpoint "GET" "/api/debug/first-project" "200" "Debug: First project"
|
||||
echo ""
|
||||
|
||||
echo "3️⃣ Knowledge & Context APIs"
|
||||
echo "-----------------------------------"
|
||||
test_endpoint "POST" "/api/projects/test-project/knowledge/upload-document" "401" "Upload document (should require auth)"
|
||||
test_endpoint "POST" "/api/projects/test-project/knowledge/import-document" "401" "Import document (should require auth)"
|
||||
test_endpoint "POST" "/api/projects/test-project/knowledge/import-ai-chat" "401" "Import AI chat (should require auth)"
|
||||
test_endpoint "POST" "/api/projects/test-project/knowledge/batch-extract" "401" "Batch extract (should require auth)"
|
||||
test_endpoint "GET" "/api/debug/knowledge-items" "200" "Debug: Knowledge items"
|
||||
test_endpoint "GET" "/api/debug/context-sources" "200" "Debug: Context sources"
|
||||
echo ""
|
||||
|
||||
echo "4️⃣ AI Chat APIs"
|
||||
echo "-----------------------------------"
|
||||
test_endpoint "POST" "/api/ai/chat" "401" "AI chat (should require auth)"
|
||||
test_endpoint "GET" "/api/ai/conversation" "400" "Get conversation (requires projectId)"
|
||||
test_endpoint "POST" "/api/ai/conversation/reset" "400" "Reset conversation (requires projectId)"
|
||||
echo ""
|
||||
|
||||
echo "5️⃣ Extraction APIs"
|
||||
echo "-----------------------------------"
|
||||
test_endpoint "POST" "/api/projects/test-project/extract-from-chat" "401" "Extract from chat (should require auth)"
|
||||
test_endpoint "POST" "/api/projects/test-project/aggregate" "401" "Aggregate extractions (should require auth)"
|
||||
echo ""
|
||||
|
||||
echo "6️⃣ GitHub Integration APIs"
|
||||
echo "-----------------------------------"
|
||||
test_endpoint "GET" "/api/github/repos" "401" "Get GitHub repos (should require auth)"
|
||||
test_endpoint "POST" "/api/github/connect" "401" "Connect GitHub (should require auth)"
|
||||
test_endpoint "GET" "/api/github/repo-tree" "400" "Get repo tree (requires params)"
|
||||
test_endpoint "GET" "/api/github/file-content" "400" "Get file content (requires params)"
|
||||
echo ""
|
||||
|
||||
echo "7️⃣ Planning & Vision APIs"
|
||||
echo "-----------------------------------"
|
||||
test_endpoint "POST" "/api/projects/test-project/plan/mvp" "401" "Generate MVP plan (should require auth)"
|
||||
test_endpoint "POST" "/api/projects/test-project/plan/marketing" "401" "Generate marketing plan (should require auth)"
|
||||
test_endpoint "POST" "/api/vision/update" "400" "Update vision (requires projectId)"
|
||||
echo ""
|
||||
|
||||
echo "8️⃣ Utility APIs"
|
||||
echo "-----------------------------------"
|
||||
test_endpoint "POST" "/api/context/summarize" "400" "Summarize context (requires body)"
|
||||
test_endpoint "GET" "/api/debug/env" "200" "Debug: Environment check"
|
||||
test_endpoint "GET" "/api/diagnose" "200" "Diagnose system"
|
||||
echo ""
|
||||
|
||||
echo ""
|
||||
echo "======================================"
|
||||
echo "📊 Test Results"
|
||||
echo "======================================"
|
||||
echo -e "Total: $TOTAL"
|
||||
echo -e "${GREEN}Passed: $PASSED${NC}"
|
||||
echo -e "${RED}Failed: $FAILED${NC}"
|
||||
echo ""
|
||||
|
||||
if [ $FAILED -eq 0 ]; then
|
||||
echo -e "${GREEN}✅ All endpoint structure tests passed!${NC}"
|
||||
echo ""
|
||||
echo "Note: 401/400 responses are EXPECTED for auth-protected and"
|
||||
echo "parameter-required endpoints. This confirms they exist and"
|
||||
echo "are properly configured."
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}❌ Some endpoints failed. Check the output above.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user