@hivemagnus/magnus-server 1.0.3 → 1.1.1

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/.env.example CHANGED
@@ -179,3 +179,23 @@ CORS_ORIGIN=https://magnus.example.com:3000
179
179
  # HEALTH_SNAPSHOT_RETENTION_DAYS=7
180
180
  # NOTIFICATION_READ_RETENTION_DAYS=30
181
181
  # NOTIFICATION_MAX_RETENTION_DAYS=90
182
+
183
+ # ═══════════════════════════════════════════════════════════════════════════
184
+ # Upgrades (optional). Each automatic behaviour has its own switch; set them
185
+ # false if you run your own release process. See UPGRADING.md.
186
+ # ═══════════════════════════════════════════════════════════════════════════
187
+
188
+ # Back up (pg_dump) and apply pending migrations at startup, under an advisory lock.
189
+ # MAGNUS_AUTO_MIGRATE=false
190
+ # MAGNUS_AUTO_MIGRATE_BACKUP=true
191
+ # MAGNUS_AUTO_MIGRATE_ALLOW_SLOW=false
192
+ # MAGNUS_BACKUP_DIR=$MAGNUS_HOME/backups
193
+ # MAGNUS_PG_DUMP=pg_dump
194
+ # Daily npm registry check for new releases (console notice and `magnus-server check`).
195
+ # MAGNUS_UPDATE_CHECK=true
196
+ # MAGNUS_UPDATE_REGISTRY=https://registry.npmjs.org
197
+ # Server-driven agent upgrades (console "Upgrade agents", `magnus-server agents upgrade`).
198
+ # MAGNUS_AGENT_UPGRADES=true
199
+ # MAGNUS_AGENT_UPGRADE_CONCURRENCY=5
200
+ # The retired git-checkout self-update endpoints (source installs only).
201
+ # MAGNUS_LEGACY_GIT_SELF_UPDATE=false
package/README.md CHANGED
@@ -59,14 +59,18 @@ under `MAGNUS_HOME`, default `~/.magnus`.
59
59
  ```bash
60
60
  magnus-server check --env-file ~/.magnus/server.env # validate env, reach PostgreSQL and Redis, list integrations
61
61
  magnus-server migrate --env-file ~/.magnus/server.env # apply migrations and base data
62
- magnus-server start --env-file ~/.magnus/server.env # or: start --migrate
62
+ magnus-server start --env-file ~/.magnus/server.env # MAGNUS_AUTO_MIGRATE=true: back up and migrate first
63
63
  ```
64
64
 
65
65
  | Command | Purpose |
66
66
  | --- | --- |
67
- | `start` (default) | Start the API server. `--migrate` applies pending migrations first. |
68
- | `check` | Validate configuration, connect to PostgreSQL and Redis, report pending migrations and enabled integrations. Exits non-zero on a problem. |
67
+ | `start` (default) | Start the API server. With `MAGNUS_AUTO_MIGRATE=true` it first dumps the database and applies pending migrations under an advisory lock. `--migrate` applies them without a dump. |
68
+ | `check` | Validate configuration, connect to PostgreSQL and Redis, report pending migrations, available updates and enabled integrations (`--format json` for tooling). Exits non-zero on a problem. |
69
69
  | `migrate` | Run `prisma migrate deploy` with the packaged schema and migrations, then create the base rows (system actor, sentinel nodes, starter library). Idempotent. |
70
+ | `migrations` | List applied and pending migrations with their impact (`fast`, `slow`, `one-way`). |
71
+ | `backup` | Dump PostgreSQL (`pg_dump -Fc`) into `MAGNUS_BACKUP_DIR` and print the restore command. |
72
+ | `upgrade`, `rollback` | Upgrade or roll back the `magnus init` project this server was installed into (runs `magnus upgrade`; see [UPGRADING.md](../../UPGRADING.md)). |
73
+ | `agents upgrade` | Upgrade agents to the version this server ships (`--to`, `--nodes`, `--dry-run`). |
70
74
  | `env-schema` | Print the documented environment variables (`--format json` or `table`). |
71
75
  | `version`, `help` | Print the version or usage. |
72
76
 
