@automagik/genie 4.260503.5 → 4.260503.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automagik/genie",
3
- "version": "4.260503.5",
3
+ "version": "4.260503.6",
4
4
  "description": "Collaborative terminal toolkit for human + AI workflows. NOTE: the npm distribution is being soft-deprecated — the canonical install is `curl -fsSL https://get.automagik.dev/genie | bash` (cosign + SLSA verified). See https://automagik.dev/genie/security/distribution-sovereignty",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,11 +12,13 @@
12
12
  "plugins/genie/",
13
13
  "scripts/postinstall-tmux.js",
14
14
  "scripts/postinstall-hook-binary.js",
15
+ "scripts/postinstall-migrations.js",
15
16
  "scripts/sec-scan.cjs",
16
17
  "scripts/sec-remediate.cjs",
17
18
  "scripts/sec-fix.cjs",
18
19
  "scripts/tmux/",
19
20
  "src/db/migrations/",
21
+ "src/migrations/",
20
22
  "templates/",
21
23
  "README.md",
22
24
  "LICENSE"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genie",
3
- "version": "4.260503.5",
3
+ "version": "4.260503.6",
4
4
  "description": "Human-AI partnership for Claude Code. Share a terminal, orchestrate workers, evolve together. Brainstorm ideas, turn them into wishes, execute with /work, validate with /review, and ship as one team.",
