169 lines
5.9 KiB
TypeScript
169 lines
5.9 KiB
TypeScript
|
|
/**
|
||
|
|
* Step: identity-restore — Restore identity files from Supabase backups.
|
||
|
|
*
|
||
|
|
* Fetches SOUL.md, IDENTITY.md, USER.md (and optionally MEMORY.md) from the
|
||
|
|
* Supabase `backups` table and writes them to the project root.
|
||
|
|
*
|
||
|
|
* Skipped silently if SUPABASE_URL is not set.
|
||
|
|
* Non-fatal on any fetch error — identity files can be created manually.
|
||
|
|
*
|
||
|
|
* Supabase backups table schema:
|
||
|
|
* file_path TEXT PRIMARY KEY
|
||
|
|
* content TEXT
|
||
|
|
* updated_at TIMESTAMPTZ
|
||
|
|
*/
|
||
|
|
|
||
|
|
import fs from 'fs';
|
||
|
|
import path from 'path';
|
||
|
|
|
||
|
|
import { logger } from '../src/logger.js';
|
||
|
|
import { emitStatus } from './status.js';
|
||
|
|
|
||
|
|
// ── Env parser ────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
function parseEnv(content: string): Record<string, string> {
|
||
|
|
const result: Record<string, string> = {};
|
||
|
|
for (const line of content.split('\n')) {
|
||
|
|
const trimmed = line.trim();
|
||
|
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||
|
|
const idx = trimmed.indexOf('=');
|
||
|
|
if (idx === -1) continue;
|
||
|
|
const key = trimmed.slice(0, idx).trim();
|
||
|
|
const rawValue = trimmed.slice(idx + 1).trim();
|
||
|
|
result[key] = rawValue.replace(/^['"]|['"]$/g, '');
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Supabase fetch ────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
interface BackupRow {
|
||
|
|
file_path: string;
|
||
|
|
content: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function fetchBackups(
|
||
|
|
supabaseUrl: string,
|
||
|
|
serviceRoleKey: string,
|
||
|
|
): Promise<BackupRow[]> {
|
||
|
|
const url = `${supabaseUrl}/rest/v1/backups?select=file_path,content`;
|
||
|
|
const response = await fetch(url, {
|
||
|
|
headers: {
|
||
|
|
Authorization: `Bearer ${serviceRoleKey}`,
|
||
|
|
apikey: serviceRoleKey,
|
||
|
|
'Content-Type': 'application/json',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!response.ok) {
|
||
|
|
throw new Error(`Supabase responded ${response.status}: ${await response.text()}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (await response.json()) as BackupRow[];
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Identity file resolution ──────────────────────────────────────────────────
|
||
|
|
|
||
|
|
// Identity files may be stored under various path conventions across versions.
|
||
|
|
// Try all known patterns and pick the first match.
|
||
|
|
const IDENTITY_FILES: Record<string, string[]> = {
|
||
|
|
'SOUL.md': ['SOUL.md', 'v2/SOUL.md', 'v2/identity/SOUL.md', 'identity/SOUL.md'],
|
||
|
|
'IDENTITY.md': ['IDENTITY.md', 'v2/IDENTITY.md', 'v2/identity/IDENTITY.md', 'identity/IDENTITY.md'],
|
||
|
|
'USER.md': ['USER.md', 'v2/USER.md', 'v2/identity/USER.md', 'identity/USER.md'],
|
||
|
|
'MEMORY.md': ['MEMORY.md', 'v2/MEMORY.md', 'v2/memory/MEMORY.md'],
|
||
|
|
};
|
||
|
|
|
||
|
|
function resolveFile(
|
||
|
|
target: string,
|
||
|
|
candidates: string[],
|
||
|
|
rows: BackupRow[],
|
||
|
|
): BackupRow | undefined {
|
||
|
|
for (const candidate of candidates) {
|
||
|
|
const row = rows.find(r => r.file_path === candidate);
|
||
|
|
if (row) {
|
||
|
|
logger.info({ target, found_at: candidate }, 'identity file located');
|
||
|
|
return row;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
export async function run(_args: string[]): Promise<void> {
|
||
|
|
const projectRoot = process.cwd();
|
||
|
|
const envFile = path.join(projectRoot, '.env');
|
||
|
|
|
||
|
|
if (!fs.existsSync(envFile)) {
|
||
|
|
logger.warn('identity-restore: .env not found — skipping');
|
||
|
|
emitStatus('IDENTITY_RESTORE', { STATUS: 'skipped', REASON: 'env_missing' });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const env = parseEnv(fs.readFileSync(envFile, 'utf-8'));
|
||
|
|
const supabaseUrl = env.SUPABASE_URL;
|
||
|
|
const serviceRoleKey = env.SUPABASE_SERVICE_ROLE_KEY;
|
||
|
|
|
||
|
|
if (!supabaseUrl || !serviceRoleKey) {
|
||
|
|
logger.warn('identity-restore: SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY not set — skipping');
|
||
|
|
emitStatus('IDENTITY_RESTORE', { STATUS: 'skipped', REASON: 'supabase_not_configured' });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.info({ supabaseUrl }, 'Fetching identity files from Supabase');
|
||
|
|
|
||
|
|
let rows: BackupRow[];
|
||
|
|
try {
|
||
|
|
rows = await fetchBackups(supabaseUrl, serviceRoleKey);
|
||
|
|
} catch (err) {
|
||
|
|
const error = err instanceof Error ? err.message : String(err);
|
||
|
|
logger.warn({ error }, 'identity-restore: Supabase fetch failed — skipping');
|
||
|
|
emitStatus('IDENTITY_RESTORE', { STATUS: 'skipped', REASON: 'fetch_failed', ERROR: error });
|
||
|
|
// Non-fatal: identity files can be created manually
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.info({ count: rows.length }, 'Supabase backups rows fetched');
|
||
|
|
|
||
|
|
const written: string[] = [];
|
||
|
|
const missing: string[] = [];
|
||
|
|
|
||
|
|
for (const [filename, candidates] of Object.entries(IDENTITY_FILES)) {
|
||
|
|
const row = resolveFile(filename, candidates, rows);
|
||
|
|
|
||
|
|
if (!row) {
|
||
|
|
missing.push(filename);
|
||
|
|
logger.info({ filename }, 'identity file not found in Supabase backups');
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const dest = path.join(projectRoot, filename);
|
||
|
|
|
||
|
|
// Do not overwrite an existing file that has real content
|
||
|
|
if (fs.existsSync(dest)) {
|
||
|
|
const existing = fs.readFileSync(dest, 'utf-8').trim();
|
||
|
|
if (existing.length > 0) {
|
||
|
|
logger.info({ filename }, 'identity file exists locally — skipping overwrite');
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fs.writeFileSync(dest, row.content, 'utf-8');
|
||
|
|
written.push(filename);
|
||
|
|
logger.info({ filename, dest }, 'identity file restored');
|
||
|
|
}
|
||
|
|
|
||
|
|
emitStatus('IDENTITY_RESTORE', {
|
||
|
|
STATUS: 'success',
|
||
|
|
WRITTEN: written.join(',') || '(none)',
|
||
|
|
MISSING: missing.join(',') || '(none)',
|
||
|
|
ROWS_FETCHED: String(rows.length),
|
||
|
|
});
|
||
|
|
|
||
|
|
if (written.length > 0) {
|
||
|
|
console.log(` Identity files restored: ${written.join(', ')}`);
|
||
|
|
} else {
|
||
|
|
console.log(' No new identity files to restore (all exist locally or not in Supabase)');
|
||
|
|
}
|
||
|
|
}
|