@@ -1 +1,3 @@
1
- {"version":"1.0.3"}
1
+ {
2
+ "version": "1.1.1"
3
+ }
@@ -0,0 +1,111 @@
1
+ /** `magnus-server agents upgrade`: the console's "Upgrade agents" action from a shell. */
2
+ import { UsageError } from './args.js';
3
+ export const AGENTS_USAGE = `Usage: magnus-server agents upgrade [options] [--env-file FILE]
4
+
5
+ Upgrades agents to the version this server ships with (or --to), one canary
6
+ first, then MAGNUS_AGENT_UPGRADE_CONCURRENCY at a time. Each agent installs the
7
+ exact version beside the running one and switches back by itself if the new
8
+ version does not report a heartbeat in time. Stops at the first failure.
9
+
10
+ Options:
11
+ --to X.Y.Z Agent version (default: the one bundled with this server)
12
+ --integrity sha512-… npm integrity of that version (default: read from the registry)
13
+ --nodes a,b Only these nodes (names, host names or ids)
14
+ --dry-run Show which agents would be upgraded, and why others would not
15
+
16
+ Agents are called over TLS. NODE_EXTRA_CA_CERTS from --env-file is honoured, so
17
+ the command trusts the same CA as the service.`;
18
+ export function parseAgentsArgs(args) {
19
+ const [sub, ...rest] = args;
20
+ if (!sub || sub === '--help' || sub === '-h' || sub === 'help')
21
+ return 'help';
22
+ if (sub !== 'upgrade')
23
+ throw new UsageError(`unknown agents subcommand "${sub}"`);
24
+ const options = { dryRun: false };
25
+ for (let index = 0; index < rest.length; index++) {
26
+ const arg = rest[index];
27
+ const [name, inline] = arg.includes('=') ? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)] : [arg, undefined];
28
+ const value = () => {
29
+ const next = inline ?? rest[++index];
30
+ if (!next || next.startsWith('--'))
31
+ throw new UsageError(`${name} requires a value`);
32
+ return next;
33
+ };
34
+ if (name === '--to')
35
+ options.to = value();
36
+ else if (name === '--integrity')
37
+ options.integrity = value();
38
+ else if (name === '--nodes')
39
+ options.nodes = value().split(',').map(node => node.trim()).filter(Boolean);
40
+ else if (name === '--dry-run')
41
+ options.dryRun = true;
42
+ else if (name === '--help' || name === '-h')
43
+ return 'help';
44
+ else
45
+ throw new UsageError(`unknown option ${name}`);
46
+ }
47
+ return options;
48
+ }
49
+ export async function commandAgents(args) {
50
+ const options = parseAgentsArgs(args);
51
+ if (options === 'help') {
52
+ console.log(AGENTS_USAGE);
53
+ return 0;
54
+ }
55
+ const { getDb, disconnectDb } = await import('../db/client.js');
56
+ const { disconnectRedis } = await import('../queue/redis.js');
57
+ const service = await import('../services/agent-upgrade-service.js');
58
+ try {
59
+ let nodeIds;
60
+ if (options.nodes) {
61
+ const rows = await getDb().node.findMany({
62
+ where: { OR: [{ id: { in: options.nodes } }, { name: { in: options.nodes } }, { hostname: { in: options.nodes } }] },
63
+ select: { id: true, name: true, hostname: true },
64
+ });
65
+ const unknown = options.nodes.filter(node => !rows.some(row => row.id === node || row.name === node || row.hostname === node));
66
+ if (unknown.length) {
67
+ console.error(`Unknown node(s): ${unknown.join(', ')}`);
68
+ return 1;
69
+ }
70
+ nodeIds = rows.map(row => row.id);
71
+ }
72
+ const release = await service.resolveAgentRelease({ version: options.to, integrity: options.integrity });
73
+ const candidates = await service.listUpgradeCandidates(release.version, nodeIds);
74
+ console.log(`magnus-agent ${release.version} (${release.integrity.slice(0, 19)}…)`);
75
+ for (const candidate of candidates) {
76
+ console.log(` ${candidate.eligible ? '→' : '·'} ${candidate.name.padEnd(28)} ${candidate.version.padEnd(12)} ${candidate.eligible ? 'will upgrade' : candidate.reason}`);
77
+ }
78
+ const eligible = candidates.filter(candidate => candidate.eligible);
79
+ if (options.dryRun) {
80
+ console.log(`Dry run: ${eligible.length} of ${candidates.length} agent(s) would be upgraded.`);
81
+ return 0;
82
+ }
83
+ if (eligible.length === 0) {
84
+ console.log('Nothing to upgrade.');
85
+ return candidates.some(candidate => candidate.reason !== 'already on this version') ? 1 : 0;
86
+ }
87
+ const deps = service.defaultDeps();
88
+ const seen = new Map();
89
+ const save = deps.save;
90
+ deps.save = async (run) => {
91
+ for (const node of run.nodes) {
92
+ if (seen.get(node.nodeId) === node.state)
93
+ continue;
94
+ seen.set(node.nodeId, node.state);
95
+ if (node.state !== 'pending')
96
+ console.log(` ${node.name}: ${node.state}${node.detail ? ` (${node.detail})` : ''}`);
97
+ }
98
+ await save(run);
99
+ };
100
+ const { done } = await service.startUpgradeRun({ version: release.version, integrity: release.integrity, nodeIds: eligible.map(node => node.nodeId),
101
+ actorId: 'system:magnus-server-cli', deps });
102
+ const run = await done;
103
+ console.log(`${run.status === 'succeeded' ? 'Done' : 'Stopped'}: ${run.message ?? run.status}`);
104
+ return run.status === 'succeeded' ? 0 : 1;
105
+ }
106
+ finally {
107
+ await disconnectDb().catch(() => undefined);
108
+ await disconnectRedis().catch(() => undefined);
109
+ }
110
+ }
111
+ //# sourceMappingURL=agents.js.map
package/dist/cli/args.js CHANGED
@@ -6,20 +6,28 @@ Commands:
6
6
  start Start the API server (default)
