diff --git a/package-lock.json b/package-lock.json index 175b5cfa..4f9e382f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@solidxai/solidctl", - "version": "0.1.45", + "version": "0.1.46-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@solidxai/solidctl", - "version": "0.1.45", + "version": "0.1.46-beta.1", "license": "BUSL-1.1", "dependencies": { "@electric-sql/pglite": "^0.5.3", diff --git a/package.json b/package.json index fff477de..57a88220 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@solidxai/solidctl", - "version": "0.1.45", + "version": "0.1.46-beta.1", "description": "", "author": "", "private": false, diff --git a/src/commands/agent-helper.ts b/src/commands/agent-helper.ts index 26369721..a8c41df5 100644 --- a/src/commands/agent-helper.ts +++ b/src/commands/agent-helper.ts @@ -2,6 +2,10 @@ import { spawnSync } from 'child_process'; import fs from 'fs'; import path from 'path'; import os from 'os'; +import semver from 'semver'; +import chalk from 'chalk'; +import inquirer from 'inquirer'; +import ora from 'ora'; const AGENT_PACKAGE = 'solidx-ai-agent'; const AGENT_UI_PACKAGE = '@solidxai/agent-ui'; @@ -12,6 +16,25 @@ const AGENT_UI_DIR = path.join(os.homedir(), '.solidx', 'agent-ui'); const AGENT_UI_PKG_DIR = path.join(AGENT_UI_DIR, 'node_modules', '@solidxai', 'agent-ui'); const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +type ExitMode = 'exit' | 'throw'; + +export class AgentCommandExit extends Error { + constructor( + public readonly exitCode: number, + message?: string, + ) { + super(message ?? `Command exited with code ${exitCode}`); + this.name = 'AgentCommandExit'; + } +} + +function exitCommand(exitCode: number, exitMode: ExitMode, message?: string): never { + if (exitMode === 'throw') { + throw new AgentCommandExit(exitCode, message); + } + process.exit(exitCode); +} + /** * Check if a command exists in PATH and returns exit code 0. */ @@ -183,6 +206,201 @@ function ensureVenv(pythonCmd: string, uvCmd: string | null): boolean { return result.status === 0; } +/** + * Resolve the Python interpreter that backs a given solidx-agent binary. + * + * The agent exposes no `--version` flag, so we instead ask the interpreter + * that owns it for the installed `solidx-ai-agent` package metadata. Works for + * the managed venv (`~/.solidx/venv/bin/`), local installs (`/.venv/bin/`), + * and PATH binaries (via the entrypoint script's shebang). + */ +function resolveAgentPython(binary: string): string | null { + const dir = path.dirname(binary); + const pyInDir = path.join(dir, process.platform === 'win32' ? 'python.exe' : 'python'); + if (fs.existsSync(pyInDir)) return pyInDir; + + try { + const content = fs.readFileSync(binary, 'utf-8'); + const firstLine = content.split(/\r?\n/)[0] || ''; + if (firstLine.startsWith('#!')) { + const interp = firstLine.slice(2).trim().split(/\s+/)[0]; + if (interp && fs.existsSync(interp)) return interp; + } + } catch { + // ignore unreadable shebangs + } + + return null; +} + +/** + * Probe the installed solidx-ai-agent package version backing a solidx-agent + * binary by asking its owning Python interpreter for the package metadata. + * + * Read-only: no install is triggered. Returns the trimmed version, or + * 'not installed' / 'unknown' when the binary is missing or the probe fails. + */ +export function getAgentVersion(agentCommand?: string): string { + const binary = agentCommand || findAgentBinary(); + if (!binary) return 'not installed'; + const pythonBin = resolveAgentPython(binary); + if (!pythonBin) return 'unknown'; + const result = spawnSync( + pythonBin, + ['-c', "import importlib.metadata as m; print(m.version('solidx-ai-agent'))"], + { stdio: 'pipe' }, + ); + if (result.status !== 0) return 'unknown'; + return (result.stdout?.toString() || '').trim() || 'unknown'; +} + +/** + * Print `solidx-ai-agent v` to stdout. + */ +export function printAgentVersion(agentCommand?: string): void { + console.log(`solidx-ai-agent v${getAgentVersion(agentCommand)}`); +} + +const PYPI_TIMEOUT_MS = 10_000; + +/** + * Determine which agent track to use based on the installed solidctl version. + * Any solidctl prerelease (alpha/beta/rc) maps to the agent beta track; a + * stable solidctl maps to the agent stable track. + */ +export function getSolidctlAgentTrack(): 'stable' | 'beta' { + // agent-helper.ts lives in src/commands/ → dist/commands/, so the consuming + // package.json is two levels up (repo root in dev, package root when installed). + const packageJsonPath = path.resolve(__dirname, '..', '..', 'package.json'); + try { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')) as { version?: string }; + const version = packageJson.version || '0.0.0'; + return semver.prerelease(version) ? 'beta' : 'stable'; + } catch { + return 'stable'; + } +} + +/** + * Normalize a PEP 440 agent version into a semver-parseable string. + * `0.2.3b1` → `0.2.3-b1`; everything else passes through unchanged. + */ +function toSemver(version: string): string { + return version.replace(/^(\d+\.\d+\.\d+)b(\d+)$/, '$1-b$2'); +} + +/** + * Ask PyPI for the highest published solidx-ai-agent release on the given track. + * Returns null on any network/parse failure (swallowed silently by callers). + */ +async function getLatestAgentRelease(track: 'stable' | 'beta'): Promise { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), PYPI_TIMEOUT_MS); + const response = await fetch('https://pypi.org/pypi/solidx-ai-agent/json', { + signal: controller.signal, + }); + clearTimeout(timer); + if (!response.ok) return null; + const data = (await response.json()) as { releases?: Record }; + const candidates = Object.keys(data.releases || {}) + .map((v) => toSemver(v)) + .filter((v) => semver.valid(v) && (track === 'beta' ? !!semver.prerelease(v) : !semver.prerelease(v))); + if (candidates.length === 0) return null; + candidates.sort((a, b) => semver.rcompare(a, b)); + return candidates[0]; + } catch { + return null; + } +} + +/** + * Check PyPI for a newer solidx-ai-agent in the same track as the installed + * solidctl, and prompt the user to upgrade into the managed ~/.solidx/venv. + * + * Skipped entirely when `isLocal` is true (the user is developing against a + * local agent checkout). Silent on any network/parse failure. Mirrors the UX + * of the solidctl self-update check in src/version-check.ts. + */ +export async function checkAgentUpdate(opts: { isLocal: boolean; exitMode?: ExitMode }): Promise { + if (opts.isLocal) return; + const currentRaw = getAgentVersion(); + const current = toSemver(currentRaw); + if (!semver.valid(current)) return; // 'not installed' / 'unknown' / unparseable + + const track = getSolidctlAgentTrack(); + const spinner = ora(`Checking for ${track} agent updates...`).start(); + const latestRaw = await getLatestAgentRelease(track); + spinner.stop(); + if (!latestRaw) return; + const latest = toSemver(latestRaw); + if (!semver.valid(latest)) return; + if (!semver.gt(latest, current)) return; + + console.log( + chalk.yellow( + `\n⚠️ A newer version of ${AGENT_PACKAGE} is available: ${latestRaw} (you have ${currentRaw})`, + ), + ); + + let upgrade = false; + try { + const answer = await inquirer.prompt<{ upgrade: boolean }>([ + { + type: 'confirm', + name: 'upgrade', + message: `Would you like to upgrade to ${latestRaw}?`, + default: false, + }, + ]); + upgrade = answer.upgrade; + } catch { + // Non-interactive (no TTY) or prompt cancelled → don't upgrade + } + + const preFlag = track === 'beta' ? ['--pre'] : []; + const manual = `pip install ${preFlag.length ? '--pre ' : ''}--upgrade ${AGENT_PACKAGE}`; + + if (!upgrade) { + console.log(chalk.dim(` You can upgrade later with: ${manual}\n`)); + return; + } + + const uvCmd = findUv(); + const venvPython = path.join(VENV_BIN, process.platform === 'win32' ? 'python.exe' : 'python'); + const pipBin = path.join(VENV_BIN, process.platform === 'win32' ? 'pip.exe' : 'pip'); + + console.log(chalk.cyan(`\n▶ Upgrading ${AGENT_PACKAGE}...\n`)); + let ok = false; + if (uvCmd) { + const result = spawnSync( + uvCmd, + ['pip', 'install', ...preFlag, '--upgrade', AGENT_PACKAGE, '--python', venvPython], + { stdio: 'inherit' }, + ); + ok = result.status === 0; + if (!ok) console.warn('⚠ uv install failed, falling back to pip'); + } + if (!ok) { + const pipCmd = fs.existsSync(pipBin) ? pipBin : venvPython; + const pipArgs = fs.existsSync(pipBin) + ? ['install', ...preFlag, '--upgrade', AGENT_PACKAGE] + : ['-m', 'pip', 'install', ...preFlag, '--upgrade', AGENT_PACKAGE]; + const result = spawnSync(pipCmd, pipArgs, { + stdio: 'inherit', + shell: process.platform === 'win32', + }); + ok = result.status === 0; + } + + if (ok) { + console.log(chalk.green(`\n✔ Upgraded to ${latestRaw}. Please re-run your command.\n`)); + exitCommand(0, opts.exitMode ?? 'exit', `Upgraded to ${latestRaw}. Please re-run your command.`); + } else { + console.error(chalk.red(`\n❌ Upgrade failed. You can manually run: ${manual}\n`)); + } +} + /** * Install solidx-ai-agent into the dedicated venv. * Prefers uv (faster) but falls back to pip. @@ -197,14 +415,20 @@ function installAgent(pythonCmd: string, uvCmd: string | null): boolean { process.platform === 'win32' ? 'pip.exe' : 'pip', ); - console.log(`📦 Installing ${AGENT_PACKAGE}...`); + // Install the agent on the same track as the installed solidctl: a beta + // solidctl pulls the latest beta from PyPI (via --pre), a stable solidctl + // pulls the latest stable. + const track = getSolidctlAgentTrack(); + const preFlag = track === 'beta' ? ['--pre'] : []; + + console.log(`📦 Installing ${AGENT_PACKAGE}${track === 'beta' ? ' (beta)' : ''}...`); if (uvCmd) { // Pass the venv's Python interpreter to uv so it targets the correct environment. // Using --python is the correct uv flag (not the pip binary). const result = spawnSync( uvCmd, - ['pip', 'install', AGENT_PACKAGE, '--python', venvPython], + ['pip', 'install', ...preFlag, AGENT_PACKAGE, '--python', venvPython], { stdio: 'inherit', }, @@ -217,8 +441,8 @@ function installAgent(pythonCmd: string, uvCmd: string | null): boolean { // which works as long as the venv itself is functional. const pipCmd = fs.existsSync(pipBin) ? pipBin : venvPython; const pipArgs = fs.existsSync(pipBin) - ? ['install', AGENT_PACKAGE] - : ['-m', 'pip', 'install', AGENT_PACKAGE]; + ? ['install', ...preFlag, AGENT_PACKAGE] + : ['-m', 'pip', 'install', ...preFlag, AGENT_PACKAGE]; const result = spawnSync(pipCmd, pipArgs, { stdio: 'inherit', @@ -236,7 +460,7 @@ function installAgent(pythonCmd: string, uvCmd: string | null): boolean { * * Exits with an error if installation is impossible. */ -export function ensureAgentInstalled(): string { +export function ensureAgentInstalled(exitMode: ExitMode = 'exit'): string { const existing = findAgentBinary(); if (existing) { return existing; @@ -251,7 +475,7 @@ export function ensureAgentInstalled(): string { ' Install Python 3.11+ and try again, or manually run:\n' + ` pip install ${AGENT_PACKAGE}`, ); - process.exit(1); + exitCommand(1, exitMode, 'Python 3.11+ is required but not found.'); } const uvCmd = findUv(); @@ -265,7 +489,7 @@ export function ensureAgentInstalled(): string { ? '\n On Ubuntu/Debian you may also need: sudo apt-get install python3-venv' : ''), ); - process.exit(1); + exitCommand(1, exitMode, `Failed to create virtual environment at ${VENV_DIR}`); } if (!installAgent(pythonCmd, uvCmd)) { @@ -274,7 +498,7 @@ export function ensureAgentInstalled(): string { ' Try installing manually:\n' + ` ${VENV_BIN}/pip install ${AGENT_PACKAGE}`, ); - process.exit(1); + exitCommand(1, exitMode, `Failed to install ${AGENT_PACKAGE}`); } // Verify the binary is now available @@ -284,7 +508,7 @@ export function ensureAgentInstalled(): string { ' The package may not have installed correctly. Try:\n' + ` ${VENV_BIN}/pip install --force-reinstall ${AGENT_PACKAGE}`, ); - process.exit(1); + exitCommand(1, exitMode, `Package installed but solidx-agent binary not found at ${VENV_AGENT_BIN}`); } console.log(`✔ ${AGENT_PACKAGE} installed successfully`); @@ -296,7 +520,7 @@ export function ensureAgentInstalled(): string { * Checks SOLIDX_AI_AGENT_PATH env var; errors if not set or not valid. * Returns the directory path if a valid agent repo is found. */ -function resolveLocalAgentSource(): string { +function resolveLocalAgentSource(exitMode: ExitMode = 'exit'): string { const envPath = process.env.SOLIDX_AI_AGENT_PATH; if (!envPath) { @@ -304,7 +528,7 @@ function resolveLocalAgentSource(): string { '❌ --local requires SOLIDX_AI_AGENT_PATH to be set.\n' + ' Example: SOLIDX_AI_AGENT_PATH=/path/to/solidx-ai-agent solidctl agent start --local', ); - process.exit(1); + exitCommand(1, exitMode, '--local requires SOLIDX_AI_AGENT_PATH to be set.'); } const resolved = path.resolve(envPath); @@ -313,7 +537,7 @@ function resolveLocalAgentSource(): string { `❌ SOLIDX_AI_AGENT_PATH points to an invalid directory: ${resolved}\n` + ' Expected pyproject.toml to exist in that directory.', ); - process.exit(1); + exitCommand(1, exitMode, `SOLIDX_AI_AGENT_PATH points to an invalid directory: ${resolved}`); } return resolved; @@ -356,8 +580,8 @@ function installLocalAgent(agentSourceDir: string): boolean { * * Exits with an error if local source is not found or install fails. */ -export function ensureAgentInstalledLocal(): string { - const agentSourceDir = resolveLocalAgentSource(); +export function ensureAgentInstalledLocal(exitMode: ExitMode = 'exit'): string { + const agentSourceDir = resolveLocalAgentSource(exitMode); const localVenvDir = path.join(agentSourceDir, '.venv'); const localVenvBin = path.join(localVenvDir, process.platform === 'win32' ? 'Scripts' : 'bin'); const localAgentBin = path.join(localVenvBin, process.platform === 'win32' ? 'solidx-agent.exe' : 'solidx-agent'); @@ -376,7 +600,7 @@ export function ensureAgentInstalledLocal(): string { '❌ Python 3.11+ is required but not found.\n' + ' Install Python 3.11+ and try again.', ); - process.exit(1); + exitCommand(1, exitMode, 'Python 3.11+ is required but not found.'); } const uvCmd = findUv(); @@ -423,7 +647,7 @@ export function ensureAgentInstalledLocal(): string { ? '\n On Ubuntu/Debian you may also need: sudo apt-get install python3-venv' : ''), ); - process.exit(1); + exitCommand(1, exitMode, `Failed to create virtual environment at ${localVenvDir}`); } } } @@ -434,7 +658,7 @@ export function ensureAgentInstalledLocal(): string { ' Try installing manually:\n' + ` ${localPythonBin} -m pip install -e ${agentSourceDir}[full]`, ); - process.exit(1); + exitCommand(1, exitMode, `Failed to install local ${AGENT_PACKAGE}`); } // Verify the binary is now available @@ -444,7 +668,7 @@ export function ensureAgentInstalledLocal(): string { ' The package may not have installed correctly. Try:\n' + ` ${localPythonBin} -m pip install --force-reinstall -e ${agentSourceDir}[full]`, ); - process.exit(1); + exitCommand(1, exitMode, `Package installed but solidx-agent binary not found at ${localAgentBin}`); } console.log(`✔ Local ${AGENT_PACKAGE} installed successfully`); diff --git a/src/commands/agent.command.ts b/src/commands/agent.command.ts index f8f1b1e3..64089f3e 100644 --- a/src/commands/agent.command.ts +++ b/src/commands/agent.command.ts @@ -4,8 +4,8 @@ import chalk from 'chalk'; import path from 'path'; import readline from 'readline'; import { config as loadDotenv } from 'dotenv'; -import { validateProjectRoot } from '../helper'; -import { ensureAgentInstalled, ensureAgentInstalledLocal, ensureAgentUIInstalled } from './agent-helper'; +import { normalizeDatabaseUrl, validateProjectRoot } from '../helper'; +import { checkAgentUpdate, ensureAgentInstalled, ensureAgentInstalledLocal, ensureAgentUIInstalled, getAgentVersion, printAgentVersion } from './agent-helper'; type AgentServiceName = 'agent' | 'ui'; @@ -35,7 +35,7 @@ type AgentStartOptions = { * SQL Server using mssql+pyodbc. Otherwise it defaults to PostgreSQL. */ function resolveDatabaseUrl(): string | undefined { - if (process.env.DATABASE_URL) return process.env.DATABASE_URL; + if (process.env.DATABASE_URL) return normalizeDatabaseUrl(process.env.DATABASE_URL); const host = process.env.DEFAULT_DATABASE_HOST; const port = process.env.DEFAULT_DATABASE_PORT; @@ -506,6 +506,18 @@ export function registerAgentCommand(program: Command) { .command('agent') .description('SolidX AI Agent — start the server or run a single task'); + agent + .option('--version', 'Print the solidx-ai-agent version and exit') + .action(async (options: { version?: boolean }) => { + if (options.version) { + await checkAgentUpdate({ isLocal: false }); + const agentCommand = ensureAgentInstalled(); + console.log(`solidx-ai-agent v${getAgentVersion(agentCommand)}`); + process.exit(0); + } + agent.help(); + }); + agent .command('start') .description('Start the AI agent server and chat UI in a single supervisor') @@ -515,6 +527,8 @@ export function registerAgentCommand(program: Command) { .option('--plain', 'Disable interactive controls and print merged logs only') .option('--local', 'Install agent from local source (pip install -e .[full]) instead of PyPI') .action(async (options: { port: string; host: string; logLevel: string; plain?: boolean; local?: boolean }) => { + await checkAgentUpdate({ isLocal: Boolean(options.local) }); + validateProjectRoot(); const projectRoot = process.cwd(); @@ -524,6 +538,8 @@ export function registerAgentCommand(program: Command) { const agentCommand = options.local ? ensureAgentInstalledLocal() : ensureAgentInstalled(); const agentUiDir = ensureAgentUIInstalled(); + printAgentVersion(agentCommand); + const supervisor = new AgentSupervisor(projectRoot, agentCommand, agentUiDir, options); await supervisor.start(); }); @@ -535,7 +551,9 @@ export function registerAgentCommand(program: Command) { .option('-m, --mode ', 'Tool mode: native or mcp') .option('-l, --log-level ', 'Logging level', 'INFO') .option('--local', 'Install agent from local source (pip install -e .[full]) instead of PyPI') - .action((task, options) => { + .action(async (task, options) => { + await checkAgentUpdate({ isLocal: Boolean(options.local) }); + validateProjectRoot(); const projectRoot = process.cwd(); @@ -560,6 +578,7 @@ export function registerAgentCommand(program: Command) { // Match the MCP startup flow: do dependency resolution first so Ubuntu // users do not see a "running" banner when Python/bootstrap fails. const agentCommand = options.local ? ensureAgentInstalledLocal() : ensureAgentInstalled(); + printAgentVersion(agentCommand); const bridgedKeys = ['DATABASE_URL', 'SOLIDX_PROJECT_ROOT', 'BASE_URL', 'APP_ENCRYPTION_KEY']; const bridged = bridgedKeys.filter((k) => env[k]); const missing = bridgedKeys.filter((k) => !env[k]); diff --git a/src/commands/create-app/create-app.command.ts b/src/commands/create-app/create-app.command.ts index 8c117c02..31639990 100644 --- a/src/commands/create-app/create-app.command.ts +++ b/src/commands/create-app/create-app.command.ts @@ -38,6 +38,12 @@ import { startEmbeddedServer, stopEmbeddedServer, } from '../../db/embedded'; +import { getCurrentVersion, getDistTag } from '../../version-check'; + +function detectSolidxVersion(): string { + const tag = getDistTag(getCurrentVersion()); + return tag === 'beta' ? 'beta' : 'stable'; +} function buildAnswersFromOptions(options: Record): SetupAnswers { @@ -68,7 +74,7 @@ function buildAnswersFromOptions(options: Record', `Auto-sync DB schema: Yes or No (default: ${SETUP_DEFAULTS.solidApiDatabaseSynchronize})`) .option('--db-exists ', `Database already exists: Yes or No (default: ${SETUP_DEFAULTS.databaseExists})`) .option('--ui-port ', `Frontend port (default: ${SETUP_DEFAULTS.solidUiPort})`) - .option('--beta', 'Use beta release channel for @solidxai/* packages (default: stable)') + .option('--beta', 'Force beta release channel for @solidxai/* packages (default: auto-detected from installed solidctl)') .action(async (options) => { try { const showLogs: boolean = options.verbose || false; @@ -138,6 +144,7 @@ export function registerCreateAppCommand(program: Command) { } else { console.log(chalk.cyan("Hello, Let's setup your SolidX project!")); answers = await inquirer.prompt(setupQuestions); + answers.solidxVersion = detectSolidxVersion(); if (answers.databaseMode === 'embedded') { answers = applyEmbeddedDatabaseDefaults(answers); } diff --git a/src/commands/create-app/helpers.ts b/src/commands/create-app/helpers.ts index 4ceb4f6a..69235b96 100644 --- a/src/commands/create-app/helpers.ts +++ b/src/commands/create-app/helpers.ts @@ -272,7 +272,11 @@ export function getBackendEnvConfig(answers: SetupAnswers, properAppName: string answers.solidApiDatabaseSynchronize === 'Yes' ? 'true' : 'false', DEFAULT_DATABASE_LOGGING: 'false', // Marks an embedded PGlite project so solidctl manages the database lifecycle. - ...(isEmbedded ? { DEFAULT_DATABASE_DRIVER: 'pglite' } : {}), + // The PGlite socket server exposes a single backend, so app-side pooling must + // stay at one connection to avoid protocol-level prepared-statement collisions. + ...(isEmbedded + ? { DEFAULT_DATABASE_DRIVER: 'pglite', DEFAULT_DATABASE_POOL_MAX: '1' } + : {}), }, 'IAM Registration': { IAM_PASSWORD_LESS_REGISTRATION: 'false', diff --git a/src/commands/create-app/setup-questions.ts b/src/commands/create-app/setup-questions.ts index 926ec9c6..68d321ca 100644 --- a/src/commands/create-app/setup-questions.ts +++ b/src/commands/create-app/setup-questions.ts @@ -45,13 +45,6 @@ export const setupQuestions = [ message: 'What is the name of your project?', default: 'my-solid-app', }, - { - type: 'list', - name: 'solidxVersion', - message: 'Which SolidX version would you like to use?', - choices: ['stable', 'beta'], - default: 'stable', - }, { type: 'input', name: 'solidApiPort', diff --git a/src/commands/local-upgrade.command.ts b/src/commands/local-upgrade.command.ts index b936b95c..e1ad6450 100644 --- a/src/commands/local-upgrade.command.ts +++ b/src/commands/local-upgrade.command.ts @@ -151,6 +151,8 @@ Required environment variables: if (doUi) { console.log('\n=== solid-ui → solid-ui ==='); packAndInstall(process.env.SOLID_UI_PATH!, './solid-ui', showNpmLogs); + console.log('\n▶ Syncing UI resources (postinstall)'); + exec('npm run postinstall', './solid-ui'); } if (doCodeBuilder) { diff --git a/src/commands/mcp-launch.spec.ts b/src/commands/mcp-launch.spec.ts new file mode 100644 index 00000000..5cb83b02 --- /dev/null +++ b/src/commands/mcp-launch.spec.ts @@ -0,0 +1,41 @@ +import { MCP_DEFAULT_OPTIONS, buildBridgedEnv } from './mcp-launch'; + +describe('MCP_DEFAULT_OPTIONS', () => { + it('exposes the expected default values', () => { + expect(MCP_DEFAULT_OPTIONS).toEqual({ + port: '9000', + host: '0.0.0.0', + logLevel: 'INFO', + mountPath: '/mcp', + }); + }); +}); + +describe('buildBridgedEnv', () => { + it('sets SOLIDX_PROJECT_ROOT to the provided project root', () => { + const env = buildBridgedEnv('/tmp/fake-project'); + expect(env.SOLIDX_PROJECT_ROOT).toBe('/tmp/fake-project'); + }); + + it('includes DATABASE_URL when already present in process.env', () => { + const original = process.env.DATABASE_URL; + process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/db'; + try { + const env = buildBridgedEnv('/tmp/fake-project'); + expect(env.DATABASE_URL).toBeDefined(); + } finally { + if (original === undefined) delete process.env.DATABASE_URL; + else process.env.DATABASE_URL = original; + } + }); + + it('returns an object that includes process.env keys', () => { + process.env.MCP_LAUNCH_TEST_VAR = 'hello'; + try { + const env = buildBridgedEnv('/tmp/fake-project'); + expect(env.MCP_LAUNCH_TEST_VAR).toBe('hello'); + } finally { + delete process.env.MCP_LAUNCH_TEST_VAR; + } + }); +}); diff --git a/src/commands/mcp-launch.ts b/src/commands/mcp-launch.ts new file mode 100644 index 00000000..17b2370c --- /dev/null +++ b/src/commands/mcp-launch.ts @@ -0,0 +1,181 @@ +import { spawn } from 'child_process'; +import path from 'path'; +import { config as loadDotenv } from 'dotenv'; +import { normalizeDatabaseUrl } from '../helper'; +import { + checkAgentUpdate, + ensureAgentInstalled, + ensureAgentInstalledLocal, + getAgentVersion, + printAgentVersion, +} from './agent-helper'; + +export interface McpOptions { + port: string; + host: string; + logLevel: string; + mountPath: string; + local?: boolean; +} + +export const MCP_DEFAULT_OPTIONS: McpOptions = { + port: '9000', + host: '0.0.0.0', + logLevel: 'INFO', + mountPath: '/mcp', +}; + +/** + * Build a DATABASE_URL from the consuming project's individual DB env vars, + * unless DATABASE_URL is already explicitly set. + * + * If SOLID_CORE_DB_TYPE is set to "mssql", the URL is built for + * SQL Server using mssql+pyodbc. Otherwise it defaults to PostgreSQL. + */ +function resolveDatabaseUrl(): string | undefined { + if (process.env.DATABASE_URL) { + return normalizeDatabaseUrl(process.env.DATABASE_URL); + } + + const host = process.env.DEFAULT_DATABASE_HOST; + const port = process.env.DEFAULT_DATABASE_PORT; + const user = process.env.DEFAULT_DATABASE_USER; + const password = process.env.DEFAULT_DATABASE_PASSWORD; + const name = process.env.DEFAULT_DATABASE_NAME; + const dbType = (process.env.SOLID_CORE_DB_TYPE || 'postgres').toLowerCase(); + + if (!host || !port || !user || !name) return undefined; + + const encodedPw = password ? ':' + encodeURIComponent(password) : ''; + + if (dbType === 'mssql') { + return `mssql+pyodbc://${user}${encodedPw}@${host}:${port}/${name}?driver=ODBC+Driver+18+for+SQL+Server&TrustServerCertificate=yes&Encrypt=no`; + } + + if (dbType === 'mysql') { + return `mysql+pymysql://${user}${encodedPw}@${host}:${port}/${name}`; + } + + return `postgresql://${user}${encodedPw}@${host}:${port}/${name}`; +} + +/** + * Build the bridged environment object for the MCP server. + * + * Loads the consuming project's solid-api/.env, resolves DATABASE_URL from + * individual DB vars if needed, and sets SOLIDX_PROJECT_ROOT to the consuming + * project root. Returns a new object instead of mutating process.env so it is + * safe to reuse inside a long-running supervisor. + */ +export function buildBridgedEnv( + projectRoot = process.cwd(), +): Record { + loadDotenv({ path: path.join(projectRoot, 'solid-api', '.env') }); + + const databaseUrl = resolveDatabaseUrl(); + + return { + ...(process.env as Record), + SOLIDX_PROJECT_ROOT: projectRoot, + ...(databaseUrl ? { DATABASE_URL: databaseUrl } : {}), + ...(process.env.BASE_URL ? { BASE_URL: process.env.BASE_URL } : {}), + ...(process.env.FRONTEND_BASE_URL + ? { FRONTEND_BASE_URL: process.env.FRONTEND_BASE_URL } + : {}), + ...(process.env.APP_ENCRYPTION_KEY + ? { APP_ENCRYPTION_KEY: process.env.APP_ENCRYPTION_KEY } + : {}), + }; +} + +/** + * Print which critical env vars were bridged vs. missing. + */ +export function printBridgeSummary(env: Record): void { + const bridgedKeys = [ + 'DATABASE_URL', + 'SOLIDX_PROJECT_ROOT', + 'BASE_URL', + 'FRONTEND_BASE_URL', + 'APP_ENCRYPTION_KEY', + ]; + const bridged = bridgedKeys.filter((k) => env[k]); + const missing = bridgedKeys.filter((k) => !env[k]); + console.log(`✔ Bridged env: ${bridged.join(', ') || 'none'}`); + if (missing.length) console.warn(`⚠ Missing env: ${missing.join(', ')}`); +} + +export interface McpLaunchConfig { + command: string; + args: string[]; + cwd: string; + env: Record; +} + +/** + * Resolve the agent binary, build the bridged env, and return the full launch + * config for the MCP server. Performs agent update check and install before + * returning. Validates that DATABASE_URL is present. + * + * Throws if DATABASE_URL is missing. + */ +export async function resolveMcpLaunchConfig( + options: McpOptions, + projectRoot = process.cwd(), +): Promise { + await checkAgentUpdate({ + isLocal: Boolean(options.local), + exitMode: 'throw', + }); + + const env = buildBridgedEnv(projectRoot); + + if (!env.DATABASE_URL) { + throw new Error( + 'DATABASE_URL is required for MCP mode.\n' + + ' It is needed to validate API keys and write audit logs.\n' + + ' Set it in your solid-api/.env or environment.', + ); + } + + const agentCommand = options.local + ? ensureAgentInstalledLocal('throw') + : ensureAgentInstalled('throw'); + printAgentVersion(agentCommand); + + return { + command: agentCommand, + args: [ + 'mcp-remote', + '--host', + options.host, + '--port', + options.port, + '--log-level', + options.logLevel, + '--mount-path', + options.mountPath, + ], + cwd: env.SOLIDX_PROJECT_ROOT, + env, + }; +} + +/** + * Print agent version via the shared helper (re-exported for convenience). + */ +export { getAgentVersion, printAgentVersion }; + +/** + * Spawn the MCP server as a long-running child process with piped stdio. + * Intended for use inside a supervisor that manages the process lifecycle. + */ +export function spawnMcpServer(launchConfig: McpLaunchConfig) { + return spawn(launchConfig.command, launchConfig.args, { + cwd: launchConfig.cwd, + env: launchConfig.env, + stdio: ['ignore', 'pipe', 'pipe'], + shell: process.platform === 'win32', + detached: process.platform !== 'win32', + }); +} diff --git a/src/commands/mcp.command.ts b/src/commands/mcp.command.ts index 52a1263e..46f81aab 100644 --- a/src/commands/mcp.command.ts +++ b/src/commands/mcp.command.ts @@ -1,84 +1,26 @@ import { Command } from 'commander'; import { spawnSync } from 'child_process'; -import path from 'path'; -import { config as loadDotenv } from 'dotenv'; import { validateProjectRoot } from '../helper'; -import { ensureAgentInstalled, ensureAgentInstalledLocal } from './agent-helper'; - -/** - * Build a DATABASE_URL from the consuming project's individual DB env vars, - * unless DATABASE_URL is already explicitly set. - * - * If SOLID_CORE_DB_TYPE is set to "mssql", the URL is built for - * SQL Server using mssql+pyodbc. Otherwise it defaults to PostgreSQL. - */ -function resolveDatabaseUrl(): string | undefined { - if (process.env.DATABASE_URL) return process.env.DATABASE_URL; - - const host = process.env.DEFAULT_DATABASE_HOST; - const port = process.env.DEFAULT_DATABASE_PORT; - const user = process.env.DEFAULT_DATABASE_USER; - const password = process.env.DEFAULT_DATABASE_PASSWORD; - const name = process.env.DEFAULT_DATABASE_NAME; - const dbType = (process.env.SOLID_CORE_DB_TYPE || 'postgres').toLowerCase(); - - if (!host || !port || !user || !name) return undefined; - - const encodedPw = password ? ':' + encodeURIComponent(password) : ''; - - if (dbType === 'mssql') { - return `mssql+pyodbc://${user}${encodedPw}@${host}:${port}/${name}?driver=ODBC+Driver+18+for+SQL+Server&TrustServerCertificate=yes&Encrypt=no`; - } - - if (dbType === 'mysql') { - return `mysql+pymysql://${user}${encodedPw}@${host}:${port}/${name}`; - } - - // Default to PostgreSQL - return `postgresql://${user}${encodedPw}@${host}:${port}/${name}`; -} - -/** - * Build the bridged environment object for the MCP server. - * - * Mirrors the env-bridging logic in agent.command.ts — loads the consuming - * project's solid-api/.env, resolves DATABASE_URL from individual DB vars - * if needed, and sets SOLIDX_PROJECT_ROOT to the consuming project root. - */ -function buildBridgedEnv(): Record { - const projectRoot = process.cwd(); - - // Load consuming project's .env (lives in solid-api/) - loadDotenv({ path: path.join(projectRoot, 'solid-api', '.env') }); - - const databaseUrl = resolveDatabaseUrl(); - - return { - ...(process.env as Record), - SOLIDX_PROJECT_ROOT: projectRoot, - ...(databaseUrl ? { DATABASE_URL: databaseUrl } : {}), - ...(process.env.BASE_URL ? { BASE_URL: process.env.BASE_URL } : {}), - ...(process.env.FRONTEND_BASE_URL ? { FRONTEND_BASE_URL: process.env.FRONTEND_BASE_URL } : {}), - ...(process.env.APP_ENCRYPTION_KEY ? { APP_ENCRYPTION_KEY: process.env.APP_ENCRYPTION_KEY } : {}), - }; -} - -/** - * Print which critical env vars were bridged vs. missing. - */ -function printBridgeSummary(env: Record): void { - const bridgedKeys = ['DATABASE_URL', 'SOLIDX_PROJECT_ROOT', 'BASE_URL', 'FRONTEND_BASE_URL', 'APP_ENCRYPTION_KEY']; - const bridged = bridgedKeys.filter((k) => env[k]); - const missing = bridgedKeys.filter((k) => !env[k]); - console.log(`✔ Bridged env: ${bridged.join(', ') || 'none'}`); - if (missing.length) console.warn(`⚠ Missing env: ${missing.join(', ')}`); -} +import { checkAgentUpdate, ensureAgentInstalled, ensureAgentInstalledLocal, getAgentVersion, printAgentVersion } from './agent-helper'; +import { buildBridgedEnv, printBridgeSummary } from './mcp-launch'; export function registerMcpCommand(program: Command) { const mcp = program .command('mcp') .description('SolidX MCP Server (Streamable HTTP) — for remote clients (Cursor, Codex, cloud desktops)'); + mcp + .option('--version', 'Print the solidx-ai-agent version and exit') + .action(async (options: { version?: boolean }) => { + if (options.version) { + await checkAgentUpdate({ isLocal: false }); + const agentCommand = ensureAgentInstalled(); + console.log(`solidx-ai-agent v${getAgentVersion(agentCommand)}`); + process.exit(0); + } + mcp.help(); + }); + mcp .command('start') .description('Start the MCP server using Streamable HTTP transport') @@ -87,7 +29,8 @@ export function registerMcpCommand(program: Command) { .option('-l, --log-level ', 'Logging level', 'INFO') .option('--mount-path ', 'Path under which to mount the MCP app', '/mcp') .option('--local', 'Install agent from local source (pip install -e .[full]) instead of PyPI') - .action((options: { port: string; host: string; logLevel: string; mountPath: string; local?: boolean }) => { + .action(async (options: { port: string; host: string; logLevel: string; mountPath: string; local?: boolean }) => { + await checkAgentUpdate({ isLocal: Boolean(options.local) }); validateProjectRoot(); const env = buildBridgedEnv(); @@ -104,6 +47,7 @@ export function registerMcpCommand(program: Command) { // This keeps the CLI honest on Ubuntu: if Python/venv/bootstrap fails, we // should not imply that the MCP server actually started listening yet. const agentCommand = options.local ? ensureAgentInstalledLocal() : ensureAgentInstalled(); + printAgentVersion(agentCommand); printBridgeSummary(env); console.log(`▶ Starting SolidX MCP Server on ${options.host}:${options.port}`); const result = spawnSync( diff --git a/src/commands/migration.command.ts b/src/commands/migration.command.ts index a2f860ee..43aeee06 100644 --- a/src/commands/migration.command.ts +++ b/src/commands/migration.command.ts @@ -5,8 +5,10 @@ import path from 'path'; import { validateProjectRoot } from '../helper'; type MigrationOptions = { - datasource: string; + datasource?: string; module?: string; + name?: string; + apply?: boolean; }; function getNpxCommand() { @@ -88,23 +90,32 @@ function runTypeormCli(args: string[], solidApiDir: string, failureLabel: string export function registerMigrationCommand(program: Command) { program .command('migration [migrationName]') - .description('Generate, run, or revert TypeORM migrations for a datasource') - .requiredOption('-d, --datasource ', 'Datasource name (maps to src/typeorm--datasource.ts)') + .description('Generate, run, revert, or remove-field TypeORM migrations for a datasource') + .option('-d, --datasource ', 'Datasource name (maps to src/typeorm--datasource.ts), required for generate/run/revert') .option('-m, --module ', 'Module name, required for generate') + .option('-n, --name ', 'Model name (singularName), required for remove-field') + .option('--apply', 'Apply the cleanup instead of running a dry-run preview (used with remove-field)') .addHelpText('after', ` Examples: npx @solidxai/solidctl migration -d default -m onboarding generate AddPreApplicationMaster npx @solidxai/solidctl migration -d default run - npx @solidxai/solidctl migration -d default revert`) + npx @solidxai/solidctl migration -d default revert + npx @solidxai/solidctl migration -n book remove-field + npx @solidxai/solidctl migration -n book remove-field --apply`) .action((action: string, migrationName: string | undefined, options: MigrationOptions) => { validateProjectRoot(); const normalizedAction = action.trim().toLowerCase(); const projectRoot = process.cwd(); const solidApiDir = path.join(projectRoot, 'solid-api'); - ensureDatasourceFile(solidApiDir, options.datasource); if (normalizedAction === 'generate') { + if (!options.datasource) { + fail('Option --datasource is required for generate.'); + } + + ensureDatasourceFile(solidApiDir, options.datasource); + const { migrationTargetPath } = validateGenerateInputs( solidApiDir, options.datasource, @@ -127,6 +138,12 @@ Examples: } if (normalizedAction === 'run') { + if (!options.datasource) { + fail('Option --datasource is required for run.'); + } + + ensureDatasourceFile(solidApiDir, options.datasource); + console.log(`✅ Using datasource: src/typeorm-${options.datasource}-datasource.ts`); console.log('➡ Running migrations...'); runTypeormCli( @@ -138,6 +155,12 @@ Examples: } if (normalizedAction === 'revert') { + if (!options.datasource) { + fail('Option --datasource is required for revert.'); + } + + ensureDatasourceFile(solidApiDir, options.datasource); + console.log(`✅ Using datasource: src/typeorm-${options.datasource}-datasource.ts`); console.log('➡ Reverting last migration...'); runTypeormCli( @@ -148,6 +171,51 @@ Examples: return; } - fail(`Unknown action "${action}". Expected generate, run, or revert.`); + if (normalizedAction === 'remove-field') { + if (!options.name) { + console.error('Option --name is required for remove-field.'); + process.exit(1); + } + + const mainCliPath = path.join(solidApiDir, 'dist', 'main-cli.js'); + + if (!fs.existsSync(mainCliPath)) { + console.error(`solid-api CLI not found at ${mainCliPath}. Run "npx @solidxai/solidctl build" or "cd solid-api && npm run build" first.`); + process.exit(1); + } + + const args = [ + path.relative(solidApiDir, mainCliPath), + 'migrate-removed-fields', + '-n', + options.name, + ]; + + if (options.apply) { + args.push('-d', 'false'); + } + + console.log(`▶ Running removed-field cleanup for model "${options.name}"${options.apply ? ' (apply)' : ' (dry-run)'}`); + const result = spawnSync(process.execPath, args, { + cwd: solidApiDir, + stdio: 'inherit', + env: process.env, + }); + + if (result.error) { + console.error(`Failed to run cleanup-removed-fields: ${result.error.message}`); + process.exit(1); + } + + if (result.status !== 0) { + console.error(`cleanup-removed-fields exited with code ${result.status}`); + process.exit(result.status ?? 1); + } + + console.log(`✔ cleanup-removed-fields completed for model "${options.name}"`); + return; + } + + fail(`Unknown action "${action}". Expected generate, run, revert, or remove-field.`); }); -} +} \ No newline at end of file diff --git a/src/commands/start.command.ts b/src/commands/start.command.ts index 3821f57c..1ec87099 100644 --- a/src/commands/start.command.ts +++ b/src/commands/start.command.ts @@ -12,10 +12,15 @@ import { startEmbeddedServer, stopEmbeddedServer, } from '../db/embedded'; +import { AgentCommandExit } from './agent-helper'; +import { MCP_DEFAULT_OPTIONS, resolveMcpLaunchConfig, spawnMcpServer, type McpLaunchConfig } from './mcp-launch'; -type ServiceName = 'api' | 'ui'; +type ServiceName = 'api' | 'ui' | 'mcp'; -type ServiceScripts = Record; +/** Services that are launched via an npm script. MCP is spawned directly. */ +type ScriptServiceName = 'api' | 'ui'; + +type ServiceScripts = Record; type ServiceConfig = { cwd: string; @@ -36,6 +41,7 @@ type StartOptions = { controls?: boolean; plain?: boolean; ui?: boolean; + mcp?: boolean; }; class StartSupervisor { @@ -48,8 +54,13 @@ class StartSupervisor { private readonly isInteractive: boolean; private readonly npmCommand: string; private readonly isEmbedded: boolean; + private readonly supportsMcp: boolean; + private mcpPort: string; private embeddedServer: EmbeddedServerHandle | null = null; + private mcpLaunchConfig: McpLaunchConfig | null = null; private shuttingDown = false; + private startupPhase = true; + private startupAbortInProgress = false; private exitCode = 0; private stdinWasRaw = false; @@ -57,14 +68,17 @@ class StartSupervisor { private readonly projectRoot: string, serviceScripts: ServiceScripts, options: StartOptions, + supportsMcp = false, ) { this.isInteractive = Boolean(process.stdout.isTTY && process.stdin.isTTY && !options.plain); this.npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; this.isEmbedded = isEmbeddedProject(projectRoot); + this.supportsMcp = supportsMcp; this.serviceScripts = serviceScripts; this.activeServices = this.resolveActiveServices(options); this.apiPort = this.resolveApiPort(); this.uiPort = this.resolveUiPort(); + this.mcpPort = MCP_DEFAULT_OPTIONS.port; this.serviceConfigs = { api: { @@ -77,40 +91,67 @@ class StartSupervisor { label: 'ui', color: chalk.magenta, }, + mcp: { + cwd: projectRoot, + label: 'mcp', + color: chalk.green, + }, }; this.serviceStates = { api: this.createInitialState(), ui: this.createInitialState(), + mcp: this.createInitialState(), }; } async start() { this.attachSignalHandlers(); - this.attachKeyboardControls(); this.printStatus('Starting SolidX dev processes'); - if (this.isEmbedded) { - await this.startEmbeddedDatabase(); - } + let shouldStartServices = true; - for (const serviceName of this.activeServices) { - this.spawnService(serviceName); - } - this.renderFooter(); + try { + if (this.isEmbedded) { + await this.startEmbeddedDatabase(); + shouldStartServices = !this.shuttingDown; + } - await new Promise((resolve) => { - const poll = setInterval(() => { - if (!this.hasRunningChildren()) { - clearInterval(poll); - this.cleanupTerminal(); - resolve(); - } - }, 100); - }); + if (shouldStartServices && this.activeServices.includes('mcp')) { + await this.startMcpServer(); + shouldStartServices = !this.shuttingDown; + } - await this.stopEmbeddedDatabase(); + if (shouldStartServices) { + this.startupPhase = false; + this.attachKeyboardControls(); + for (const serviceName of this.activeServices) { + this.spawnService(serviceName); + } + this.renderFooter(); + + await new Promise((resolve) => { + const poll = setInterval(() => { + if (!this.hasRunningChildren()) { + clearInterval(poll); + resolve(); + } + }, 100); + }); + } + } catch (error) { + if (error instanceof AgentCommandExit) { + this.exitCode = error.exitCode; + } else if (this.exitCode === 0) { + this.exitCode = 1; + } + this.shuttingDown = true; + } finally { + this.startupPhase = false; + await this.stopEmbeddedDatabase(); + this.cleanupTerminal(); + } process.exit(this.exitCode); } @@ -129,12 +170,17 @@ class StartSupervisor { try { await this.embeddedServer.ready; + if (this.shuttingDown) { + return; + } this.printStatus(`Embedded database ready on ${config.host}:${config.port}`); } catch (error) { + if (this.shuttingDown) { + return; + } const message = error instanceof Error ? error.message : String(error); this.printStatus(`Embedded database failed to start: ${message}`); - this.embeddedServer = null; - process.exit(1); + throw error; } } @@ -147,6 +193,31 @@ class StartSupervisor { this.embeddedServer = null; } + private async startMcpServer() { + this.printStatus('Starting MCP server'); + + try { + this.mcpLaunchConfig = await resolveMcpLaunchConfig( + { ...MCP_DEFAULT_OPTIONS }, + this.projectRoot, + ); + this.mcpPort = this.mcpLaunchConfig.args[ + this.mcpLaunchConfig.args.indexOf('--port') + 1 + ]; + this.printStatus(`MCP server config resolved (port ${this.mcpPort})`); + } catch (error) { + if (this.shuttingDown) { + return; + } + if (error instanceof AgentCommandExit) { + throw error; + } + const message = error instanceof Error ? error.message : String(error); + this.printStatus(`MCP server failed to initialize: ${message}`); + throw error; + } + } + private createInitialState(): ServiceState { return { child: null, @@ -168,24 +239,48 @@ class StartSupervisor { selectedServices.push('ui'); } - return selectedServices.length ? selectedServices : ['api', 'ui']; + if (this.supportsMcp && options.mcp) { + selectedServices.push('mcp'); + } + + if (selectedServices.length) { + return selectedServices; + } + + // No explicit selection: default to all available services. + const defaults: ServiceName[] = ['api', 'ui']; + if (this.supportsMcp) { + defaults.push('mcp'); + } + return defaults; } private spawnService(serviceName: ServiceName) { const config = this.serviceConfigs[serviceName]; const state = this.serviceStates[serviceName]; - const scriptName = this.serviceScripts[serviceName]; state.restartRequested = false; state.stoppingForShutdown = false; - const child = spawn(this.npmCommand, ['run', scriptName], { - cwd: config.cwd, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], - shell: false, - detached: process.platform !== 'win32', - }); + let child: ChildProcess; + + if (serviceName === 'mcp') { + if (!this.mcpLaunchConfig) { + this.printLog(serviceName, 'MCP launch config not available', true); + this.handleUnexpectedExit(serviceName, 1); + return; + } + child = spawnMcpServer(this.mcpLaunchConfig); + } else { + const scriptName = this.serviceScripts[serviceName as ScriptServiceName]; + child = spawn(this.npmCommand, ['run', scriptName], { + cwd: config.cwd, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + shell: false, + detached: process.platform !== 'win32', + }); + } state.child = child; this.printStatus(`Started ${config.label}`); @@ -279,13 +374,15 @@ class StartSupervisor { const controlSegments = ['q quit', 'c clear']; if (this.activeServices.includes('api')) { - serviceSegments.push(`API (a restart, d open docs, :${this.apiPort})`); - controlSegments.push('a restart API'); + serviceSegments.push(`API (a restart, d docs, :${this.apiPort})`); } if (this.activeServices.includes('ui')) { serviceSegments.push(`UI (u restart, o open, :${this.uiPort})`); - controlSegments.push('u restart UI'); + } + + if (this.activeServices.includes('mcp')) { + serviceSegments.push(`MCP (m restart, :${this.mcpPort})`); } if (this.activeServices.length > 1) { @@ -409,6 +506,11 @@ class StartSupervisor { this.restartService('ui'); } break; + case 'm': + if (this.activeServices.includes('mcp')) { + this.restartService('mcp'); + } + break; case 'r': for (const serviceName of this.activeServices) { this.restartService(serviceName); @@ -532,6 +634,14 @@ class StartSupervisor { this.shuttingDown = true; this.printStatus('Shutting down'); + if (this.startupPhase) { + if (!this.startupAbortInProgress) { + this.startupAbortInProgress = true; + void this.abortStartup(); + } + return; + } + for (const serviceName of this.activeServices) { const state = this.serviceStates[serviceName]; state.stoppingForShutdown = true; @@ -542,6 +652,12 @@ class StartSupervisor { this.renderFooter(); } + private async abortStartup() { + await this.stopEmbeddedDatabase(); + this.cleanupTerminal(); + process.exit(this.exitCode); + } + private hasRunningChildren() { return this.activeServices.some((serviceName) => { return this.serviceStates[serviceName].child !== null; @@ -639,42 +755,53 @@ export function registerStartCommand(program: Command) { commandName: string, description: string, serviceScripts: ServiceScripts, + supportsMcp = false, ) => { - program + const command = program .command(commandName) .description(description) .option('--api', 'Only supervise solid-api') .option('--controls', 'Enable interactive controls with pinned footer and keyboard shortcuts (default on TTYs)') .option('--plain', 'Disable interactive controls and print merged logs only') - .option('--ui', 'Only supervise solid-ui') - .action(async (options: StartOptions) => { + .option('--ui', 'Only supervise solid-ui'); + + if (supportsMcp) { + command.option('--mcp', 'Only supervise the MCP server'); + } + + command.action(async (options: StartOptions) => { validateProjectRoot(); const selectedServices = [ ...(options.api ? (['api'] as const) : []), ...(options.ui ? (['ui'] as const) : []), + ...(supportsMcp && options.mcp ? (['mcp'] as const) : []), ]; - const activeServices = selectedServices.length ? selectedServices : (['api', 'ui'] as const); + const activeServices = selectedServices.length ? selectedServices : ( + supportsMcp ? (['api', 'ui', 'mcp'] as const) : (['api', 'ui'] as const) + ); for (const serviceName of activeServices) { + if (serviceName === 'mcp') continue; validateProjectScript( serviceName === 'api' ? 'solid-api' : 'solid-ui', - serviceScripts[serviceName], + serviceScripts[serviceName as ScriptServiceName], ); } - const supervisor = new StartSupervisor(process.cwd(), serviceScripts, options); + const supervisor = new StartSupervisor(process.cwd(), serviceScripts, options, supportsMcp); await supervisor.start(); }); }; registerSupervisorCommand( 'start:dev', - 'Start solid-api and solid-ui dev processes in a single supervisor', + 'Start solid-api, solid-ui, and MCP dev processes in a single supervisor', { api: 'solidx:dev', ui: 'solidx:dev', }, + true, ); registerSupervisorCommand( diff --git a/src/db/embedded.ts b/src/db/embedded.ts index b56c72c2..5701e2b9 100644 --- a/src/db/embedded.ts +++ b/src/db/embedded.ts @@ -114,7 +114,7 @@ export function startEmbeddedServer( SOLIDX_PGLITE_HOST: config.host, SOLIDX_PGLITE_PORT: config.port, SOLIDX_PGLITE_DATA: config.dataDir, - SOLIDX_PGLITE_MAX_CONNECTIONS: String(options.maxConnections ?? 50), + SOLIDX_PGLITE_MAX_CONNECTIONS: String(options.maxConnections ?? 1), }, stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/src/db/pglite-server.ts b/src/db/pglite-server.ts index 54c5a701..2593ba89 100644 --- a/src/db/pglite-server.ts +++ b/src/db/pglite-server.ts @@ -16,7 +16,7 @@ async function main(): Promise { const port = Number(process.env.SOLIDX_PGLITE_PORT || '54329'); const dataDir = process.env.SOLIDX_PGLITE_DATA; const maxConnections = Number( - process.env.SOLIDX_PGLITE_MAX_CONNECTIONS || '50', + process.env.SOLIDX_PGLITE_MAX_CONNECTIONS || '1', ); if (!dataDir) { diff --git a/src/helper.ts b/src/helper.ts index 18078e03..c9d67bb3 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -3,6 +3,7 @@ import os from 'os'; import path from 'path'; const requiredProjectFiles = ['solid-api/package.json', 'solid-ui/package.json'] as const; +const validPercentEscapePattern = /^%[0-9A-Fa-f]{2}$/; export function validateProjectRoot() { const cwd = process.cwd(); @@ -48,3 +49,41 @@ export function getSolidCommandEnv() { PATH: `${solidctlBinDir}${path.delimiter}${currentPath}`, }; } + +function encodeUserInfoComponent(value: string) { + return value + .split(/(%[0-9A-Fa-f]{2})/) + .map((part) => (validPercentEscapePattern.test(part) ? part : encodeURIComponent(part))) + .join(''); +} + +export function normalizeDatabaseUrl(urlValue: string) { + const trimmedUrl = urlValue.trim(); + const schemeMatch = trimmedUrl.match(/^([a-z][a-z0-9+.-]*:\/\/)([\s\S]+)$/i); + + if (!schemeMatch) return trimmedUrl; + + const [, scheme, remainder] = schemeMatch; + const userInfoSeparatorIndex = remainder.lastIndexOf('@'); + + if (userInfoSeparatorIndex < 0) return trimmedUrl; + + const userInfo = remainder.slice(0, userInfoSeparatorIndex); + const hostAndSuffix = remainder.slice(userInfoSeparatorIndex + 1); + const authorityEndIndex = ['/', '?', '#'] + .map((separator) => hostAndSuffix.indexOf(separator)) + .filter((index) => index >= 0) + .sort((a, b) => a - b)[0] ?? hostAndSuffix.length; + const hostInfo = hostAndSuffix.slice(0, authorityEndIndex); + const suffix = hostAndSuffix.slice(authorityEndIndex); + const passwordSeparatorIndex = userInfo.indexOf(':'); + + if (passwordSeparatorIndex < 0) { + return `${scheme}${encodeUserInfoComponent(userInfo)}@${hostInfo}${suffix}`; + } + + const username = userInfo.slice(0, passwordSeparatorIndex); + const password = userInfo.slice(passwordSeparatorIndex + 1); + + return `${scheme}${encodeUserInfoComponent(username)}:${encodeUserInfoComponent(password)}@${hostInfo}${suffix}`; +} diff --git a/src/main.ts b/src/main.ts index 83801943..3ea67c2c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -41,6 +41,12 @@ async function bootstrap() { const program = new Command(); + // Ensure options appearing after a subcommand name belong to that subcommand, + // not the root program. Without this, the root program's `--version` would + // intercept `solidctl agent --version` / `solidctl mcp --version` and print + // the CLI version instead of letting the subcommand handle it. + program.enablePositionalOptions(); + program .name('solidctl') .description('Solidctl tool') diff --git a/src/version-check.ts b/src/version-check.ts index 245843b7..eb5ae5a4 100644 --- a/src/version-check.ts +++ b/src/version-check.ts @@ -9,7 +9,7 @@ import ora from 'ora'; const PACKAGE_NAME = '@solidxai/solidctl'; const NPM_TIMEOUT_MS = 10_000; -function getCurrentVersion(): string { +export function getCurrentVersion(): string { const packageJsonPath = path.resolve(__dirname, '..', 'package.json'); try { const packageJson = JSON.parse( @@ -21,7 +21,7 @@ function getCurrentVersion(): string { } } -function getDistTag(version: string): string { +export function getDistTag(version: string): string { const prerelease = semver.prerelease(version); if (prerelease && prerelease.length > 0) { return String(prerelease[0]); diff --git a/templates/api-template/nodemon.json b/templates/api-template/nodemon.json index c11a3242..35632355 100644 --- a/templates/api-template/nodemon.json +++ b/templates/api-template/nodemon.json @@ -6,6 +6,7 @@ "ignore": [ "src/**/*.spec.ts", "src/**/migrations/**", + "src/**/metadata/**/*.json", "dist", "node_modules" ],