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
4 changes: 2 additions & 2 deletions src/commands/agent.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import chalk from 'chalk';
import path from 'path';
import readline from 'readline';
import { config as loadDotenv } from 'dotenv';
import { validateProjectRoot } from '../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
11 changes: 9 additions & 2 deletions src/commands/mcp.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Command } from 'commander';
import { spawnSync } from 'child_process';
import path from 'path';
import { config as loadDotenv } from 'dotenv';
import { validateProjectRoot } from '../helper';
import { normalizeDatabaseUrl, validateProjectRoot } from '../helper';
import { checkAgentUpdate, ensureAgentInstalled, ensureAgentInstalledLocal, getAgentVersion, printAgentVersion } from './agent-helper';

/**
Expand All @@ -13,7 +13,12 @@ import { checkAgentUpdate, ensureAgentInstalled, ensureAgentInstalledLocal, getA
* SQL Server using mssql+pyodbc. Otherwise it defaults to PostgreSQL.
*/
function resolveDatabaseUrl(): string | undefined {
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
// console.log('Before resolving DATABASE_URL', process.env.DATABASE_URL);
if (process.env.DATABASE_URL){
const resolvedUrl = normalizeDatabaseUrl(process.env.DATABASE_URL);
// console.log('After resolving DATABASE_URL',resolvedUrl);
return resolvedUrl;
}

const host = process.env.DEFAULT_DATABASE_HOST;
const port = process.env.DEFAULT_DATABASE_PORT;
Expand All @@ -35,6 +40,8 @@ function resolveDatabaseUrl(): string | undefined {
}

// Default to PostgreSQL
console.log(`Final Url postgresql://${user}${encodedPw}@${host}:${port}/${name}`);

return `postgresql://${user}${encodedPw}@${host}:${port}/${name}`;
}

Expand Down
82 changes: 75 additions & 7 deletions src/commands/migration.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -88,23 +90,32 @@ function runTypeormCli(args: string[], solidApiDir: string, failureLabel: string
export function registerMigrationCommand(program: Command) {
program
.command('migration <action> [migrationName]')
.description('Generate, run, or revert TypeORM migrations for a datasource')
.requiredOption('-d, --datasource <datasource>', 'Datasource name (maps to src/typeorm-<datasource>-datasource.ts)')
.description('Generate, run, revert, or remove-field TypeORM migrations for a datasource')
.option('-d, --datasource <datasource>', 'Datasource name (maps to src/typeorm-<datasource>-datasource.ts), required for generate/run/revert')
.option('-m, --module <module>', 'Module name, required for generate')
.option('-n, --name <modelName>', '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 <datasource> is required for generate.');
}

ensureDatasourceFile(solidApiDir, options.datasource);

const { migrationTargetPath } = validateGenerateInputs(
solidApiDir,
options.datasource,
Expand All @@ -127,6 +138,12 @@ Examples:
}

if (normalizedAction === 'run') {
if (!options.datasource) {
fail('Option --datasource <datasource> is required for run.');
}

ensureDatasourceFile(solidApiDir, options.datasource);

console.log(`✅ Using datasource: src/typeorm-${options.datasource}-datasource.ts`);
console.log('➡ Running migrations...');
runTypeormCli(
Expand All @@ -138,6 +155,12 @@ Examples:
}

if (normalizedAction === 'revert') {
if (!options.datasource) {
fail('Option --datasource <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(
Expand All @@ -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 <model> 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.`);
});
}
}
39 changes: 39 additions & 0 deletions src/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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}`;
}