7
7
  check Validate configuration and reach PostgreSQL and Redis; list enabled integrations
8
8
  migrate Apply pending migrations (prisma migrate deploy) and base data
9
+ migrations Show applied and pending migrations with their impact notes
10
+ backup Dump PostgreSQL (pg_dump -Fc) into MAGNUS_BACKUP_DIR and print the restore command
11
+ upgrade Upgrade a \`magnus init\` project (runs \`magnus upgrade\`; see --help there)
12
+ rollback Switch a \`magnus init\` project back to the previous version
13
+ agents Agent fleet commands: \`agents upgrade [--to X.Y.Z] [--nodes a,b] [--dry-run]\`
9
14
  env-schema Print the documented environment variables
10
15
  help Show this help
11
16
  version Print the version
12
17
 
13
18
  Options:
14
19
  --env-file <path> Load variables from an env file (repeatable; existing variables win)
15
- --migrate start: apply pending migrations before listening
16
- --format <fmt> env-schema: json (default) or table
20
+ --migrate start: apply pending migrations before listening (MAGNUS_AUTO_MIGRATE adds a backup and lock)
21
+ --format <fmt> env-schema (default json), check and migrations (default table): json or table
22
+ --label <text> backup: label in the dump file name
17
23
  -h, --help Show this help
18
24
  -v, --version Print the version
19
25
  `;
