@jhizzard/termdeck 0.4.3 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/package.json +1 -1
- package/packages/cli/src/index.js +17 -2
- package/packages/cli/src/stack.js +467 -0
- package/packages/client/public/app.js +282 -18
- package/packages/client/public/index.html +1 -1
- package/packages/client/public/style.css +73 -7
- package/packages/server/src/index.js +353 -0
- package/packages/server/src/setup/index.js +2 -1
- package/packages/server/src/setup/migration-runner.js +136 -0
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
const express = require('express');
|
|
5
5
|
const http = require('http');
|
|
6
|
+
const https = require('https');
|
|
6
7
|
const { WebSocketServer } = require('ws');
|
|
7
8
|
const path = require('path');
|
|
8
9
|
const os = require('os');
|
|
@@ -252,6 +253,171 @@ function createServer(config) {
|
|
|
252
253
|
}
|
|
253
254
|
});
|
|
254
255
|
|
|
256
|
+
// POST /api/setup/configure - Sprint 23 T2
|
|
257
|
+
// Accepts pasted credentials from the browser wizard, validates each,
|
|
258
|
+
// then writes ~/.termdeck/secrets.env (chmod 600) and updates
|
|
259
|
+
// ~/.termdeck/config.yaml with rag.enabled: true plus ${VAR} references.
|
|
260
|
+
// Security: the bind guardrail refuses non-loopback binds without auth,
|
|
261
|
+
// so this endpoint only ever responds on 127.0.0.1 in the default config.
|
|
262
|
+
app.post('/api/setup/configure', async (req, res) => {
|
|
263
|
+
const b = req.body || {};
|
|
264
|
+
const supabaseUrl = typeof b.supabaseUrl === 'string' ? b.supabaseUrl.trim() : '';
|
|
265
|
+
const supabaseServiceRoleKey = typeof b.supabaseServiceRoleKey === 'string' ? b.supabaseServiceRoleKey.trim() : '';
|
|
266
|
+
const openaiApiKey = typeof b.openaiApiKey === 'string' ? b.openaiApiKey.trim() : '';
|
|
267
|
+
const anthropicApiKey = typeof b.anthropicApiKey === 'string' ? b.anthropicApiKey.trim() : '';
|
|
268
|
+
const databaseUrl = typeof b.databaseUrl === 'string' ? b.databaseUrl.trim() : '';
|
|
269
|
+
|
|
270
|
+
const missing = [];
|
|
271
|
+
if (!supabaseUrl) missing.push('supabaseUrl');
|
|
272
|
+
if (!supabaseServiceRoleKey) missing.push('supabaseServiceRoleKey');
|
|
273
|
+
if (!openaiApiKey) missing.push('openaiApiKey');
|
|
274
|
+
if (!databaseUrl) missing.push('databaseUrl');
|
|
275
|
+
if (missing.length) {
|
|
276
|
+
return res.status(400).json({
|
|
277
|
+
success: false,
|
|
278
|
+
error: `Missing required credentials: ${missing.join(', ')}`
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!/^https?:\/\//i.test(supabaseUrl)) {
|
|
283
|
+
return res.status(400).json({
|
|
284
|
+
success: false,
|
|
285
|
+
error: 'supabaseUrl must start with http:// or https://'
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const [supaRes, oaiRes, dbRes] = await Promise.all([
|
|
290
|
+
validateSupabase(supabaseUrl, supabaseServiceRoleKey).catch((e) => ({ ok: false, detail: e.message })),
|
|
291
|
+
validateOpenAI(openaiApiKey).catch((e) => ({ ok: false, detail: e.message })),
|
|
292
|
+
validateDatabase(databaseUrl).catch((e) => ({ ok: false, detail: e.message }))
|
|
293
|
+
]);
|
|
294
|
+
const validation = { supabase: supaRes, openai: oaiRes, database: dbRes };
|
|
295
|
+
|
|
296
|
+
const allValid = validation.supabase.ok && validation.openai.ok && validation.database.ok;
|
|
297
|
+
if (!allValid) {
|
|
298
|
+
return res.status(400).json({
|
|
299
|
+
success: false,
|
|
300
|
+
validation,
|
|
301
|
+
error: 'One or more credentials failed validation'
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
if (!fs.existsSync(SETUP_CONFIG_DIR)) {
|
|
307
|
+
fs.mkdirSync(SETUP_CONFIG_DIR, { recursive: true });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const secretsBody = buildSecretsEnv({
|
|
311
|
+
SUPABASE_URL: supabaseUrl,
|
|
312
|
+
SUPABASE_SERVICE_ROLE_KEY: supabaseServiceRoleKey,
|
|
313
|
+
OPENAI_API_KEY: openaiApiKey,
|
|
314
|
+
ANTHROPIC_API_KEY: anthropicApiKey,
|
|
315
|
+
DATABASE_URL: databaseUrl
|
|
316
|
+
});
|
|
317
|
+
const tmpPath = SETUP_SECRETS_PATH + '.tmp';
|
|
318
|
+
fs.writeFileSync(tmpPath, secretsBody, { mode: 0o600 });
|
|
319
|
+
fs.renameSync(tmpPath, SETUP_SECRETS_PATH);
|
|
320
|
+
try { fs.chmodSync(SETUP_SECRETS_PATH, 0o600); } catch (err) {
|
|
321
|
+
console.warn('[setup] chmod 600 on secrets.env failed:', err.message);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
process.env.SUPABASE_URL = supabaseUrl;
|
|
325
|
+
process.env.SUPABASE_SERVICE_ROLE_KEY = supabaseServiceRoleKey;
|
|
326
|
+
process.env.OPENAI_API_KEY = openaiApiKey;
|
|
327
|
+
if (anthropicApiKey) process.env.ANTHROPIC_API_KEY = anthropicApiKey;
|
|
328
|
+
process.env.DATABASE_URL = databaseUrl;
|
|
329
|
+
|
|
330
|
+
updateConfigYamlForRag(config);
|
|
331
|
+
|
|
332
|
+
_setupCache = null;
|
|
333
|
+
_setupCachedAt = 0;
|
|
334
|
+
|
|
335
|
+
console.log('[setup] Credentials saved, RAG enabled via wizard');
|
|
336
|
+
|
|
337
|
+
return res.json({
|
|
338
|
+
success: true,
|
|
339
|
+
tier: 2,
|
|
340
|
+
detail: 'Secrets saved, RAG enabled',
|
|
341
|
+
validation
|
|
342
|
+
});
|
|
343
|
+
} catch (err) {
|
|
344
|
+
console.error('[setup] /api/setup/configure write failed:', err.message);
|
|
345
|
+
return res.status(500).json({
|
|
346
|
+
success: false,
|
|
347
|
+
validation,
|
|
348
|
+
error: `Failed to write config: ${err.message}`
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// POST /api/setup/migrate - auto-run all 7 bootstrap migrations (Sprint 23 T3)
|
|
354
|
+
// Invoked by the browser setup wizard after credentials are saved. Reloads
|
|
355
|
+
// ~/.termdeck/secrets.env so DATABASE_URL picks up T2's just-written value
|
|
356
|
+
// without a server restart, then streams per-migration status to the server
|
|
357
|
+
// log and returns an aggregate result to the client. Idempotent — all seven
|
|
358
|
+
// migration files (6 Mnestra + 1 transcript) are authored with IF NOT EXISTS
|
|
359
|
+
// / CREATE OR REPLACE so re-runs are safe.
|
|
360
|
+
const { migrationRunner: _migrationRunner, dotenv: _dotenv } = require('./setup');
|
|
361
|
+
let _migrateInFlight = false;
|
|
362
|
+
app.post('/api/setup/migrate', async (req, res) => {
|
|
363
|
+
if (_migrateInFlight) {
|
|
364
|
+
return res.status(409).json({ ok: false, error: 'Migration already in progress' });
|
|
365
|
+
}
|
|
366
|
+
_migrateInFlight = true;
|
|
367
|
+
|
|
368
|
+
// Invalidate the /api/setup cache — tier status will shift once migrations land.
|
|
369
|
+
_setupCache = null;
|
|
370
|
+
_setupCachedAt = 0;
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
// Re-read secrets.env so a freshly saved DATABASE_URL is visible without
|
|
374
|
+
// a restart. dotenv-io will not clobber pre-set process.env entries.
|
|
375
|
+
try {
|
|
376
|
+
const secrets = _dotenv.readSecrets();
|
|
377
|
+
for (const [k, v] of Object.entries(secrets)) {
|
|
378
|
+
if (process.env[k] === undefined || process.env[k] === '') {
|
|
379
|
+
process.env[k] = v;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
} catch (_err) { /* optional refresh — fall back to explicit lookup */ }
|
|
383
|
+
|
|
384
|
+
const databaseUrl = _migrationRunner.resolveDatabaseUrl(req.body && req.body.databaseUrl);
|
|
385
|
+
if (!databaseUrl) {
|
|
386
|
+
_migrateInFlight = false;
|
|
387
|
+
return res.status(400).json({
|
|
388
|
+
ok: false,
|
|
389
|
+
error: 'DATABASE_URL not set. Save credentials in the setup wizard first.'
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const total = _migrationRunner.listAllMigrations().length;
|
|
394
|
+
console.log(`[setup] /api/setup/migrate starting (${total} migrations)`);
|
|
395
|
+
|
|
396
|
+
const events = [];
|
|
397
|
+
const result = await _migrationRunner.runAll({
|
|
398
|
+
databaseUrl,
|
|
399
|
+
onProgress: (event) => {
|
|
400
|
+
events.push(event);
|
|
401
|
+
if (event.type === 'step' && event.status === 'running') {
|
|
402
|
+
console.log(`[setup] Migration ${event.index}/${event.total}: ${event.file}...`);
|
|
403
|
+
} else if (event.type === 'step' && event.status === 'done') {
|
|
404
|
+
console.log(`[setup] Migration ${event.index}/${event.total}: ${event.file} ✓ (${event.elapsedMs}ms)`);
|
|
405
|
+
} else if (event.type === 'step' && event.status === 'failed') {
|
|
406
|
+
console.error(`[setup] Migration ${event.index}/${event.total}: ${event.file} ✗ ${event.error}`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
console.log(`[setup] Migrations ${result.ok ? 'complete' : 'halted'} (${result.applied}/${result.total} applied)`);
|
|
412
|
+
res.json({ ok: result.ok, ...result, events });
|
|
413
|
+
} catch (err) {
|
|
414
|
+
console.error('[setup] /api/setup/migrate failed:', err.message);
|
|
415
|
+
res.status(500).json({ ok: false, error: err.message, code: err.code || null });
|
|
416
|
+
} finally {
|
|
417
|
+
_migrateInFlight = false;
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
255
421
|
// GET /api/sessions - list all active sessions
|
|
256
422
|
app.get('/api/sessions', (req, res) => {
|
|
257
423
|
res.json(sessions.getAll());
|
|
@@ -973,6 +1139,193 @@ function createServer(config) {
|
|
|
973
1139
|
return { app, server, wss, sessions, rag, db, transcriptWriter };
|
|
974
1140
|
}
|
|
975
1141
|
|
|
1142
|
+
// ==================== Setup-configure helpers (Sprint 23 T2) ====================
|
|
1143
|
+
// Scoped to module level so they can be unit tested without spinning the server.
|
|
1144
|
+
// Each validator resolves to { ok: boolean, detail: string } — never throws.
|
|
1145
|
+
|
|
1146
|
+
function validateSupabase(url, key) {
|
|
1147
|
+
return new Promise((resolve) => {
|
|
1148
|
+
let parsed;
|
|
1149
|
+
try {
|
|
1150
|
+
parsed = new URL(url);
|
|
1151
|
+
} catch (err) {
|
|
1152
|
+
return resolve({ ok: false, detail: `invalid URL: ${err.message}` });
|
|
1153
|
+
}
|
|
1154
|
+
const client = parsed.protocol === 'http:' ? http : https;
|
|
1155
|
+
const probePath = '/rest/v1/';
|
|
1156
|
+
const req = client.request({
|
|
1157
|
+
protocol: parsed.protocol,
|
|
1158
|
+
hostname: parsed.hostname,
|
|
1159
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
1160
|
+
path: probePath,
|
|
1161
|
+
method: 'GET',
|
|
1162
|
+
headers: {
|
|
1163
|
+
apikey: key,
|
|
1164
|
+
Authorization: `Bearer ${key}`
|
|
1165
|
+
},
|
|
1166
|
+
timeout: 8000
|
|
1167
|
+
}, (r) => {
|
|
1168
|
+
let body = '';
|
|
1169
|
+
r.on('data', (c) => { body += c; });
|
|
1170
|
+
r.on('end', () => {
|
|
1171
|
+
// 200 = PostgREST OpenAPI doc served, 404 = URL reachable but no doc —
|
|
1172
|
+
// both indicate the host + key passed the edge auth check.
|
|
1173
|
+
if (r.statusCode === 200 || r.statusCode === 404) {
|
|
1174
|
+
resolve({ ok: true, detail: `Supabase reachable (HTTP ${r.statusCode})` });
|
|
1175
|
+
} else if (r.statusCode === 401 || r.statusCode === 403) {
|
|
1176
|
+
resolve({ ok: false, detail: `Authentication failed (HTTP ${r.statusCode}) — check service role key` });
|
|
1177
|
+
} else {
|
|
1178
|
+
resolve({ ok: false, detail: `Unexpected response HTTP ${r.statusCode}` });
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1181
|
+
});
|
|
1182
|
+
req.on('error', (err) => resolve({ ok: false, detail: err.message }));
|
|
1183
|
+
req.on('timeout', () => { req.destroy(); resolve({ ok: false, detail: 'timeout after 8s' }); });
|
|
1184
|
+
req.end();
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function validateOpenAI(key) {
|
|
1189
|
+
return new Promise((resolve) => {
|
|
1190
|
+
const payload = JSON.stringify({
|
|
1191
|
+
model: 'text-embedding-3-small',
|
|
1192
|
+
input: 'termdeck setup test'
|
|
1193
|
+
});
|
|
1194
|
+
const req = https.request({
|
|
1195
|
+
hostname: 'api.openai.com',
|
|
1196
|
+
port: 443,
|
|
1197
|
+
path: '/v1/embeddings',
|
|
1198
|
+
method: 'POST',
|
|
1199
|
+
headers: {
|
|
1200
|
+
'Content-Type': 'application/json',
|
|
1201
|
+
'Authorization': `Bearer ${key}`,
|
|
1202
|
+
'Content-Length': Buffer.byteLength(payload)
|
|
1203
|
+
},
|
|
1204
|
+
timeout: 10000
|
|
1205
|
+
}, (r) => {
|
|
1206
|
+
let body = '';
|
|
1207
|
+
r.on('data', (c) => { body += c; });
|
|
1208
|
+
r.on('end', () => {
|
|
1209
|
+
if (r.statusCode === 200) {
|
|
1210
|
+
resolve({ ok: true, detail: 'Embedding test succeeded' });
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
let msg = `HTTP ${r.statusCode}`;
|
|
1214
|
+
try {
|
|
1215
|
+
const parsed = JSON.parse(body);
|
|
1216
|
+
if (parsed && parsed.error && parsed.error.message) msg = parsed.error.message;
|
|
1217
|
+
} catch (_err) { /* ignore body parse */ }
|
|
1218
|
+
resolve({ ok: false, detail: msg });
|
|
1219
|
+
});
|
|
1220
|
+
});
|
|
1221
|
+
req.on('error', (err) => resolve({ ok: false, detail: err.message }));
|
|
1222
|
+
req.on('timeout', () => { req.destroy(); resolve({ ok: false, detail: 'timeout after 10s' }); });
|
|
1223
|
+
req.write(payload);
|
|
1224
|
+
req.end();
|
|
1225
|
+
});
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
async function validateDatabase(connStr) {
|
|
1229
|
+
let pgMod;
|
|
1230
|
+
try { pgMod = require('pg'); } catch (err) { pgMod = null; }
|
|
1231
|
+
if (!pgMod) return { ok: false, detail: 'pg module not installed' };
|
|
1232
|
+
|
|
1233
|
+
const pool = new pgMod.Pool({
|
|
1234
|
+
connectionString: connStr,
|
|
1235
|
+
max: 1,
|
|
1236
|
+
connectionTimeoutMillis: 6000
|
|
1237
|
+
});
|
|
1238
|
+
try {
|
|
1239
|
+
const t0 = Date.now();
|
|
1240
|
+
const r = await pool.query('SELECT 1 AS ok');
|
|
1241
|
+
const ms = Date.now() - t0;
|
|
1242
|
+
if (r.rows[0] && r.rows[0].ok === 1) {
|
|
1243
|
+
return { ok: true, detail: `connected in ${ms}ms` };
|
|
1244
|
+
}
|
|
1245
|
+
return { ok: false, detail: 'unexpected query result' };
|
|
1246
|
+
} catch (err) {
|
|
1247
|
+
return { ok: false, detail: err.message };
|
|
1248
|
+
} finally {
|
|
1249
|
+
await pool.end().catch(() => {});
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
function buildSecretsEnv(vars) {
|
|
1254
|
+
const secretsPath = path.join(os.homedir(), '.termdeck', 'secrets.env');
|
|
1255
|
+
const existing = {};
|
|
1256
|
+
if (fs.existsSync(secretsPath)) {
|
|
1257
|
+
try {
|
|
1258
|
+
const raw = fs.readFileSync(secretsPath, 'utf-8');
|
|
1259
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
1260
|
+
const trimmed = line.trim();
|
|
1261
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
1262
|
+
const eq = trimmed.indexOf('=');
|
|
1263
|
+
if (eq === -1) continue;
|
|
1264
|
+
const k = trimmed.slice(0, eq).trim();
|
|
1265
|
+
if (!k) continue;
|
|
1266
|
+
let v = trimmed.slice(eq + 1).trim();
|
|
1267
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
1268
|
+
v = v.slice(1, -1);
|
|
1269
|
+
}
|
|
1270
|
+
existing[k] = v;
|
|
1271
|
+
}
|
|
1272
|
+
} catch (err) {
|
|
1273
|
+
console.warn('[setup] Could not parse existing secrets.env:', err.message);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
const merged = { ...existing };
|
|
1277
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
1278
|
+
if (v != null && v !== '') merged[k] = v;
|
|
1279
|
+
}
|
|
1280
|
+
const lines = [
|
|
1281
|
+
'# TermDeck secrets — written by setup wizard',
|
|
1282
|
+
'# Do not commit this file.',
|
|
1283
|
+
''
|
|
1284
|
+
];
|
|
1285
|
+
for (const [k, v] of Object.entries(merged)) {
|
|
1286
|
+
const needsQuote = /[\s#"']/.test(v);
|
|
1287
|
+
lines.push(needsQuote ? `${k}="${String(v).replace(/"/g, '\\"')}"` : `${k}=${v}`);
|
|
1288
|
+
}
|
|
1289
|
+
return lines.join('\n') + '\n';
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function updateConfigYamlForRag(runningConfig) {
|
|
1293
|
+
const yaml = require('yaml');
|
|
1294
|
+
const configPath = path.join(os.homedir(), '.termdeck', 'config.yaml');
|
|
1295
|
+
let parsed = {};
|
|
1296
|
+
if (fs.existsSync(configPath)) {
|
|
1297
|
+
try {
|
|
1298
|
+
parsed = yaml.parse(fs.readFileSync(configPath, 'utf-8')) || {};
|
|
1299
|
+
} catch (err) {
|
|
1300
|
+
console.warn('[setup] config.yaml parse failed, starting from empty:', err.message);
|
|
1301
|
+
parsed = {};
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
parsed.rag = parsed.rag || {};
|
|
1305
|
+
parsed.rag.enabled = true;
|
|
1306
|
+
if (!parsed.rag.supabaseUrl) parsed.rag.supabaseUrl = '${SUPABASE_URL}';
|
|
1307
|
+
if (!parsed.rag.supabaseKey) parsed.rag.supabaseKey = '${SUPABASE_SERVICE_ROLE_KEY}';
|
|
1308
|
+
if (!parsed.rag.openaiApiKey) parsed.rag.openaiApiKey = '${OPENAI_API_KEY}';
|
|
1309
|
+
if (!parsed.rag.anthropicApiKey) parsed.rag.anthropicApiKey = '${ANTHROPIC_API_KEY}';
|
|
1310
|
+
|
|
1311
|
+
if (fs.existsSync(configPath)) {
|
|
1312
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1313
|
+
try { fs.copyFileSync(configPath, `${configPath}.${ts}.bak`); } catch (err) {
|
|
1314
|
+
console.warn('[setup] config.yaml backup failed:', err.message);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
fs.writeFileSync(configPath, yaml.stringify(parsed), 'utf-8');
|
|
1318
|
+
|
|
1319
|
+
if (runningConfig) {
|
|
1320
|
+
runningConfig.rag = runningConfig.rag || {};
|
|
1321
|
+
runningConfig.rag.enabled = true;
|
|
1322
|
+
runningConfig.rag.supabaseUrl = process.env.SUPABASE_URL;
|
|
1323
|
+
runningConfig.rag.supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
1324
|
+
runningConfig.rag.openaiApiKey = process.env.OPENAI_API_KEY;
|
|
1325
|
+
if (process.env.ANTHROPIC_API_KEY) runningConfig.rag.anthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
976
1329
|
// Start server
|
|
977
1330
|
if (require.main === module) {
|
|
978
1331
|
// Minimal flag parsing for direct-invocation users (the CLI wrapper has its own).
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Unified migration runner for the setup wizard and `termdeck init --mnestra`.
|
|
2
|
+
//
|
|
3
|
+
// Applies the full 7-migration bootstrap sequence in order:
|
|
4
|
+
// 1-6. Mnestra schema + RPCs (bundled under ./mnestra-migrations)
|
|
5
|
+
// 7. termdeck_transcripts table (repo root: config/transcript-migration.sql)
|
|
6
|
+
//
|
|
7
|
+
// Every migration file is authored with IF NOT EXISTS / CREATE OR REPLACE so
|
|
8
|
+
// re-running the sequence is a no-op on an already-configured database.
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const dotenv = require('./dotenv-io');
|
|
14
|
+
const migrations = require('./migrations');
|
|
15
|
+
const pgRunner = require('./pg-runner');
|
|
16
|
+
|
|
17
|
+
const TRANSCRIPT_MIGRATION = path.resolve(
|
|
18
|
+
__dirname, '..', '..', '..', '..', 'config', 'transcript-migration.sql'
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
// Build the ordered list of absolute migration file paths. The transcript
|
|
22
|
+
// migration lives outside the Mnestra bundle so we tack it on at the end.
|
|
23
|
+
function listAllMigrations() {
|
|
24
|
+
const mnestra = migrations.listMnestraMigrations();
|
|
25
|
+
const files = mnestra.slice();
|
|
26
|
+
if (fs.existsSync(TRANSCRIPT_MIGRATION)) {
|
|
27
|
+
files.push(TRANSCRIPT_MIGRATION);
|
|
28
|
+
}
|
|
29
|
+
return files;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Resolve DATABASE_URL from (in order) an explicit argument, process.env, or
|
|
33
|
+
// a freshly-loaded ~/.termdeck/secrets.env. The wizard path needs the third
|
|
34
|
+
// branch because it may have just written secrets.env without restarting the
|
|
35
|
+
// server, so process.env won't have picked up the new value.
|
|
36
|
+
function resolveDatabaseUrl(explicit) {
|
|
37
|
+
if (explicit) return explicit;
|
|
38
|
+
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
|
|
39
|
+
const secrets = dotenv.readSecrets();
|
|
40
|
+
return secrets.DATABASE_URL || null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Run the full migration sequence. Options:
|
|
44
|
+
// - databaseUrl: override URL (otherwise resolved per the rules above)
|
|
45
|
+
// - onProgress: (event) => void, fires for 'start'|'step'|'done'|'error'
|
|
46
|
+
// Returns { ok, applied, failed, results } where `results` is one entry per
|
|
47
|
+
// migration file: { file, ok, elapsedMs, error? }.
|
|
48
|
+
async function runAll({ databaseUrl, onProgress } = {}) {
|
|
49
|
+
const url = resolveDatabaseUrl(databaseUrl);
|
|
50
|
+
if (!url) {
|
|
51
|
+
const err = new Error(
|
|
52
|
+
'DATABASE_URL not set. Save credentials in the setup wizard (or set it in ~/.termdeck/secrets.env) and try again.'
|
|
53
|
+
);
|
|
54
|
+
err.code = 'NO_DATABASE_URL';
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const files = listAllMigrations();
|
|
59
|
+
if (files.length === 0) {
|
|
60
|
+
const err = new Error('No migration files found. TermDeck install looks corrupted.');
|
|
61
|
+
err.code = 'NO_MIGRATIONS';
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const emit = (event) => { if (typeof onProgress === 'function') onProgress(event); };
|
|
66
|
+
|
|
67
|
+
emit({ type: 'start', total: files.length });
|
|
68
|
+
|
|
69
|
+
let client;
|
|
70
|
+
try {
|
|
71
|
+
client = await pgRunner.connect(url);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
emit({ type: 'error', phase: 'connect', message: err.message });
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const results = [];
|
|
78
|
+
let appliedCount = 0;
|
|
79
|
+
let failedCount = 0;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
for (let i = 0; i < files.length; i++) {
|
|
83
|
+
const file = files[i];
|
|
84
|
+
const base = path.basename(file);
|
|
85
|
+
const stepIdx = i + 1;
|
|
86
|
+
emit({ type: 'step', index: stepIdx, total: files.length, file: base, status: 'running' });
|
|
87
|
+
|
|
88
|
+
const result = await pgRunner.applyFile(client, file);
|
|
89
|
+
const entry = {
|
|
90
|
+
file: base,
|
|
91
|
+
ok: result.ok,
|
|
92
|
+
elapsedMs: result.elapsedMs,
|
|
93
|
+
...(result.error ? { error: result.error } : {})
|
|
94
|
+
};
|
|
95
|
+
results.push(entry);
|
|
96
|
+
|
|
97
|
+
if (result.ok) {
|
|
98
|
+
appliedCount++;
|
|
99
|
+
emit({
|
|
100
|
+
type: 'step',
|
|
101
|
+
index: stepIdx,
|
|
102
|
+
total: files.length,
|
|
103
|
+
file: base,
|
|
104
|
+
status: 'done',
|
|
105
|
+
elapsedMs: result.elapsedMs
|
|
106
|
+
});
|
|
107
|
+
} else {
|
|
108
|
+
failedCount++;
|
|
109
|
+
emit({
|
|
110
|
+
type: 'step',
|
|
111
|
+
index: stepIdx,
|
|
112
|
+
total: files.length,
|
|
113
|
+
file: base,
|
|
114
|
+
status: 'failed',
|
|
115
|
+
error: result.error
|
|
116
|
+
});
|
|
117
|
+
// Stop on first failure — later migrations usually depend on earlier ones.
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} finally {
|
|
122
|
+
try { await client.end(); } catch (_err) { /* ignore */ }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const ok = failedCount === 0 && appliedCount === files.length;
|
|
126
|
+
emit({ type: 'done', ok, applied: appliedCount, failed: failedCount, total: files.length });
|
|
127
|
+
|
|
128
|
+
return { ok, applied: appliedCount, failed: failedCount, total: files.length, results };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = {
|
|
132
|
+
listAllMigrations,
|
|
133
|
+
resolveDatabaseUrl,
|
|
134
|
+
runAll,
|
|
135
|
+
TRANSCRIPT_MIGRATION
|
|
136
|
+
};
|