5
5
  "author": {
6
6
  "name": "Namastex Labs"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genie-plugin",
3
- "version": "4.260503.5",
3
+ "version": "4.260503.6",
4
4
  "private": true,
5
5
  "description": "Runtime dependencies for genie bundled CLIs",
6
6
  "type": "module",
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * genie host-migrations postinstall hook.
4
+ *
5
+ * Runs `genie migrate --quiet` after `bun add -g @automagik/genie@<latest>`
6
+ * so users get host-state self-heal transparently.
7
+ *
8
+ * Behavior:
9
+ * - GENIE_SKIP_MIGRATIONS=1 → exit 0 immediately (CI / containers)
10
+ * - No ~/.genie/ directory → fresh install, exit 0 silently
11
+ * - genie binary not callable yet → exit 0 (other postinstalls may run first)
12
+ * - Otherwise: invoke `genie migrate --quiet` with timeout
13
+ * - Soft-fail: any error logs warning, exits 0 (never breaks bun install)
14
+ *
15
+ * The escape hatch for forced re-runs is `genie migrate` (manual).
16
+ */
17
+
18
+ import { spawnSync } from 'node:child_process';
19
+ import fs from 'node:fs';
20
+ import os from 'node:os';
21
+ import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const __filename = fileURLToPath(import.meta.url);
25
+ const __dirname = path.dirname(__filename);
26
+
27
+ function main() {
28
+ if (process.env.GENIE_SKIP_MIGRATIONS === '1') return;
29
+
30
+ const genieRoot = path.join(os.homedir(), '.genie');
31
+ if (!fs.existsSync(genieRoot)) return; // fresh install
32
+
33
+ // Try locating the genie binary in this package
34
+ const candidate = path.join(__dirname, '..', 'dist', 'genie.js');
35
+ if (!fs.existsSync(candidate)) {
36
+ process.stderr.write(`[genie-postinstall-migrations] dist not built yet at ${candidate}, skipping\n`);
37
+ return;
38
+ }
39
+
40
+ const result = spawnSync(process.execPath, [candidate, 'migrate', '--quiet'], {
41
+ stdio: ['ignore', 'inherit', 'inherit'],
42
+ timeout: 60_000,
43
+ });
44
+
45
+ if (result.error) {
46
+ process.stderr.write(`[genie-postinstall-migrations] WARNING: invocation failed: ${result.error.message}\n`);
47
+ process.stderr.write('[genie-postinstall-migrations] Run `genie migrate` manually to retry.\n');
48
+ return;
49
+ }
50
+ if (result.status !== 0) {
51
+ process.stderr.write(`[genie-postinstall-migrations] WARNING: \`genie migrate\` exited ${result.status}\n`);
52
+ process.stderr.write('[genie-postinstall-migrations] Run `genie migrate` manually to investigate.\n');
53
+ }
54
+ }
55
+
56
+ try {
57
+ main();
58
+ } catch (err) {
59
+ process.stderr.write(`[genie-postinstall-migrations] WARNING: unexpected error: ${err.message}\n`);
60
+ }
61
+ process.exit(0);
@@ -0,0 +1,71 @@
1
+ # genie host-migrations
2
+
3
+ `genie migrate` applies versioned host-state migrations that fix drift
4
+ between current code expectations and persisted host state (pm2 env
5
+ blocks, embedded pgserve fantasmas, config drifts). Same pattern as DB
6
+ migrations (drizzle, alembic) but for HOST state.
7
+
8
+ ## Lifecycle
9
+
10
+ ```
11
+ bun add -g @automagik/genie
12
+ └─ postinstall hook → genie migrate --quiet
13
+ └─ for each step:
14
+ ├─ check(ctx) — needs apply?
15
+ ├─ apply(ctx) — make it so
16
+ ├─ validate(ctx) — confirm it stuck
17
+ └─ record APPLIED in ~/.genie/migrations.json
18
+ ```
19
+
20
+ ## Subcommands
21
+
22
+ | Command | Behavior |
23
+ |---------|----------|
24
+ | `genie migrate` | Apply all pending in alphabetical order |
25
+ | `genie migrate --dry-run` | List pending without executing |
26
+ | `genie migrate --quiet` | Suppress per-step OK lines (used by postinstall) |
27
+ | `genie migrate --status` | Show applied / pending / failed table |
28
+
29
+ ## Tracking store
30
+
31
+ `~/.genie/migrations.json` — atomic-write JSON. Override path via `GENIE_MIGRATIONS_STORE`.
32
+
33
+ ## Writing a new migration
34
+
35
+ 1. Pick the next 3-digit ID — alphabetical = apply order
36
+ 2. Create file `src/migrations/steps/<NNN>-<kebab-case-name>.ts`
37
+ 3. Export the contract:
38
+
39
+ ```typescript
40
+ import type { MigrationContext } from '../discover.js';
41
+ export const id = 'NNN-kebab-case-name';
42
+ export const description = 'One-line operator-facing description';
43
+ export async function check(ctx: MigrationContext): Promise<boolean> { /* return true if needs apply */ }
44
+ export async function apply(ctx: MigrationContext): Promise<void> { /* write side */ }
45
+ export async function validate(ctx: MigrationContext): Promise<void> { /* throw on fail */ }
46
+ ```
47
+
48
+ ## Idempotency requirement
49
+
50
+ Every migration MUST be idempotent at the apply level. Prefer "set X to Y" over "increment X by 1". Re-runs after partial failure must never corrupt state.
51
+
52
+ ## Failure semantics
53
+
54
+ - A migration that throws is recorded as `FAILED` with error message
55
+ - Subsequent `genie migrate` runs RETRY the failed migration
56
+ - `genie migrate --status` surfaces FAILED rows
57
+ - Postinstall hook soft-fails (warn + exit 0) — `bun install` never breaks
58
+
59
+ ## Override / escape hatch
60
+
61
+ | Env var | Effect |
62
+ |---------|--------|
63
+ | `GENIE_SKIP_MIGRATIONS=1` | Postinstall exits 0 without invoking migrate |
64
+ | `GENIE_MIGRATIONS_STORE` | Override `~/.genie/migrations.json` path |
65
+ | `GENIE_KEEP_LEGACY_PG=1` | Migration 002 will not stop the legacy embedded |
66
+
67
+ ## See also
68
+
69
+ - Wish: `.genie/wishes/genie-host-migrations/WISH.md`
70
+ - Sibling: `pgserve/autopg-upgrade-command` (same self-heal pattern, pgserve subsystem)
71
+ - Drizzle DB migrations (separate concern): `src/db/migrations/`
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Smoke tests for the migrations orchestrator.
3
+ *
4
+ * Validates: dry-run lists discoverable steps, status read works, store
5
+ * read/write atomic, no-op on already-applied. Does NOT exercise actual
6
+ * migration apply paths (that would touch pm2/processes — left for
7
+ * integration tests).
8
+ */
9
+
10
+ import { afterEach, beforeEach, expect, test } from 'bun:test';
11
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+
15
+ let tmpStore: string;
16
+ const ORIGINAL_STORE = process.env.GENIE_MIGRATIONS_STORE;
17
+
18
+ beforeEach(() => {
19
+ const dir = mkdtempSync(join(tmpdir(), 'genie-mig-test-'));
20
+ tmpStore = join(dir, 'migrations.json');
21
+ process.env.GENIE_MIGRATIONS_STORE = tmpStore;
22
+ });
23
+
24
+ afterEach(() => {
25
+ if (existsSync(tmpStore)) rmSync(tmpStore, { force: true });
26
+ if (ORIGINAL_STORE) process.env.GENIE_MIGRATIONS_STORE = ORIGINAL_STORE;
27
+ else process.env.GENIE_MIGRATIONS_STORE = undefined;
28
+ });
29
+
30
+ test('store: load empty when file missing', async () => {
31
+ const { loadStore } = await import('../store.js');
32
+ const s = loadStore();
33
+ expect(s.applied).toEqual([]);
34
+ });
35
+
36
+ test('store: recordApplied + load round-trip', async () => {
37
+ const { recordApplied, loadStore } = await import('../store.js');
38
+ recordApplied('999-test', '4.0.0', 'test detail');
39
+ const s = loadStore();
40
+ expect(s.applied.length).toBe(1);
41
+ expect(s.applied[0].id).toBe('999-test');
42
+ expect(s.applied[0].status).toBe('APPLIED');
43
+ expect(s.applied[0].appliedFrom).toBe('4.0.0');
44
+ });
45
+
46
+ test('store: recordFailed then recordApplied replaces FAILED', async () => {
47
+ const { recordFailed, recordApplied, loadStore } = await import('../store.js');
48
+ recordFailed('999-test', '4.0.0', 'first attempt err');
49
+ expect(loadStore().applied[0].status).toBe('FAILED');
50
+ recordApplied('999-test', '4.0.1', 'second attempt ok');
51
+ const s = loadStore();
52
+ expect(s.applied.length).toBe(1);
53
+ expect(s.applied[0].status).toBe('APPLIED');
54
+ });
55
+
56
+ test('store: atomic write prevents partial JSON', async () => {
57
+ const { recordApplied } = await import('../store.js');
58
+ recordApplied('999-atomic', '4.0.0');
59
+ const raw = readFileSync(tmpStore, 'utf8');
60
+ // file should be valid JSON in one piece
61
+ expect(() => JSON.parse(raw)).not.toThrow();
62
+ });
63
+
64
+ test('discover: returns sorted by id (lexical)', async () => {
65
+ const { discoverMigrations } = await import('../discover.js');
66
+ const list = discoverMigrations();
67
+ // current shipped: 001-* and 002-*
68
+ expect(list.length).toBeGreaterThanOrEqual(2);
69
+ for (let i = 1; i < list.length; i++) {
70
+ expect(list[i].id > list[i - 1].id).toBe(true);
71
+ }
72
+ });
73
+
74
+ test('orchestrator: dry-run does not write store for synthetic check=true mig', async () => {
75
+ // We can't easily inject a synthetic migration without filesystem ceremony;
76
+ // instead verify dry-run on the real shipped migrations doesn't record APPLIED.
77
+ const { migrate } = await import('../index.js');
78
+ const _before = existsSync(tmpStore);
79
+ const r = await migrate({ dryRun: true, quiet: true });
80
+ expect(r.results.length).toBeGreaterThanOrEqual(2);
81
+ // Each result should be DRY-RUN, NO-OP, or SKIP — never APPLIED on dry-run
82
+ for (const x of r.results) {
83
+ expect(['DRY-RUN', 'NO-OP', 'SKIP', 'FAIL'].includes(x.status)).toBe(true);
84
+ }
85
+ // If file exists, it should not contain entries from this dry-run
86
+ if (existsSync(tmpStore)) {
87
+ const s = JSON.parse(readFileSync(tmpStore, 'utf8'));
88
+ // dry-run never records APPLIED (NO-OP could record though, that's allowed for short-circuit)
89
+ expect(s.applied.every((r: any) => r.status !== 'APPLIED' || r.detail === 'no-op (check returned false)')).toBe(
90
+ true,
91
+ );
92
+ }
93
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Discover migrations: scan src/migrations/steps/ for files matching
3
+ * `^\d{3}-.+\.(ts|js)$`. Filename = id (sans extension). Alphabetical
4
+ * sort = strict apply order.
5
+ */
6
+
7
+ import { existsSync, readdirSync } from 'node:fs';
8
+ import { dirname, join } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+
13
+ const FILE_PATTERN = /^(\d{3}-[a-z0-9-]+)\.(ts|js)$/;
14
+
15
+ export interface MigrationModule {
16
+ id: string;
17
+ description: string;
18
+ check: (ctx: MigrationContext) => Promise<boolean>;
19
+ apply: (ctx: MigrationContext) => Promise<void>;
20
+ validate: (ctx: MigrationContext) => Promise<void>;
21
+ }
22
+
23
+ export interface MigrationContext {
24
+ log: (msg: string) => void;
25
+ warn: (msg: string) => void;
26
+ dryRun: boolean;
27
+ }
28
+
29
+ export interface DiscoveredMigration {
30
+ id: string;
31
+ filePath: string;
32
+ }
33
+
34
+ export function discoverMigrations(): DiscoveredMigration[] {
35
+ const stepsDir = join(__dirname, 'steps');
36
+ if (!existsSync(stepsDir)) return [];
37
+ const files = readdirSync(stepsDir);
38
+ const matched: DiscoveredMigration[] = [];
39
+ for (const file of files) {
40
+ const m = file.match(FILE_PATTERN);
41
+ if (!m) continue;
42
+ matched.push({ id: m[1], filePath: join(stepsDir, file) });
43
+ }
44
+ matched.sort((a, b) => a.id.localeCompare(b.id));
45
+ return matched;
46
+ }
47
+
48
+ export async function loadMigrationModule(filePath: string): Promise<MigrationModule> {
49
+ const mod = await import(filePath);
50
+ if (typeof mod.id !== 'string' || typeof mod.description !== 'string') {
51
+ throw new Error(`migration ${filePath}: missing id/description exports`);
52
+ }
53
+ if (typeof mod.check !== 'function' || typeof mod.apply !== 'function' || typeof mod.validate !== 'function') {
54
+ throw new Error(`migration ${filePath}: missing check/apply/validate exports`);
55
+ }
56
+ return mod as MigrationModule;
57
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Genie host migrations — versioned, applied-once-per-host.
3
+ *
4
+ * Discover migrations in steps/ → filter pending vs already-applied →
5
+ * run check → apply → validate per step, record to store. Same pattern
6
+ * as DB migrations (drizzle, alembic) but for HOST state (pm2 env,
7
+ * embedded pgserve, config drifts).
8
+ *
9
+ * Auto-runs on `bun add -g @automagik/genie@latest` via postinstall hook
10
+ * (scripts/postinstall-migrations.js). Manual `genie migrate` is the
11
+ * explicit escape hatch.
12
+ *
13
+ * See: .genie/wishes/genie-host-migrations/WISH.md
14
+ */
15
+
16
+ import { readFileSync } from 'node:fs';
17
+ import { dirname, join } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ import { type MigrationContext, type MigrationModule, discoverMigrations, loadMigrationModule } from './discover.js';
21
+ import { type StepResult, runMigration } from './runner.js';
22
+ import { type MigrationRecord, getApplied } from './store.js';
23
+
24
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+
26
+ export interface MigrateOptions {
27
+ quiet?: boolean;
28
+ dryRun?: boolean;
29
+ }
30
+
31
+ export interface MigrateResult {
32
+ ok: boolean;
33
+ results: StepResult[];
34
+ summary: string;
35
+ }
36
+
37
+ function getGenieVersion(): string {
38
+ try {
39
+ // genie cli installed structure: <root>/dist/genie.js + <root>/package.json
40
+ const pkgPath = join(__dirname, '..', '..', 'package.json');
41
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
42
+ return pkg.version || 'unknown';
43
+ } catch {
44
+ return 'unknown';
45
+ }
46
+ }
47
+
48
+ export async function migrate(options: MigrateOptions = {}): Promise<MigrateResult> {
49
+ const { quiet = false, dryRun = false } = options;
50
+ const log = (msg: string) => {
51
+ if (!quiet) process.stderr.write(`${msg}\n`);
52
+ };
53
+ const warn = (msg: string) => process.stderr.write(`${msg}\n`);
54
+ const ctx: MigrationContext = { log, warn, dryRun };
55
+
56
+ const version = getGenieVersion();
57
+ log(`genie host-migrations starting (version=${version}, dryRun=${dryRun})`);
58
+
59
+ const discovered = discoverMigrations();
60
+ if (discovered.length === 0) {
61
+ return { ok: true, results: [], summary: 'no migrations discovered' };
62
+ }
63
+
64
+ const results: StepResult[] = [];
65
+ for (const item of discovered) {
66
+ let mod: MigrationModule;
67
+ try {
68
+ mod = await loadMigrationModule(item.filePath);
69
+ } catch (err) {
70
+ const msg = (err as Error).message;
71
+ warn(`[${item.id}] FAIL during load: ${msg}`);
72
+ results.push({ id: item.id, status: 'FAIL', detail: `load threw: ${msg}` });
73
+ continue;
74
+ }
75
+ const r = await runMigration(mod, ctx, version);
76
+ results.push(r);
77
+ }
78
+
79
+ const failed = results.filter((r) => r.status === 'FAIL');
80
+ const summary = `genie host-migrations complete: ${results.length - failed.length}/${results.length} OK`;
81
+ log(summary);
82
+ if (failed.length > 0) {
83
+ warn(`Failed migrations: ${failed.map((r) => r.id).join(', ')}`);
84
+ warn('Re-run `genie migrate` after addressing the above.');
85
+ return { ok: false, results, summary };
86
+ }
87
+ return { ok: true, results, summary };
88
+ }
89
+
90
+ export interface StatusEntry {
91
+ id: string;
92
+ status: 'APPLIED' | 'PENDING' | 'FAILED';
93
+ appliedAt?: string;
94
+ appliedFrom?: string;
95
+ detail?: string;
96
+ }
97
+
98
+ export function status(): StatusEntry[] {
99
+ const discovered = discoverMigrations();
100
+ const applied = getApplied();
101
+ const out: StatusEntry[] = [];
102
+ for (const item of discovered) {
103
+ const rec: MigrationRecord | undefined = applied.get(item.id);
104
+ if (!rec) {
105
+ out.push({ id: item.id, status: 'PENDING' });
106
+ } else {
107
+ out.push({
108
+ id: item.id,
109
+ status: rec.status === 'APPLIED' ? 'APPLIED' : 'FAILED',
110
+ appliedAt: rec.appliedAt,
111
+ appliedFrom: rec.appliedFrom,
112
+ detail: rec.detail,
113
+ });
114
+ }
115
+ }
116
+ return out;
117
+ }
118
+
119
+ export { discoverMigrations, loadMigrationModule } from './discover.js';
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Per-step runner — enforces the check → apply → validate contract,
3
+ * records outcome to the store, returns structured result.
4
+ */
5
+
6
+ import type { MigrationContext, MigrationModule } from './discover.js';
7
+ import { getApplied, recordApplied, recordFailed } from './store.js';
8
+
9
+ export type StepStatus = 'APPLIED' | 'SKIP' | 'NO-OP' | 'FAIL' | 'DRY-RUN';
10
+
11
+ export interface StepResult {
12
+ id: string;
13
+ status: StepStatus;
14
+ detail: string;
15
+ }
16
+
17
+ export async function runMigration(mod: MigrationModule, ctx: MigrationContext, version: string): Promise<StepResult> {
18
+ const applied = getApplied();
19
+ const prior = applied.get(mod.id);
20
+ if (prior?.status === 'APPLIED') {
21
+ ctx.log(`[${mod.id}] SKIP: already applied at ${prior.appliedAt}`);
22
+ return { id: mod.id, status: 'SKIP', detail: `already applied at ${prior.appliedAt}` };
23
+ }
24
+
25
+ let needsApply: boolean;
26
+ try {
27
+ needsApply = await mod.check(ctx);
28
+ } catch (err) {
29
+ const msg = (err as Error).message;
30
+ ctx.warn(`[${mod.id}] FAIL during check: ${msg}`);
31
+ recordFailed(mod.id, version, `check threw: ${msg}`);
32
+ return { id: mod.id, status: 'FAIL', detail: `check threw: ${msg}` };
33
+ }
34
+
35
+ if (!needsApply) {
36
+ ctx.log(`[${mod.id}] NO-OP: check returned false (host already in target state)`);
37
+ recordApplied(mod.id, version, 'no-op (check returned false)');
38
+ return { id: mod.id, status: 'NO-OP', detail: 'check returned false' };
39
+ }
40
+
41
+ if (ctx.dryRun) {
42
+ ctx.log(`[${mod.id}] DRY-RUN: would apply — ${mod.description}`);
43
+ return { id: mod.id, status: 'DRY-RUN', detail: mod.description };
44
+ }
45
+
46
+ try {
47
+ await mod.apply(ctx);
48
+ } catch (err) {
49
+ const msg = (err as Error).message;
50
+ ctx.warn(`[${mod.id}] FAIL during apply: ${msg}`);
51
+ recordFailed(mod.id, version, `apply threw: ${msg}`);
52
+ return { id: mod.id, status: 'FAIL', detail: `apply threw: ${msg}` };
53
+ }
54
+
55
+ try {
56
+ await mod.validate(ctx);
57
+ } catch (err) {
58
+ const msg = (err as Error).message;
59
+ ctx.warn(`[${mod.id}] FAIL during validate: ${msg}`);
60
+ recordFailed(mod.id, version, `validate threw: ${msg}`);
61
+ return { id: mod.id, status: 'FAIL', detail: `validate threw: ${msg}` };
62
+ }
63
+
64
+ recordApplied(mod.id, version, mod.description);
65
+ ctx.log(`[${mod.id}] APPLIED: ${mod.description}`);
66
+ return { id: mod.id, status: 'APPLIED', detail: mod.description };
67
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Migration 001 — bake DATABASE_URL into pm2 genie-serve env block.
3
+ *
4
+ * Closes the upgrade-path hole left by commit 5567e202 (`fix(install):
5
+ * bake DATABASE_URL env into ecosystem config when canonical pgserve is
6
+ * detected`). That fix only kicks in on fresh `genie install`. Hosts
7
+ * installed before the fix have a pm2 process `genie-serve` with no
8
+ * env block and silently spawn their own embedded pgserve instead of
9
+ * connecting to the canonical one.
10
+ *
11
+ * Detection: pm2 process `genie-serve` exists AND its env lacks
12
+ * `DATABASE_URL` AND canonical pgserve is registered (port 8432
13
+ * reachable).
14
+ *
15
+ * Fix: set DATABASE_URL via `pm2 set genie-serve:DATABASE_URL <url>` then
16
+ * `pm2 restart genie-serve --update-env`. Genie-serve at next boot
17
+ * connects to canonical and stops spawning the legacy embedded.
18
+ */
19
+
20
+ import { execSync } from 'node:child_process';
21
+
22
+ import type { MigrationContext } from '../discover.js';
23
+
24
+ export const id = '001-pm2-env-databaseurl-bake';
25
+ export const description = 'Bake DATABASE_URL into pm2 genie-serve env when canonical pgserve registered';
26
+
27
+ const CANONICAL_PORT = 8432;
28
+
29
+ interface Pm2Process {
30
+ pm_id: number;
31
+ name: string;
32
+ pm2_env?: { env?: Record<string, string> };
33
+ }
34
+
35
+ function pm2ListJson(): Pm2Process[] {
36
+ try {
37
+ const out = execSync('pm2 jlist', { stdio: ['ignore', 'pipe', 'pipe'] }).toString();
38
+ return JSON.parse(out) as Pm2Process[];
39
+ } catch {
40
+ return [];
41
+ }
42
+ }
43
+
44
+ function findGenieServe(): Pm2Process | undefined {
45
+ return pm2ListJson().find((p) => p.name === 'genie-serve');
46
+ }
47
+
48
+ function canonicalPgserveReachable(): boolean {
49
+ try {
50
+ execSync(`pg_isready -h 127.0.0.1 -p ${CANONICAL_PORT}`, { stdio: 'pipe' });
51
+ return true;
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ function buildCanonicalUrl(): string {
58
+ // Mirrors the URL shape baked by `genie install` post-5567e202:
59
+ // postgresql://postgres:postgres@127.0.0.1:<port>/<db>
60
+ // The DB name is per-app fingerprint; genie defaults to 'postgres' when
61
+ // the host hasn't run autopg create-app — install sets the real name.
62
+ // For the migration we use the pgserve discovery URL.
63
+ return `postgresql://postgres:postgres@127.0.0.1:${CANONICAL_PORT}/postgres`;
64
+ }
65
+
66
+ export async function check(_ctx: MigrationContext): Promise<boolean> {
67
+ const proc = findGenieServe();
68
+ if (!proc) return false; // no genie-serve under pm2 → nothing to fix
69
+ const envHas = proc.pm2_env?.env?.DATABASE_URL;
70
+ if (envHas) return false; // already baked
71
+ if (!canonicalPgserveReachable()) return false; // canonical not up → can't safely set URL
72
+ return true;
73
+ }
74
+
75
+ export async function apply(ctx: MigrationContext): Promise<void> {
76
+ const url = buildCanonicalUrl();
77
+ ctx.log(`setting pm2 env genie-serve:DATABASE_URL = ${url}`);
78
+ execSync(`pm2 set genie-serve:DATABASE_URL ${JSON.stringify(url)}`, { stdio: 'pipe' });
79
+ ctx.log('restarting genie-serve --update-env');
80
+ execSync('pm2 restart genie-serve --update-env', { stdio: 'pipe' });
81
+ }
82
+
83
+ export async function validate(_ctx: MigrationContext): Promise<void> {
84
+ const proc = findGenieServe();
85
+ if (!proc) throw new Error('genie-serve missing from pm2 after restart');
86
+ const url = proc.pm2_env?.env?.DATABASE_URL;
87
+ if (!url) throw new Error('DATABASE_URL still not baked into pm2 env after apply');
88
+ if (!url.includes(`:${CANONICAL_PORT}/`)) {
89
+ throw new Error(`DATABASE_URL points to non-canonical port: ${url}`);
90
+ }
91
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Migration 002 — kill legacy embedded pgserve listening on a non-canonical port.
3
+ *
4
+ * Detection: a postgres process owned by the current user is listening on
5
+ * a port other than canonical 8432, AND canonical pgserve responds on
6
+ * 8432, AND no obvious user-intent override (env var GENIE_KEEP_LEGACY_PG=1).
7
+ *
8
+ * Fix: send graceful pg_ctl stop to the legacy process; if that fails,
9
+ * SIGTERM. Migration 001 (must run first) ensures genie-serve no longer
10
+ * spawns it, so it stays dead.
11
+ */
12
+
13
+ import { execSync } from 'node:child_process';
14
+
15
+ import type { MigrationContext } from '../discover.js';
16
+
17
+ export const id = '002-kill-embedded-pgserve-legacy';
18
+ export const description = 'Stop legacy embedded pgserve on non-canonical ports when canonical is healthy';
19
+
20
+ const CANONICAL_PORT = 8432;
21
+
22
+ interface ListeningPg {
23
+ pid: number;
24
+ port: number;
25
+ }
26
+
27
+ function listListeningPgserve(): ListeningPg[] {
28
+ try {
29
+ const out = execSync('ss -tlnp', { stdio: ['ignore', 'pipe', 'pipe'] }).toString();
30
+ const out_lines = out.split('\n');
31
+ const found: ListeningPg[] = [];
32
+ const seen = new Set<number>();
33
+ for (const line of out_lines) {
34
+ // Match "127.0.0.1:<port>" with users:(("postgres",pid=<n>,...))
35
+ const portMatch = line.match(/127\.0\.0\.1:(\d+)\s/);
36
+ const procMatch = line.match(/users:\(\("postgres",pid=(\d+)/);
37
+ if (portMatch && procMatch) {
38
+ const pid = Number.parseInt(procMatch[1], 10);
39
+ const port = Number.parseInt(portMatch[1], 10);
40
+ if (!seen.has(pid)) {
41
+ seen.add(pid);
42
+ found.push({ pid, port });
43
+ }
44
+ }
45
+ }
46
+ return found;
47
+ } catch {
48
+ return [];
49
+ }
50
+ }
51
+
52
+ function canonicalReachable(): boolean {
53
+ try {
54
+ execSync(`pg_isready -h 127.0.0.1 -p ${CANONICAL_PORT}`, { stdio: 'pipe' });
55
+ return true;
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ function findLegacyEmbedded(): ListeningPg | undefined {
62
+ if (process.env.GENIE_KEEP_LEGACY_PG === '1') return undefined;
63
+ if (!canonicalReachable()) return undefined;
64
+ return listListeningPgserve().find((p) => p.port !== CANONICAL_PORT);
65
+ }
66
+
67
+ export async function check(_ctx: MigrationContext): Promise<boolean> {
68
+ return findLegacyEmbedded() !== undefined;
69
+ }
70
+
71
+ export async function apply(ctx: MigrationContext): Promise<void> {
72
+ const target = findLegacyEmbedded();
73
+ if (!target) {
74
+ ctx.log('no legacy embedded found at apply time (race resolved)');
75
+ return;
76
+ }
77
+ ctx.log(`stopping legacy embedded pgserve PID ${target.pid} (port ${target.port})`);
78
+ // Try pg_ctl stop via discovered data dir from the process
79
+ try {
80
+ // Process cmdline to find -D <dataDir>
81
+ const cmdline = execSync(`cat /proc/${target.pid}/cmdline | tr '\\0' ' '`, {
82
+ stdio: ['ignore', 'pipe', 'pipe'],
83
+ }).toString();
84
+ const dataMatch = cmdline.match(/-D\s+(\S+)/);
85
+ if (dataMatch) {
86
+ execSync(`pg_ctl -D ${dataMatch[1]} -m fast stop`, { stdio: 'pipe' });
87
+ ctx.log(`pg_ctl stop OK (data dir: ${dataMatch[1]})`);
88
+ return;
89
+ }
90
+ } catch (err) {
91
+ ctx.warn(`pg_ctl stop failed: ${(err as Error).message} — falling back to SIGTERM`);
92
+ }
93
+ // Fallback: SIGTERM the master process
94
+ try {
95
+ process.kill(target.pid, 'SIGTERM');
96
+ ctx.log(`SIGTERM sent to PID ${target.pid}`);
97
+ } catch (err) {
98
+ throw new Error(`could not stop legacy embedded PID ${target.pid}: ${(err as Error).message}`);
99
+ }
100
+ }
101
+
102
+ export async function validate(_ctx: MigrationContext): Promise<void> {
103
+ // Allow up to 5s for shutdown
104
+ const deadline = Date.now() + 5000;
105
+ while (Date.now() < deadline) {
106
+ if (findLegacyEmbedded() === undefined) return;
107
+ // small sleep
108
+ Bun.sleepSync(200);
109
+ }
110
+ const remaining = findLegacyEmbedded();
111
+ if (remaining) {
112
+ throw new Error(`legacy embedded still listening on port ${remaining.port} (PID ${remaining.pid}) after 5s`);
113
+ }
114
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Migration tracking store — atomic read/write of ~/.genie/migrations.json.
3
+ *
4
+ * Records which migrations have been applied to this host, when, and from
5
+ * which genie cli version. Used by the orchestrator to filter pending vs
6
+ * already-applied. File-based (not PG) because migrations may need to RUN
7
+ * before genie-serve / canonical pgserve are healthy.
8
+ */
9
+
10
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
11
+ import { homedir } from 'node:os';
12
+ import { dirname } from 'node:path';
13
+
14
+ export type MigrationStatus = 'APPLIED' | 'FAILED';
15
+
16
+ export interface MigrationRecord {
17
+ id: string;
18
+ status: MigrationStatus;
19
+ appliedAt: string; // ISO timestamp
20
+ appliedFrom: string; // genie cli version at apply time
21
+ detail?: string; // FAILED reason or APPLIED note
22
+ }
23
+
24
+ interface StoreFile {
25
+ applied: MigrationRecord[];
26
+ }
27
+
28
+ export function getStorePath(): string {
29
+ return process.env.GENIE_MIGRATIONS_STORE || `${homedir()}/.genie/migrations.json`;
30
+ }
31
+
32
+ export function loadStore(): StoreFile {
33
+ const p = getStorePath();
34
+ if (!existsSync(p)) return { applied: [] };
35
+ try {
36
+ const raw = readFileSync(p, 'utf8');
37
+ const parsed = JSON.parse(raw);
38
+ if (!parsed || !Array.isArray(parsed.applied)) return { applied: [] };
39
+ return parsed as StoreFile;
40
+ } catch {
41
+ return { applied: [] };
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Atomic write: tmp file + rename so a crash mid-write never leaves
47
+ * partial JSON on disk.
48
+ */
49
+ export function saveStore(store: StoreFile): void {
50
+ const p = getStorePath();
51
+ mkdirSync(dirname(p), { recursive: true });
52
+ const tmp = `${p}.tmp.${process.pid}.${Date.now()}`;
53
+ writeFileSync(tmp, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o644 });
54
+ renameSync(tmp, p);
55
+ }
56
+
57
+ export function recordApplied(id: string, version: string, detail?: string): void {
58
+ const store = loadStore();
59
+ // Strip any prior FAILED record for this id; record APPLIED authoritatively.
60
+ store.applied = store.applied.filter((r) => r.id !== id);
61
+ store.applied.push({
62
+ id,
63
+ status: 'APPLIED',
64
+ appliedAt: new Date().toISOString(),
65
+ appliedFrom: version,
66
+ detail,
67
+ });
68
+ saveStore(store);
69
+ }
70
+
71
+ export function recordFailed(id: string, version: string, reason: string): void {
72
+ const store = loadStore();
73
+ store.applied = store.applied.filter((r) => r.id !== id);
74
+ store.applied.push({
75
+ id,
76
+ status: 'FAILED',
77
+ appliedAt: new Date().toISOString(),
78
+ appliedFrom: version,
79
+ detail: reason,
80
+ });
81
+ saveStore(store);
82
+ }
83
+
84
+ export function getApplied(): Map<string, MigrationRecord> {
85
+ const store = loadStore();
86
+ return new Map(store.applied.map((r) => [r.id, r]));
87
+ }