20
- const COMMANDS = new Set(['start', 'check', 'migrate', 'env-schema', 'help', 'version']);
26
+ const COMMANDS = new Set(['start', 'check', 'migrate', 'migrations', 'backup', 'upgrade', 'rollback', 'agents', 'env-schema', 'help', 'version']);
27
+ /** Commands whose remaining arguments belong to another tool. */
28
+ const PASSTHROUGH = new Set(['upgrade', 'rollback', 'agents']);
21
29
  export function parseArgs(argv) {
22
- const options = { command: 'start', envFiles: [], migrate: false, format: 'json' };
30
+ const options = { command: 'start', envFiles: [], migrate: false, format: 'json', formatGiven: false, passthrough: [] };
23
31
  let commandSeen = false;
24
32
  for (let index = 0; index < argv.length; index++) {
25
33
  const arg = argv[index];
@@ -33,6 +41,10 @@ export function parseArgs(argv) {
33
41
  index++;
34
42
  return next;
35
43
  };
44
+ if (commandSeen && PASSTHROUGH.has(options.command) && arg !== '--env-file' && !arg.startsWith('--env-file=')) {
45
+ options.passthrough.push(arg);
46
+ continue;
47
+ }
36
48
  if (arg === '-h' || arg === '--help')
37
49
  options.command = 'help';
38
50
  else if (arg === '-v' || arg === '--version')
@@ -46,7 +58,10 @@ export function parseArgs(argv) {
46
58
  if (format !== 'json' && format !== 'table')
47
59
  throw new UsageError(`unknown format "${format}" (use json or table)`);
48
60
  options.format = format;
61
+ options.formatGiven = true;
49
62
  }
63
+ else if (arg === '--label' || arg.startsWith('--label='))
64
+ options.label = value('--label');
50
65
  else if (arg.startsWith('-'))
51
66
  throw new UsageError(`unknown option ${arg}`);
52
67
  else if (!commandSeen && COMMANDS.has(arg)) {
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Startup auto-migrate (`MAGNUS_AUTO_MIGRATE=true`).
3
+ *
4
+ * Runs before the server listens. A session advisory lock serializes every
5
+ * instance sharing the database, so only one dumps and migrates while the
6
+ * others wait and then find nothing pending. A failed backup stops startup:
7
+ * migrations never run without a dump unless the operator turned the dump off.
8
+ */
9
+ import { join } from 'node:path';
10
+ import pg from 'pg';
11
+ import { backupDatabase, describeMigration, migrationsNeedingAttention, readMigrationState, } from './migrations.js';
12
+ /** Key for `pg_advisory_lock(hashtextextended(key, 0))`; distinct from Prisma's own migrate lock. */
13
+ export const MIGRATE_LOCK_KEY = 'magnus:migrate';
14
+ /** Pure decision, separated for tests. */
15
+ export function decideAutoMigrate(state, settings) {
16
+ if (state.pending.length === 0)
17
+ return { action: 'skip', reason: `${state.packaged.length} migration(s) already applied` };
18
+ const attention = migrationsNeedingAttention(state.pending);
19
+ if (attention.length > 0 && !settings.autoMigrateAllowSlow) {
20
+ return {
21
+ action: 'refuse',
22
+ reason: `pending migration(s) need an operator: ${attention.map(describeMigration).join('; ')}. `
23
+ + 'Run `magnus upgrade` (or `magnus-server migrate` after a backup), or set MAGNUS_AUTO_MIGRATE_ALLOW_SLOW=true.',
24
+ };
25
+ }
26
+ // A fresh database has nothing to back up.
27
+ return { action: 'migrate', backup: settings.autoMigrateBackup && !state.fresh };
28
+ }
29
+ export async function autoMigrate(options) {
30
+ const log = options.log ?? ((line) => console.log(line));
31
+ const client = new pg.Client({ connectionString: options.databaseUrl, connectionTimeoutMillis: 10_000 });
32
+ await client.connect();
33
+ let locked = false;
34
+ try {
35
+ const tried = await client.query('SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS locked', [MIGRATE_LOCK_KEY]);
36
+ if (!tried.rows[0]?.locked) {
37
+ log('[auto-migrate] another instance is migrating; waiting for it to finish');
38
+ await client.query('SELECT pg_advisory_lock(hashtextextended($1, 0))', [MIGRATE_LOCK_KEY]);
39
+ }
40
+ locked = true;
41
+ const state = await readMigrationState(client, options.migrationsDir);
42
+ const decision = decideAutoMigrate(state, options.settings);
43
+ if (decision.action === 'skip') {
44
+ log(`[auto-migrate] nothing to do: ${decision.reason}`);
45
+ return { decision };
46
+ }
47
+ if (decision.action === 'refuse')
48
+ throw new Error(`Auto-migrate refused: ${decision.reason}`);
49
+ log(`[auto-migrate] ${state.pending.length} pending: ${state.pending.map(describeMigration).join(', ')}`);
50
+ let backup;
51
+ if (decision.backup) {
52
+ backup = await backupDatabase({
53
+ databaseUrl: options.databaseUrl,
54
+ backupDir: options.backupDir,
55
+ label: `pre-migrate-${options.version}`,
56
+ client,
57
+ pgDump: options.pgDump,
58
+ }).catch((error) => {
59
+ throw new Error(`Auto-migrate stopped before migrating: the backup failed. ${error instanceof Error ? error.message : String(error)}`);
60
+ });
61
+ log(`[auto-migrate] backup written to ${backup.file}`);
62
+ }
63
+ else if (!state.fresh) {
64
+ log('[auto-migrate] MAGNUS_AUTO_MIGRATE_BACKUP=false: migrating without a dump');
65
+ }
66
+ try {
67
+ await options.migrate();
68
+ }
69
+ catch (error) {
70
+ const restore = backup ? ` To restore the pre-migration state: ${backup.restoreCommand}` : '';
71
+ throw new Error(`${error instanceof Error ? error.message : String(error)}${restore}`);
72
+ }
73
+ log('[auto-migrate] migrations applied');
74
+ return { decision, backup };
75
+ }
76
+ finally {
77
+ if (locked)
78
+ await client.query('SELECT pg_advisory_unlock(hashtextextended($1, 0))', [MIGRATE_LOCK_KEY]).catch(() => undefined);
79
+ await client.end().catch(() => undefined);
80
+ }
81
+ }
82
+ export function defaultBackupDir(env, home) {
83
+ return env['MAGNUS_BACKUP_DIR']?.trim() || join(home, 'backups');
84
+ }
85
+ //# sourceMappingURL=auto-migrate.js.map
@@ -97,6 +97,20 @@ export const ENV_CATALOG = [
97
97
  { name: 'OPENAI_API_KEY', group: 'ai', secret: true, description: 'Optional OpenAI provider key.' },
98
98
  { name: 'OPENROUTER_API_KEY', group: 'ai', secret: true, description: 'Optional OpenRouter provider key.' },
99
99
  { name: 'ANTHROPIC_API_KEY', group: 'ai', secret: true, description: 'Optional Anthropic provider key.' },
100
+ // ── Upgrades ── (every automatic behaviour has its own switch; set them false to run your own release process)
101
+ { name: 'MAGNUS_AUTO_MIGRATE', group: 'upgrades', default: 'false', description: 'true: apply pending migrations at startup, before listening, under an advisory lock and after a pg_dump. `magnus init` projects and the container images set it to true.' },
102
+ { name: 'MAGNUS_AUTO_MIGRATE_BACKUP', group: 'upgrades', default: 'true', description: 'Dump the database (pg_dump -Fc into MAGNUS_BACKUP_DIR) before an automatic migration. A failed dump stops startup. Set false only if you back up another way.' },
103
+ { name: 'MAGNUS_AUTO_MIGRATE_ALLOW_SLOW', group: 'upgrades', default: 'false', description: 'Let auto-migrate apply migrations marked slow or one-way; otherwise startup stops and asks for `magnus upgrade`.' },
104
+ { name: 'MAGNUS_BACKUP_DIR', group: 'upgrades', default: '$MAGNUS_HOME/backups', description: 'Directory for pre-migration database dumps (0700; dumps are 0600).' },
105
+ { name: 'MAGNUS_PG_DUMP', group: 'upgrades', default: 'pg_dump', description: 'pg_dump binary used for pre-migration backups. Its major version must be at least the server\'s.' },
106
+ { name: 'MAGNUS_RUNTIME_FILE', group: 'upgrades', default: '$MAGNUS_HOME/run/server-runtime.json', description: 'File the server writes after it starts listening ({version, pid, port}); upgrade tooling reads it to confirm the new version is serving.' },
107
+ { name: 'MAGNUS_UPDATE_CHECK', group: 'upgrades', default: 'true', description: 'Daily check of the npm registry for newer Magnus releases, shown in the console and `magnus-server check`. Sends no data beyond a registry GET. Set false to opt out.' },
108
+ { name: 'MAGNUS_UPDATE_REGISTRY', group: 'upgrades', default: 'https://registry.npmjs.org', description: 'npm registry used by the update check.' },
109
+ { name: 'MAGNUS_AGENT_UPGRADES', group: 'upgrades', default: 'true', description: 'Allow server-driven agent upgrades (console action, API and `magnus-server agents upgrade`). Agents must also allow it (MAGNUS_AGENT_SELF_UPGRADE).' },
110
+ { name: 'MAGNUS_AGENT_UPGRADE_CONCURRENCY', group: 'upgrades', default: '5', description: 'Agents upgraded in parallel after the first (canary) agent confirms. Maximum 50.' },
111
+ { name: 'MAGNUS_LEGACY_GIT_SELF_UPDATE', group: 'upgrades', default: 'false', description: 'true: keep the retired git-checkout self-update endpoints (/api/v1/self-update/apply and /restart) for source installs. npm installs use `magnus upgrade`.' },
112
+ { name: 'NODE_EXTRA_CA_CERTS', group: 'upgrades', description: 'Extra CA bundle (PEM) trusted when the server calls agents, for agent certificates from a private CA. Node reads it at process start: set it in the unit; `magnus-server agents` also honours it from --env-file.' },
113
+ { name: 'MAGNUS_PROJECT_DIR', group: 'upgrades', description: 'Managed project created by `magnus init` (holds magnus-project.json). `magnus-server upgrade` finds it from the install path when unset.' },
100
114
  // ── Retention ──
101
115
  { name: 'LOG_RETENTION_DAYS', group: 'retention', default: '30', description: 'Days to keep ingested node logs.' },
102
116
  { name: 'HEALTH_SNAPSHOT_RETENTION_DAYS', group: 'retention', default: '7', description: 'Days to keep health snapshots.' },
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Migration state, impact notes and pre-migration backups, shared by
3
+ * `magnus-server migrate`, `check` and startup auto-migrate.
4
+ */
5
+ import { spawn } from 'node:child_process';
6
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
7
+ import { createRequire } from 'node:module';
8
+ import { dirname, join } from 'node:path';
9
+ /** Packaged migration directory names, in apply order. */
10
+ export function packagedMigrations(migrationsDir) {
11
+ if (!existsSync(migrationsDir))
12
+ return [];
13
+ return readdirSync(migrationsDir, { withFileTypes: true })
14
+ .filter(entry => entry.isDirectory() && existsSync(join(migrationsDir, entry.name, 'migration.sql')))
15
+ .map(entry => entry.name)
16
+ .sort();
17
+ }
18
+ /**
19
+ * Each migration may carry `magnus-impact.json` (`{"impact":"slow","note":"…"}`).
20
+ * A migration without one is treated as fast and reversible by rollback.
21
+ */
22
+ export function readMigrationImpact(migrationsDir, name) {
23
+ const path = join(migrationsDir, name, 'magnus-impact.json');
24
+ if (!existsSync(path))
25
+ return { impact: 'fast', note: '' };
26
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
27
+ const impact = raw.impact === 'slow' || raw.impact === 'one-way' ? raw.impact : 'fast';
28
+ return { impact, note: typeof raw.note === 'string' ? raw.note : '' };
29
+ }
30
+ export async function readMigrationState(client, migrationsDir) {
31
+ const packaged = packagedMigrations(migrationsDir);
32
+ const table = await client.query(`SELECT to_regclass('public._prisma_migrations') IS NOT NULL AS exists`);
33
+ const applied = table.rows[0]?.exists
34
+ ? new Set((await client.query('SELECT migration_name FROM _prisma_migrations WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL')).rows
35
+ .map(row => row.migration_name))
36
+ : new Set();
37
+ const pending = packaged.filter(name => !applied.has(name))
38
+ .map(name => ({ name, ...readMigrationImpact(migrationsDir, name) }));
39
+ return { packaged, applied, pending, fresh: applied.size === 0 };
40
+ }
41
+ /** Pending migrations an operator must confirm: slow ones, or ones only a restore can undo. */
42
+ export function migrationsNeedingAttention(pending) {
43
+ return pending.filter(migration => migration.impact !== 'fast');
44
+ }
45
+ export function describeMigration(migration) {
46
+ return `${migration.name} (${migration.impact}${migration.note ? `: ${migration.note}` : ''})`;
47
+ }
48
+ export const runCommand = (command, args, options = {}) => new Promise((resolve, reject) => {
49
+ const child = spawn(command, args, {
50
+ cwd: options.cwd,
51
+ env: options.env ?? process.env,
52
+ stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : ['ignore', 'inherit', 'inherit'],
53
+ });
54
+ let stdout = '';
55
+ let stderr = '';
56
+ child.stdout?.on('data', chunk => { stdout += String(chunk); });
57
+ child.stderr?.on('data', chunk => { stderr += String(chunk); });
58
+ child.on('error', reject);
59
+ child.on('close', code => resolve({ code: code ?? 1, stdout, stderr }));
60
+ });
61
+ /** Absolute path of the prisma CLI installed next to magnus-server. */
62
+ export function prismaCliPath() {
63
+ const require = createRequire(import.meta.url);
64
+ return join(dirname(require.resolve('prisma/package.json')), 'build', 'index.js');
65
+ }
66
+ export function majorOf(versionText) {
67
+ const match = /(\d+)(?:\.\d+)?/.exec(versionText);
68
+ return match ? Number(match[1]) : null;
69
+ }
70
+ /** A restore command that never contains the connection password. */
71
+ export function restoreCommand(file) {
72
+ return `pg_restore --clean --if-exists --no-owner -d "$DATABASE_URL" '${file.replace(/'/g, `'\\''`)}'`;
73
+ }
74
+ /**
75
+ * `pg_dump -Fc` into the backup directory (0700 directory, 0600 dump).
76
+ * Refuses when pg_dump is older than the server, which would produce an
77
+ * unusable or partial dump.
78
+ */
79
+ export async function backupDatabase(options) {
80
+ const run = options.run ?? runCommand;
81
+ const pgDump = options.pgDump ?? 'pg_dump';
82
+ const serverVersion = (await options.client.query('SHOW server_version_num')).rows[0]?.server_version_num ?? '';
83
+ const serverMajor = serverVersion ? Math.floor(Number(serverVersion) / 10_000) : null;
84
+ let client;
85
+ try {
86
+ client = await run(pgDump, ['--version'], { capture: true });
87
+ }
88
+ catch {
89
+ throw new Error(`${pgDump} is not installed. Install the PostgreSQL ${serverMajor ?? ''} client tools (or set MAGNUS_PG_DUMP), or set MAGNUS_AUTO_MIGRATE_BACKUP=false if you back up the database another way.`.replace(' ', ' '));
90
+ }
91
+ const clientMajor = majorOf(client.stdout);
92
+ if (client.code !== 0 || clientMajor === null)
93
+ throw new Error(`${pgDump} --version failed: ${client.stderr.trim() || client.stdout.trim()}`);
94
+ if (serverMajor !== null && clientMajor < serverMajor) {
95
+ throw new Error(`${pgDump} is PostgreSQL ${clientMajor} but the server is PostgreSQL ${serverMajor}; install the PostgreSQL ${serverMajor} client and set MAGNUS_PG_DUMP to it (on Debian and Ubuntu: /usr/lib/postgresql/${serverMajor}/bin/pg_dump).`);
96
+ }
97
+ mkdirSync(options.backupDir, { recursive: true, mode: 0o700 });
98
+ const stamp = (options.now ?? new Date()).toISOString().replace(/[:.]/g, '-');
99
+ const file = join(options.backupDir, `${stamp}-${options.label.replace(/[^A-Za-z0-9._-]+/g, '_')}.dump`);
100
+ const result = await run(pgDump, ['--format=custom', '--no-owner', '--file', file, '--dbname', options.databaseUrl], { capture: true });
101
+ if (result.code !== 0)
102
+ throw new Error(`pg_dump failed (exit ${result.code}): ${result.stderr.trim().split('\n').slice(-3).join(' ')}`);
103
+ if (!existsSync(file) || statSync(file).size === 0)
104
+ throw new Error(`pg_dump wrote no data to ${file}`);
105
+ chmodSync(file, 0o600);
106
+ const metadataFile = `${file}.json`;
107
+ const restore = restoreCommand(file);
108
+ writeFileSync(metadataFile, `${JSON.stringify({
109
+ createdAt: (options.now ?? new Date()).toISOString(),
110
+ label: options.label,
111
+ postgresMajor: serverMajor,
112
+ pgDumpMajor: clientMajor,
113
+ restoreCommand: restore,
114
+ }, null, 2)}\n`, { mode: 0o600 });
115
+ return { file, metadataFile, restoreCommand: restore };
116
+ }
117
+ //# sourceMappingURL=migrations.js.map
@@ -1,8 +1,8 @@
1
1
  /** Reachability and configuration checks behind `magnus-server check`. */
2
- import { existsSync, readdirSync } from 'node:fs';
3
- import { join } from 'node:path';
2
+ import { existsSync } from 'node:fs';
4
3
  import pg from 'pg';
5
4
  import { Redis } from 'ioredis';
5
+ import { describeMigration, migrationsNeedingAttention, readMigrationState } from './migrations.js';
6
6
  function errorMessage(error) {
7
7
  return error instanceof Error ? error.message : String(error);
8
8
  }
@@ -31,20 +31,11 @@ export async function checkDatabase(url, migrationsDir) {
31
31
  }
32
32
  const results = [{ name: 'database', status: 'pass', message: `connected to ${redactUrl(url)}` }];
33
33
  try {
34
- const packaged = existsSync(migrationsDir)
35
- ? readdirSync(migrationsDir, { withFileTypes: true })
36
- .filter(entry => entry.isDirectory() && existsSync(join(migrationsDir, entry.name, 'migration.sql')))
37
- .map(entry => entry.name)
38
- : [];
39
- const table = await client.query(`SELECT to_regclass('public._prisma_migrations') IS NOT NULL AS exists`);
40
- const applied = table.rows[0]?.exists
41
- ? new Set((await client.query('SELECT migration_name FROM _prisma_migrations WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL')).rows
42
- .map(row => row.migration_name))
43
- : new Set();
44
- const pending = packaged.filter(name => !applied.has(name));
34
+ const { packaged, pending } = await readMigrationState(client, migrationsDir);
35
+ const attention = migrationsNeedingAttention(pending);
45
36
  results.push(pending.length === 0
46
37
  ? { name: 'migrations', status: 'pass', message: `${packaged.length} applied` }
47
- : { name: 'migrations', status: 'warn', message: `${pending.length} pending; run \`magnus-server migrate\`` });
38
+ : { name: 'migrations', status: 'warn', message: `${pending.length} pending${attention.length ? ` (needs attention: ${attention.map(describeMigration).join('; ')})` : ''}; run \`magnus-server migrate\`` });
48
39
  }
49
40
  catch (error) {
50
41
  results.push({ name: 'migrations', status: 'warn', message: `cannot read migration state: ${errorMessage(error)}` });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `magnus-server upgrade|rollback`: the upgrade engine lives in
3
+ * @hivemagnus/magnus. Find the managed project this server was installed
4
+ * into and hand the command to the engine installed alongside it.
5
+ */
6
+ import { spawn } from 'node:child_process';
7
+ import { existsSync, realpathSync } from 'node:fs';
8
+ import { dirname, join, resolve } from 'node:path';
9
+ export const PROJECT_FILE = 'magnus-project.json';
10
+ export function findProjectDir(start) {
11
+ let dir = resolve(start);
12
+ for (;;) {
13
+ if (existsSync(join(dir, PROJECT_FILE)))
14
+ return dir;
15
+ const parent = dirname(dir);
16
+ if (parent === dir)
17
+ return null;
18
+ dir = parent;
19
+ }
20
+ }
21
+ export function locateProject(packageRoot, env = process.env) {
22
+ const configured = env['MAGNUS_PROJECT_DIR']?.trim();
23
+ if (configured)
24
+ return existsSync(join(configured, PROJECT_FILE)) ? resolve(configured) : null;
25
+ let real = packageRoot;
26
+ try {
27
+ real = realpathSync(packageRoot);
28
+ }
29
+ catch { /* keep the unresolved path */ }
30
+ return findProjectDir(real);
31
+ }
32
+ export async function delegateToProject(command, args, packageRoot) {
33
+ const dir = locateProject(packageRoot);
34
+ if (!dir) {
35
+ console.error([
36
+ 'This magnus-server was not installed by `magnus init`, so there is no managed project to upgrade.',
37
+ 'Either turn this install into a managed project once:',
38
+ ' npx @hivemagnus/magnus adopt --server-env-file <path to your server env file> [--ui-env-file <path>]',
39
+ 'or upgrade by hand: see UPGRADING.md (https://github.com/ChefTaco/magnus/blob/main/UPGRADING.md).',
40
+ 'Set MAGNUS_PROJECT_DIR if the project lives somewhere else.',
41
+ ].join('\n'));
42
+ return 1;
43
+ }
44
+ const engine = join(dir, 'current', 'node_modules', '@hivemagnus', 'magnus', 'dist', 'cli.js');
45
+ if (!existsSync(engine)) {
46
+ console.error(`The project at ${dir} has no upgrade engine in ./current. Run: npx @hivemagnus/magnus@latest ${command} --dir ${dir}`);
47
+ return 1;
48
+ }
49
+ return new Promise((resolvePromise, reject) => {
50
+ const child = spawn(process.execPath, [engine, command, '--dir', dir, ...args], { stdio: 'inherit' });
51
+ child.on('error', reject);
52
+ child.on('close', code => resolvePromise(code ?? 1));
53
+ });
54
+ }
55
+ //# sourceMappingURL=upgrade.js.map