Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/genie-commands/__tests__/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,27 @@ describe('buildEcosystemConfigSource — pm2 ecosystem config locked down', () =
expect(src).toContain(`"${field}": ${value}`);
}
});

test('omits env block when no databaseUrl provided (legacy fallback path)', () => {
// When canonical pgserve isn't available at install time, we omit the
// env block entirely so genie-serve can spawn its embedded pgserve as
// a fallback. Adding `env: {}` would override any DATABASE_URL the
// operator sets in their shell with an empty string.
const src = buildEcosystemConfigSource('/usr/local/bin/genie');
expect(src).not.toContain('"env":');
expect(src).not.toContain('DATABASE_URL');
});

test('bakes DATABASE_URL into env block when canonical pgserve url provided', () => {
// Canonical pgserve detected at install time → bake the URL into the
// pm2-stored env so genie-serve finds it on every restart without
// operators having to set DATABASE_URL in their shell. This is the
// wire that closes the wish's "shared backbone" loop for genie.
const url = 'postgresql://postgres:postgres@localhost:8432/genie';
const src = buildEcosystemConfigSource('/usr/local/bin/genie', url);
expect(src).toContain('"env":');
expect(src).toContain(`"DATABASE_URL": "${url}"`);
});
});

describe('buildPm2StartArgs — CLI invocation', () => {
Expand Down
65 changes: 56 additions & 9 deletions src/genie-commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,34 @@ function tryPgserveInstall(): boolean {
return true;
}

/**
* Read the canonical pgserve port via `pgserve port`. Returns null on any
* failure — caller falls back to the embedded auto-spawn path so a missing
* binary is non-fatal.
*
* Probes via `pgserve port` (not `pgserve --version` — that flag doesn't
* exist in pgserve@^2.1.0 and was the cause of an earlier false-negative
* regression in `omni doctor --fix`).
*/
function tryPgservePort(): number | null {
if (!pgserveIsAvailable()) return null;
const result = spawnSync('pgserve', ['port'], { encoding: 'utf8', timeout: 5000 });
if (result.status !== 0) return null;
const port = Number.parseInt((result.stdout ?? '').trim(), 10);
if (!Number.isFinite(port) || port <= 0 || port > 65535) return null;
return port;
}

/**
* Compose the genie-serve connection string from a canonical pgserve port.
*
* The `genie` database is auto-provisioned by pgserve on first connection,
* matching omni's pattern. Credentials match pgserve@^2.1.0 defaults.
*/
function buildGenieDatabaseUrl(port: number): string {
return `postgresql://postgres:postgres@localhost:${port}/genie`;
}

/**
* Resolve the path to the `genie` binary. We prefer `which genie` so the
* pm2-registered command matches the operator's `$PATH` — surviving bun
Expand Down Expand Up @@ -171,13 +199,19 @@ function getEcosystemConfigPath(): string {
* time; we generate it deterministically from `HARDENED_DEFAULTS` and the
* resolved `geniePath`. Exported for unit tests to assert content shape.
*/
export function buildEcosystemConfigSource(geniePath: string): string {
export function buildEcosystemConfigSource(geniePath: string, databaseUrl?: string): string {
const logs = {
out: join(getLogsDir(), `${PM2_PROCESS_NAME}-out.log`),
error: join(getLogsDir(), `${PM2_PROCESS_NAME}-error.log`),
};
// Use JSON.stringify for safe value escaping inside the generated JS.
const cfg = {
// When `databaseUrl` is supplied (canonical pgserve detected at install
// time), it's baked into the pm2-stored env so genie-serve picks it up
// on every restart without operators having to set DATABASE_URL in their
// shell. Without it, genie-serve falls back to its embedded pgserve
// auto-spawn path — which works, but defeats the canonical-shared-
// backbone goal of the wish.
const cfg: Record<string, unknown> = {
name: PM2_PROCESS_NAME,
script: geniePath,
args: 'serve start --headless --no-tui --no-interactive',
Expand All @@ -199,6 +233,9 @@ export function buildEcosystemConfigSource(geniePath: string): string {
merge_logs: true,
time: true,
};
if (databaseUrl) {
cfg.env = { DATABASE_URL: databaseUrl };
}
return `// Generated by \`genie install\` — do not edit by hand.
// Regenerated on every \`genie install\` invocation.
module.exports = {
Expand All @@ -211,18 +248,18 @@ module.exports = {
* Write the ecosystem config to disk. Returns the absolute path. Idempotent
* (overwrites every install — values are deterministic from defaults).
*/
function writeEcosystemConfig(geniePath: string): string {
function writeEcosystemConfig(geniePath: string, databaseUrl?: string): string {
const path = getEcosystemConfigPath();
ensureLogsDir(); // also ensures ~/.genie exists
writeFileSync(path, buildEcosystemConfigSource(geniePath), { mode: 0o644 });
writeFileSync(path, buildEcosystemConfigSource(geniePath, databaseUrl), { mode: 0o644 });
return path;
}

function buildPm2StartArgs(geniePath: string): string[] {
function buildPm2StartArgs(geniePath: string, databaseUrl?: string): string[] {
// pm2 6 dropped CLI flags like --min-uptime / --max-restarts / etc.
// ecosystem config is the only supported path. We write the config
// first and tell pm2 to start from it.
const configPath = writeEcosystemConfig(geniePath);
const configPath = writeEcosystemConfig(geniePath, databaseUrl);
return ['start', configPath, '--update-env'];
}

Expand All @@ -247,28 +284,38 @@ export async function installCommand(options: InstallOptions = {}): Promise<void

// Step 1 — canonical pgserve. Best-effort; we continue on failure
// because genie can boot its embedded pgserve as a fallback.
let canonicalDatabaseUrl: string | undefined;
if (!options.skipPgserve) {
tryPgserveInstall();
if (tryPgserveInstall()) {
Comment on lines 288 to +289

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Probe canonical pgserve port even when --skip-pgserve is set

The new DATABASE_URL wiring is fully gated by !options.skipPgserve, so users who intentionally pass --skip-pgserve (documented as “don’t run pgserve install first”) never get canonical URL injection even if pgserve is already running and pgserve port would succeed. In that path, genie-serve falls back to embedded pgserve behavior, which defeats the main goal of this change for operator-managed pgserve setups.

Useful? React with 👍 / 👎.

const port = tryPgservePort();
if (port !== null) {
canonicalDatabaseUrl = buildGenieDatabaseUrl(port);
ok(`canonical pgserve detected; genie-serve will connect to ${canonicalDatabaseUrl}`);
}
}
}

// Step 2 — pm2-supervise genie-serve.
const existing = pm2GetProcess(PM2_PROCESS_NAME);
if (existing) {
ok(
`already installed (pm2 process "${PM2_PROCESS_NAME}", status=${existing.pm2_env?.status ?? 'unknown'}). Use \`pm2 restart genie-serve\` to pick up code changes.`,
`already installed (pm2 process "${PM2_PROCESS_NAME}", status=${existing.pm2_env?.status ?? 'unknown'}). Use \`pm2 delete genie-serve && genie install\` to refresh the env (e.g. to pick up a new canonical pgserve URL).`,
);
return;
}

ensureLogsDir();
const geniePath = resolveGenieBinary();
const pm2Args = buildPm2StartArgs(geniePath);
const pm2Args = buildPm2StartArgs(geniePath, canonicalDatabaseUrl);
const result = spawnSync('pm2', pm2Args, { stdio: 'inherit' });
if (result.status !== 0) {
fail(`pm2 start failed (exit ${result.status}). Logs: ${getLogsDir()}/${PM2_PROCESS_NAME}-error.log`);
}

ok(`installed: pm2 process "${PM2_PROCESS_NAME}" (logs: ${getLogsDir()})`);
if (canonicalDatabaseUrl) {
ok(`genie-serve env DATABASE_URL → ${canonicalDatabaseUrl}`);
}
ok('the genie bridge will now survive shell closure and host reboots (after `pm2 save` + `pm2 startup`).');
}

Expand Down
Loading