Skip to content
Open

Dev #62

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@solidxai/solidctl",
"version": "0.1.45",
"version": "0.1.46-beta.1",
"description": "",
"author": "",
"private": false,
Expand Down
213 changes: 209 additions & 4 deletions src/commands/agent-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -183,6 +187,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 (`<src>/.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<x.y.z>` 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<string | null> {
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<string, unknown> };
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 }): Promise<void> {
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`));
process.exit(0);
} 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.
Expand All @@ -197,14 +396,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 <interpreter> 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',
},
Expand All @@ -217,8 +422,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',
Expand Down
27 changes: 23 additions & 4 deletions src/commands/agent.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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')
Expand All @@ -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();

Expand All @@ -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();
});
Expand All @@ -535,7 +551,9 @@ export function registerAgentCommand(program: Command) {
.option('-m, --mode <mode>', 'Tool mode: native or mcp')
.option('-l, --log-level <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();

Expand All @@ -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]);
Expand Down
11 changes: 9 additions & 2 deletions src/commands/create-app/create-app.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | boolean | undefined>): SetupAnswers {
Expand Down Expand Up @@ -68,7 +74,7 @@ function buildAnswersFromOptions(options: Record<string, string | boolean | unde
process.exit(1);
}

const solidxVersion = options.beta ? 'beta' : SETUP_DEFAULTS.solidxVersion;
const solidxVersion = options.beta ? 'beta' : detectSolidxVersion();
if (!SOLIDX_VERSION_OPTIONS.includes(solidxVersion as any)) {
console.error(chalk.red(`Invalid SolidX version "${solidxVersion}". Must be one of: ${SOLIDX_VERSION_OPTIONS.join(', ')}`));
process.exit(1);
Expand Down Expand Up @@ -125,7 +131,7 @@ export function registerCreateAppCommand(program: Command) {
.option('--db-synchronize <yes|no>', `Auto-sync DB schema: Yes or No (default: ${SETUP_DEFAULTS.solidApiDatabaseSynchronize})`)
.option('--db-exists <yes|no>', `Database already exists: Yes or No (default: ${SETUP_DEFAULTS.databaseExists})`)
.option('--ui-port <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;
Expand All @@ -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);
}
Expand Down
6 changes: 5 additions & 1 deletion src/commands/create-app/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading