clawdie-ai/setup/identity-restore.ts
Clawdie AI 61be190bd8 feat: identity-restore installer step + Supabase-backed identity files
- New setup/identity-restore.ts: fetches SOUL.md, IDENTITY.md, USER.md
  from Supabase backups table; non-fatal if Supabase not configured
- Registered in setup/index.ts and setup/install.ts (before verify step)
- Restored SOUL.md, IDENTITY.md, USER.md from prior agent's Supabase backup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---
Build: pass | Tests: pass — Tests  431 passed (431)
2026-03-28 13:45:25 +00:00

168 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)');
}
}