@inkobytes/nexus 1.0.0

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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +455 -0
  3. package/bin/nexus.js +108 -0
  4. package/drills/nexus-agent-protocol/README.md +65 -0
  5. package/drills/nexus-agent-protocol/cases/blocked.yaml +20 -0
  6. package/drills/nexus-agent-protocol/cases/claim-before-edit.yaml +16 -0
  7. package/drills/nexus-agent-protocol/cases/current-file-state.yaml +15 -0
  8. package/drills/nexus-agent-protocol/cases/data-boundary-table-header.yaml +21 -0
  9. package/drills/nexus-agent-protocol/cases/data-mutation-delete-rows.yaml +20 -0
  10. package/drills/nexus-agent-protocol/cases/done-claim-adversarial.yaml +18 -0
  11. package/drills/nexus-agent-protocol/cases/ghost-file-claim-loop.yaml +16 -0
  12. package/drills/nexus-agent-protocol/cases/issue-found.yaml +21 -0
  13. package/drills/nexus-agent-protocol/cases/private-path-protection.yaml +23 -0
  14. package/drills/nexus-agent-protocol/cases/queue-is-thin-index.yaml +21 -0
  15. package/drills/nexus-agent-protocol/cases/removal-scope.yaml +26 -0
  16. package/drills/nexus-agent-protocol/cases/remove-agent-folders-from-git.yaml +24 -0
  17. package/drills/nexus-agent-protocol/cases/stale-lock-after-commit.yaml +26 -0
  18. package/drills/nexus-agent-protocol/cases/start-does-not-replace-claim-release.yaml +17 -0
  19. package/drills/nexus-agent-protocol/cases/task-contract.yaml +23 -0
  20. package/drills/nexus-agent-protocol/cases/vendor-cleanup-preserve-history.yaml +24 -0
  21. package/drills/nexus-agent-protocol/cases/wrong-repo-push.yaml +23 -0
  22. package/nexus-dashboard/docs/index.html +183 -0
  23. package/nexus-dashboard/index.html +678 -0
  24. package/nexus-dashboard/logo-nexus.svg +14 -0
  25. package/nexus-dashboard/style.css +1454 -0
  26. package/package.json +42 -0
  27. package/skills/nexus/SKILL.md +62 -0
  28. package/src/commands/checkin.js +19 -0
  29. package/src/commands/checkout.js +33 -0
  30. package/src/commands/chmod.js +93 -0
  31. package/src/commands/claim.js +122 -0
  32. package/src/commands/clean.js +76 -0
  33. package/src/commands/dashboard.js +387 -0
  34. package/src/commands/db.js +256 -0
  35. package/src/commands/doctor.js +958 -0
  36. package/src/commands/drill.js +507 -0
  37. package/src/commands/help.js +8 -0
  38. package/src/commands/init.js +576 -0
  39. package/src/commands/ledger.js +215 -0
  40. package/src/commands/metrics.js +178 -0
  41. package/src/commands/next.js +317 -0
  42. package/src/commands/release.js +107 -0
  43. package/src/commands/soul.js +156 -0
  44. package/src/commands/standup.js +59 -0
  45. package/src/commands/start.js +126 -0
  46. package/src/commands/status.js +109 -0
  47. package/src/hooks/pre-migration-backup.js +35 -0
  48. package/src/lib/agentScopes.js +61 -0
  49. package/src/lib/blackboard.js +90 -0
  50. package/src/lib/config.js +38 -0
  51. package/src/lib/dump.js +63 -0
  52. package/src/lib/git.js +111 -0
  53. package/src/lib/lockManager.js +302 -0
  54. package/src/lib/pathSafety.js +41 -0
  55. package/src/lib/permissions.js +74 -0
@@ -0,0 +1,387 @@
1
+ /**
2
+ * nexus dashboard --serve
3
+ * Read-only local dashboard for human Nexus status checks.
4
+ */
5
+
6
+ import { createServer } from 'http';
7
+ import { existsSync, readFileSync, readdirSync } from 'fs';
8
+ import { spawnSync } from 'child_process';
9
+ import { networkInterfaces } from 'os';
10
+ import { join } from 'path';
11
+ import { getConfig } from '../lib/config.js';
12
+ import { listLocks } from '../lib/lockManager.js';
13
+ import { readLedgerEntries } from './ledger.js';
14
+
15
+ const DEFAULT_PORT = 13787;
16
+ const MAX_PORT_SEARCH = 30;
17
+ const DASHBOARD_HTML_URL = new URL('../../nexus-dashboard/index.html', import.meta.url);
18
+ const DASHBOARD_DOCS_URL = new URL('../../nexus-dashboard/docs/index.html', import.meta.url);
19
+ const DASHBOARD_LOGO_URL = new URL('../../nexus-dashboard/logo-nexus.svg', import.meta.url);
20
+
21
+ export default function dashboard(args) {
22
+ if (args.includes('--snapshot')) {
23
+ console.log(JSON.stringify(buildSnapshot(), null, 2));
24
+ return;
25
+ }
26
+
27
+ serveDashboard(resolveDashboardPort(args));
28
+ }
29
+
30
+ export function buildSnapshot() {
31
+ const config = getConfig();
32
+ const locks = listLocks().map(lock => ({
33
+ target: lock.target,
34
+ age: lock.age,
35
+ stale: lock.age !== null && lock.age >= config.staleThreshold,
36
+ agent: lock.agent || '',
37
+ intent: lock.intent || '',
38
+ subagents: lock.subagents || 0,
39
+ model: lock.model || '',
40
+ thinking: lock.thinking || '',
41
+ verified: lock.verified ?? true,
42
+ trustSource: lock.trustSource || 'unverified',
43
+ }));
44
+
45
+ const presence = {};
46
+ if (existsSync(join(config.root, '.nexus', 'presence'))) {
47
+ for (const file of readdirSync(join(config.root, '.nexus', 'presence'))) {
48
+ try {
49
+ presence[file] = parseInt(readFileSync(join(config.root, '.nexus', 'presence', file), 'utf-8').trim(), 10);
50
+ } catch { /* ignore */ }
51
+ }
52
+ }
53
+
54
+ const queueText = readText(config.queue);
55
+ const standupText = readText(config.standup);
56
+ const reportText = readText(config.report);
57
+ const git = getGitStatus(config.root);
58
+
59
+ return {
60
+ generatedAt: new Date().toISOString(),
61
+ repo: config.root,
62
+ branch: git.branch,
63
+ dirtyFiles: git.files,
64
+ health: getHealth(config),
65
+ locks,
66
+ presence,
67
+ queue: parseQueue(queueText),
68
+ proposed: parseProposed(queueText),
69
+ ledger: readLedgerEntries().reverse(),
70
+ standup: parseStandupEntries(standupText).filter(entry => entry.type.startsWith('@')).slice(-8).reverse(),
71
+ releases: parseReleaseEntries(reportText).slice(-16).reverse(),
72
+ report: sortReportBlocksLatestFirst(reportText),
73
+ };
74
+ }
75
+
76
+ function serveDashboard(port) {
77
+ const clients = new Set();
78
+ const server = createServer((req, res) => {
79
+ const url = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
80
+
81
+ if (url.pathname === '/api/snapshot') {
82
+ sendJson(res, buildSnapshot());
83
+ return;
84
+ }
85
+
86
+ if (url.pathname === '/style.css') {
87
+ res.writeHead(200, { 'Content-Type': 'text/css' });
88
+ res.end(readFileSync(new URL('../../nexus-dashboard/style.css', import.meta.url)));
89
+ return;
90
+ }
91
+
92
+ if (url.pathname === '/logo-nexus.svg') {
93
+ res.writeHead(200, { 'Content-Type': 'image/svg+xml' });
94
+ res.end(readFileSync(DASHBOARD_LOGO_URL));
95
+ return;
96
+ }
97
+
98
+ if (url.pathname === '/docs' || url.pathname === '/docs/') {
99
+ res.writeHead(302, { Location: '/docs/index.html' });
100
+ res.end();
101
+ return;
102
+ }
103
+
104
+ if (url.pathname === '/docs/index.html') {
105
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
106
+ res.end(readFileSync(DASHBOARD_DOCS_URL, 'utf-8'));
107
+ return;
108
+ }
109
+
110
+ if (url.pathname === '/events') {
111
+ res.writeHead(200, {
112
+ 'Content-Type': 'text/event-stream',
113
+ 'Cache-Control': 'no-cache',
114
+ Connection: 'keep-alive',
115
+ });
116
+ res.write('event: update\ndata: ready\n\n');
117
+ clients.add(res);
118
+ req.on('close', () => clients.delete(res));
119
+ return;
120
+ }
121
+
122
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
123
+ res.end(readDashboardHtml());
124
+ });
125
+
126
+ const interval = setInterval(() => {
127
+ for (const client of clients) {
128
+ client.write(`event: update\ndata: ${Date.now()}\n\n`);
129
+ }
130
+ }, 2000);
131
+
132
+ server.on('close', () => clearInterval(interval));
133
+ listenOnAvailablePort(server, port, port === DEFAULT_PORT);
134
+ }
135
+
136
+ export function resolveDashboardPort(args) {
137
+ const index = args.indexOf('--port');
138
+ if (index === -1) return DEFAULT_PORT;
139
+ const value = Number.parseInt(args[index + 1], 10);
140
+ if (!Number.isInteger(value) || value < 1 || value > 65535) {
141
+ throw new Error('Invalid --port value.');
142
+ }
143
+ return value;
144
+ }
145
+
146
+ function listenOnAvailablePort(server, port, canSearch) {
147
+ let attempts = 0;
148
+
149
+ const tryListen = (candidate) => {
150
+ server.once('error', (err) => {
151
+ if (canSearch && err.code === 'EADDRINUSE' && attempts < MAX_PORT_SEARCH) {
152
+ attempts++;
153
+ tryListen(candidate + 1);
154
+ return;
155
+ }
156
+ throw err;
157
+ });
158
+
159
+ server.listen(candidate, '0.0.0.0', () => {
160
+ const moved = candidate !== port ? ` (default ${port} was busy)` : '';
161
+ console.log(`Nexus dashboard listening at http://127.0.0.1:${candidate}${moved}`);
162
+ for (const url of getLanUrls(candidate)) {
163
+ console.log(`Local network: ${url}`);
164
+ }
165
+ console.log('Press Ctrl+C to stop.');
166
+ });
167
+ };
168
+
169
+ tryListen(port);
170
+ }
171
+
172
+ function getLanUrls(port) {
173
+ const urls = [];
174
+ for (const entries of Object.values(networkInterfaces())) {
175
+ for (const entry of entries || []) {
176
+ if (entry.family !== 'IPv4' || entry.internal) continue;
177
+ urls.push(`http://${entry.address}:${port}`);
178
+ }
179
+ }
180
+ return urls;
181
+ }
182
+
183
+ function sendJson(res, body) {
184
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
185
+ res.end(JSON.stringify(body));
186
+ }
187
+
188
+ function readText(path) {
189
+ if (!existsSync(path)) return '';
190
+ return readFileSync(path, 'utf-8');
191
+ }
192
+
193
+ function getGitStatus(root) {
194
+ const result = spawnSync('git', ['status', '--short', '--branch'], {
195
+ cwd: root,
196
+ encoding: 'utf-8',
197
+ stdio: 'pipe',
198
+ });
199
+ if (result.status !== 0) return { branch: 'unknown', files: [] };
200
+
201
+ const lines = result.stdout.trim().split('\n').filter(Boolean);
202
+ const branch = lines[0]?.replace(/^##\s*/, '') || 'unknown';
203
+ return { branch, files: lines.slice(1) };
204
+ }
205
+
206
+ function getHealth(config) {
207
+ const issues = [];
208
+ for (const file of ['_NEXUS_CONSTITUTION.md', '_NEXUS_QUEUE.md', '_NEXUS_STANDUP.md']) {
209
+ if (!existsSync(`${config.root}/${file}`)) issues.push(`Missing ${file}`);
210
+ }
211
+
212
+ const trackedPrivate = spawnSync('git', ['ls-files', 'DECISIONS.md', 'docs-priv'], {
213
+ cwd: config.root,
214
+ encoding: 'utf-8',
215
+ stdio: 'pipe',
216
+ }).stdout.trim().split('\n').filter(Boolean);
217
+ for (const file of trackedPrivate) issues.push(`Tracked private path: ${file}`);
218
+
219
+ return { ok: issues.length === 0, issues };
220
+ }
221
+
222
+ function extractSection(content, heading) {
223
+ const lines = content.split('\n');
224
+ let inSection = false;
225
+ const result = [];
226
+ for (const line of lines) {
227
+ if (line.startsWith('## ')) { inSection = line.trim() === heading; continue; }
228
+ if (inSection) result.push(line);
229
+ }
230
+ return result.join('\n');
231
+ }
232
+
233
+ function parseTasks(content) {
234
+ const tasks = [];
235
+ const lines = content.split('\n');
236
+ let current = null;
237
+
238
+ for (const line of lines) {
239
+ const task = line.match(/^- \[([ x])\] TASK\/([^:]+):\s*(.+)$/);
240
+ if (task) {
241
+ if (current) tasks.push(current);
242
+ current = {
243
+ checked: task[1] === 'x',
244
+ agent: task[2].trim(),
245
+ title: task[3].trim(),
246
+ id: '', epic: '', status: '', depends: '',
247
+ files: '', drills: '', cost: '', autoFlow: '', notes: '',
248
+ review: '', approvedBy: '',
249
+ };
250
+ continue;
251
+ }
252
+ if (!current) continue;
253
+ const field = line.trim().match(/^- ([^:]+):\s*(.+)$/);
254
+ if (!field) continue;
255
+ const key = field[1].toLowerCase();
256
+ if (key === 'id') current.id = field[2];
257
+ if (key === 'epic') current.epic = field[2];
258
+ if (key === 'status') current.status = field[2];
259
+ if (key === 'depends on') current.depends = field[2];
260
+ if (key === 'files') current.files = field[2];
261
+ if (key === 'drills') current.drills = field[2];
262
+ if (key === 'cost') current.cost = field[2];
263
+ if (key === 'auto-flow') current.autoFlow = field[2];
264
+ if (key === 'notes') current.notes = field[2];
265
+ if (key === 'review') current.review = field[2];
266
+ if (key === 'approved by') current.approvedBy = field[2];
267
+ }
268
+ if (current) tasks.push(current);
269
+ return tasks;
270
+ }
271
+
272
+ function parseQueue(content) {
273
+ return parseTasks(extractSection(content, '## Ready Queue'));
274
+ }
275
+
276
+ function parseProposed(content) {
277
+ return parseTasks(extractSection(content, '## Proposed Queue'));
278
+ }
279
+
280
+ function parseStandupEntries(content) {
281
+ const entries = [];
282
+ const lines = content.split('\n');
283
+
284
+ for (let index = 0; index < lines.length; index++) {
285
+ const line = lines[index].trim();
286
+ if (!line || line.startsWith('#') || line.startsWith('---')) continue;
287
+
288
+ const task = line.match(/^- \[([ x])\] TASK\/([^:]+):\s*(.+)$/);
289
+ if (task) {
290
+ const details = {};
291
+ while (index + 1 < lines.length && lines[index + 1].trim().startsWith('- ')) {
292
+ const field = lines[index + 1].trim().match(/^- ([^:]+):\s*(.+)$/);
293
+ if (!field) break;
294
+ details[field[1].toLowerCase()] = field[2];
295
+ index++;
296
+ }
297
+ entries.push({
298
+ type: 'Task',
299
+ title: task[3].trim(),
300
+ meta: [task[2].trim(), details.status, details.files].filter(Boolean).join(' · '),
301
+ });
302
+ continue;
303
+ }
304
+
305
+ const datedAgent = line.match(/^(\d{4}-\d{2}-\d{2}(?:[ T]\d{1,2}:\d{2}(?:\s?(?:AM|PM|am|pm))?)?)\s+(@\S+)(?:\s+\[([A-Z][A-Z0-9_-]*)\])?:\s*(.+)$/);
306
+ if (datedAgent) {
307
+ entries.push({
308
+ type: datedAgent[2],
309
+ title: datedAgent[4],
310
+ meta: [datedAgent[1], datedAgent[3]].filter(Boolean).join(' · '),
311
+ });
312
+ continue;
313
+ }
314
+
315
+ const agent = line.match(/^(@[^:]+):\s*(.+)$/);
316
+ if (agent) {
317
+ entries.push({
318
+ type: agent[1],
319
+ title: agent[2],
320
+ meta: '',
321
+ warning: 'Missing date/time. Use YYYY-MM-DD HH:MM AM/PM @agent [STATUS]: message',
322
+ });
323
+ continue;
324
+ }
325
+
326
+ entries.push({ type: 'Note', title: line.replace(/^- /, ''), meta: '' });
327
+ }
328
+
329
+ return entries;
330
+ }
331
+
332
+ function parseReleaseEntries(content) {
333
+ const entries = [];
334
+ const lines = content.split('\n');
335
+
336
+ for (let index = 0; index < lines.length; index++) {
337
+ const line = lines[index].trim();
338
+ if (!line || line.startsWith('#') || line.startsWith('---')) continue;
339
+
340
+ if (line.startsWith('Commit:') || line.startsWith('- Commit:')) {
341
+ entries.push({ type: 'Commit', title: line.replace(/^-?\s*Commit:\s*/, ''), meta: '' });
342
+ continue;
343
+ }
344
+
345
+ if (line === 'Done claim:') {
346
+ const details = [];
347
+ while (index + 1 < lines.length && lines[index + 1].trim().startsWith('- ')) {
348
+ const detail = lines[index + 1].trim().replace(/^- /, '');
349
+ if (hasReleaseDetailValue(detail)) details.push(detail);
350
+ index++;
351
+ }
352
+ if (details.length > 0) {
353
+ entries.push({ type: 'Done Claim', title: details.join(' · '), meta: '' });
354
+ }
355
+ }
356
+ }
357
+
358
+ return entries;
359
+ }
360
+
361
+ function hasReleaseDetailValue(detail) {
362
+ const colon = detail.indexOf(':');
363
+ if (colon === -1) return detail.trim().length > 0;
364
+ return detail.slice(colon + 1).trim().length > 0;
365
+ }
366
+
367
+ function sortReportBlocksLatestFirst(content) {
368
+ const trimmed = content.trim();
369
+ if (!trimmed) return content;
370
+
371
+ const firstBlock = trimmed.search(/^## \[/m);
372
+ if (firstBlock === -1) return content;
373
+
374
+ const intro = trimmed.slice(0, firstBlock).trimEnd();
375
+ const blockText = trimmed.slice(firstBlock);
376
+ const blocks = blockText
377
+ .split(/\n(?=## \[)/)
378
+ .map((block) => block.trimEnd())
379
+ .filter(Boolean)
380
+ .reverse();
381
+
382
+ return `${intro ? `${intro}\n\n` : ''}${blocks.join('\n\n')}\n`;
383
+ }
384
+
385
+ function readDashboardHtml() {
386
+ return readFileSync(DASHBOARD_HTML_URL, 'utf-8');
387
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * nexus db — database backup, restore, and schedule.
3
+ * Philosophy: total recoverability over prevention.
4
+ *
5
+ * nexus db backup [--auto] Snapshot all detected DBs
6
+ * nexus db list List available backups
7
+ * nexus db restore <stamp> Restore from a backup
8
+ * nexus db schedule Show cron setup instructions
9
+ */
10
+
11
+ import { existsSync, mkdirSync, copyFileSync, readdirSync, statSync, readFileSync, writeFileSync } from 'fs';
12
+ import { join, basename } from 'path';
13
+ import { cwd, env } from 'process';
14
+ import { spawnSync } from 'child_process';
15
+
16
+ const BACKUP_DIR = '.nexus/db-backups';
17
+ const MIGRATION_PATTERNS = /\b(migrate|db:migrate|alembic\s+upgrade|flyway|sequelize.*migrate|knex.*migrate|prisma\s+migrate)\b/i;
18
+
19
+ export { MIGRATION_PATTERNS };
20
+
21
+ function timestamp() {
22
+ return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
23
+ }
24
+
25
+ function detectDatabases(root) {
26
+ const dbs = [];
27
+
28
+ // SQLite: scan for .sqlite and .db files, skip node_modules/.git/.nexus
29
+ const SKIP = new Set(['node_modules', '.git', '.nexus', 'dist', 'build']);
30
+ function scanDir(dir, depth = 0) {
31
+ if (depth > 4) return;
32
+ let entries;
33
+ try { entries = readdirSync(dir); } catch { return; }
34
+ for (const entry of entries) {
35
+ if (SKIP.has(entry)) continue;
36
+ const full = join(dir, entry);
37
+ try {
38
+ const stat = statSync(full);
39
+ if (stat.isDirectory()) { scanDir(full, depth + 1); continue; }
40
+ if (/\.(sqlite|sqlite3|db)$/.test(entry)) {
41
+ dbs.push({ type: 'sqlite', path: full, name: entry });
42
+ }
43
+ } catch { /* skip unreadable */ }
44
+ }
45
+ }
46
+ scanDir(root);
47
+
48
+ // Postgres / MySQL via DATABASE_URL
49
+ const dbUrl = env.DATABASE_URL || env.POSTGRES_URL || env.MYSQL_URL;
50
+ if (dbUrl) {
51
+ const isPostgres = /^postgres(ql)?:\/\//i.test(dbUrl);
52
+ const isMysql = /^mysql:\/\//i.test(dbUrl);
53
+ if (isPostgres) dbs.push({ type: 'postgres', url: dbUrl, name: 'postgres' });
54
+ if (isMysql) dbs.push({ type: 'mysql', url: dbUrl, name: 'mysql' });
55
+ }
56
+
57
+ return dbs;
58
+ }
59
+
60
+ function backupSqlite(db, backupPath) {
61
+ copyFileSync(db.path, join(backupPath, db.name));
62
+ return db.name;
63
+ }
64
+
65
+ function backupPostgres(db, backupPath) {
66
+ const outFile = join(backupPath, 'dump.sql');
67
+ const result = spawnSync('pg_dump', [db.url, '--file', outFile], {
68
+ encoding: 'utf-8', stdio: 'pipe',
69
+ });
70
+ if (result.status !== 0) throw new Error(`pg_dump failed: ${result.stderr}`);
71
+ return 'dump.sql';
72
+ }
73
+
74
+ function backupMysql(db, backupPath) {
75
+ const outFile = join(backupPath, 'dump.sql');
76
+ const result = spawnSync('sh', ['-c', `mysqldump "${db.url}" > "${outFile}"`], {
77
+ encoding: 'utf-8', stdio: 'pipe',
78
+ });
79
+ if (result.status !== 0) throw new Error(`mysqldump failed: ${result.stderr}`);
80
+ return 'dump.sql';
81
+ }
82
+
83
+ export default function db(args) {
84
+ const root = cwd();
85
+ const subcommand = args[0];
86
+
87
+ if (!subcommand || subcommand === '--help') {
88
+ console.log('Usage: nexus db <backup|list|restore|schedule> [options]');
89
+ console.log(' backup [--auto] Snapshot all detected databases');
90
+ console.log(' list Show available backups');
91
+ console.log(' restore <stamp> Restore from a backup stamp');
92
+ console.log(' schedule Show cron setup for daily backups');
93
+ return;
94
+ }
95
+
96
+ if (subcommand === 'backup') return runBackup(root, args.includes('--auto'));
97
+ if (subcommand === 'list') return runList(root);
98
+ if (subcommand === 'restore') return runRestore(root, args[1]);
99
+ if (subcommand === 'schedule') return runSchedule(root);
100
+
101
+ console.error(`Unknown db subcommand: ${subcommand}`);
102
+ process.exit(1);
103
+ }
104
+
105
+ function runBackup(root, auto = false) {
106
+ const dbs = detectDatabases(root);
107
+
108
+ if (!dbs.length) {
109
+ console.log('[nexus db] No databases detected. Checked: *.sqlite, *.db, DATABASE_URL.');
110
+ return;
111
+ }
112
+
113
+ const stamp = timestamp();
114
+ const backupPath = join(root, BACKUP_DIR, stamp);
115
+ mkdirSync(backupPath, { recursive: true });
116
+
117
+ const results = [];
118
+ for (const db of dbs) {
119
+ try {
120
+ let file;
121
+ if (db.type === 'sqlite') file = backupSqlite(db, backupPath);
122
+ if (db.type === 'postgres') file = backupPostgres(db, backupPath);
123
+ if (db.type === 'mysql') file = backupMysql(db, backupPath);
124
+ results.push({ db: db.name, type: db.type, file, ok: true });
125
+ console.log(`[nexus db] ✓ ${db.type} ${db.name} → ${BACKUP_DIR}/${stamp}/${file}`);
126
+ } catch (err) {
127
+ results.push({ db: db.name, type: db.type, ok: false, error: err.message });
128
+ console.error(`[nexus db] ✗ ${db.type} ${db.name}: ${err.message}`);
129
+ }
130
+ }
131
+
132
+ // Write manifest
133
+ writeFileSync(
134
+ join(backupPath, 'manifest.json'),
135
+ JSON.stringify({ stamp, auto, root, dbs: results }, null, 2),
136
+ 'utf-8'
137
+ );
138
+
139
+ const ok = results.every(r => r.ok);
140
+ if (auto) {
141
+ console.log(`[nexus db] Backup complete (${stamp}). Proceeding with migration.`);
142
+ } else {
143
+ console.log(`\nStamp: ${stamp}`);
144
+ console.log(`Restore with: nexus db restore ${stamp}`);
145
+ }
146
+
147
+ return ok;
148
+ }
149
+
150
+ function runList(root) {
151
+ const backupRoot = join(root, BACKUP_DIR);
152
+ if (!existsSync(backupRoot)) {
153
+ console.log('[nexus db] No backups found. Run `nexus db backup` first.');
154
+ return;
155
+ }
156
+
157
+ const entries = readdirSync(backupRoot)
158
+ .filter(e => statSync(join(backupRoot, e)).isDirectory())
159
+ .sort()
160
+ .reverse();
161
+
162
+ if (!entries.length) {
163
+ console.log('[nexus db] No backups found.');
164
+ return;
165
+ }
166
+
167
+ console.log('\nNexus DB Backups\n');
168
+ console.log(' ' + 'STAMP'.padEnd(22) + 'DATABASES');
169
+ console.log(' ' + '─'.repeat(50));
170
+
171
+ for (const stamp of entries) {
172
+ const manifestPath = join(backupRoot, stamp, 'manifest.json');
173
+ let dbs = '(no manifest)';
174
+ if (existsSync(manifestPath)) {
175
+ try {
176
+ const m = JSON.parse(readFileSync(manifestPath, 'utf-8'));
177
+ dbs = m.dbs.map(d => `${d.type}:${d.db}`).join(', ');
178
+ if (m.auto) dbs += ' [auto]';
179
+ } catch { /* ignore */ }
180
+ }
181
+ console.log(' ' + stamp.padEnd(22) + dbs);
182
+ }
183
+ console.log('');
184
+ }
185
+
186
+ function runRestore(root, stamp) {
187
+ if (!stamp) {
188
+ console.error('Usage: nexus db restore <stamp>');
189
+ console.error('Run `nexus db list` to see available stamps.');
190
+ process.exit(1);
191
+ }
192
+
193
+ const backupPath = join(root, BACKUP_DIR, stamp);
194
+ if (!existsSync(backupPath)) {
195
+ console.error(`[nexus db] Backup not found: ${stamp}`);
196
+ console.error('Run `nexus db list` to see available stamps.');
197
+ process.exit(1);
198
+ }
199
+
200
+ const manifestPath = join(backupPath, 'manifest.json');
201
+ if (!existsSync(manifestPath)) {
202
+ console.error(`[nexus db] No manifest in backup ${stamp}. Cannot restore safely.`);
203
+ process.exit(1);
204
+ }
205
+
206
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
207
+ console.log(`\n[nexus db] Restoring from ${stamp}...\n`);
208
+
209
+ for (const entry of manifest.dbs) {
210
+ if (!entry.ok) {
211
+ console.log(` skip ${entry.db} — backup was incomplete`);
212
+ continue;
213
+ }
214
+ const backupFile = join(backupPath, entry.file);
215
+
216
+ if (entry.type === 'sqlite') {
217
+ // Find original path from manifest root
218
+ const originalPath = manifest.dbs.find(d => d.db === entry.db)
219
+ ? join(manifest.root, entry.db) : null;
220
+ if (originalPath && existsSync(backupFile)) {
221
+ copyFileSync(backupFile, originalPath);
222
+ console.log(` ✓ sqlite ${entry.db} restored`);
223
+ } else {
224
+ console.log(` ✗ sqlite ${entry.db}: original path not found — copy manually from ${backupFile}`);
225
+ }
226
+ }
227
+
228
+ if (entry.type === 'postgres') {
229
+ const url = env.DATABASE_URL || env.POSTGRES_URL;
230
+ if (!url) { console.log(` ✗ postgres: DATABASE_URL not set`); continue; }
231
+ const result = spawnSync('psql', [url, '--file', backupFile], { stdio: 'inherit' });
232
+ console.log(result.status === 0 ? ` ✓ postgres restored` : ` ✗ postgres restore failed`);
233
+ }
234
+
235
+ if (entry.type === 'mysql') {
236
+ const url = env.DATABASE_URL || env.MYSQL_URL;
237
+ if (!url) { console.log(` ✗ mysql: DATABASE_URL not set`); continue; }
238
+ const result = spawnSync('sh', ['-c', `mysql "${url}" < "${backupFile}"`], { stdio: 'inherit' });
239
+ console.log(result.status === 0 ? ` ✓ mysql restored` : ` ✗ mysql restore failed`);
240
+ }
241
+ }
242
+ console.log('');
243
+ }
244
+
245
+ function runSchedule(root) {
246
+ const nexusBin = 'nexus db backup';
247
+ console.log('\nnexus db schedule — daily backup setup\n');
248
+ console.log('Add one of these to your crontab (`crontab -e`):\n');
249
+ console.log(` # Daily at 2am`);
250
+ console.log(` 0 2 * * * cd ${root} && ${nexusBin} >> .nexus/db-backups/cron.log 2>&1\n`);
251
+ console.log('Or for a project-specific .env-aware setup:');
252
+ console.log(` 0 2 * * * cd ${root} && source .env && ${nexusBin} >> .nexus/db-backups/cron.log 2>&1\n`);
253
+ console.log('Backups are stored in: .nexus/db-backups/');
254
+ console.log('Restore with: nexus db restore <stamp>');
255
+ console.log('');
256
+ }