diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index 73acd5048..ed2d8c6b3 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -2,21 +2,21 @@ import { CLIOptions, Inquirerer, Question } from 'inquirerer'; import { ParsedArgs } from 'minimist'; import { - errors, getEnvOptions, LaunchQLOptions } from '@launchql/types'; + import { getPgEnvOptions, getSpawnEnvWithPg, } from 'pg-env'; -import { deploy, deployFast } from '@launchql/core'; +import { deployFast } from '@launchql/core'; import { Logger } from '@launchql/logger'; import { execSync } from 'child_process'; -import { LaunchQLProject } from '@launchql/core'; -import { deployCommand } from '@launchql/migrate'; +import { deployProject } from '@launchql/migrate'; import { getTargetDatabase } from '../utils'; +import { selectModule } from '../utils/module-utils'; export default async ( argv: Partial, @@ -71,8 +71,6 @@ export default async ( log.debug(`Using current directory: ${cwd}`); - const project = new LaunchQLProject(cwd); - if (createdb) { log.info(`Creating database ${database}...`); execSync(`createdb ${database}`, { @@ -80,68 +78,47 @@ export default async ( }); } - const options: LaunchQLOptions = getEnvOptions({ - pg: { - database - } - }); - + let projectName: string | undefined; if (recursive) { - const modules = await project.getModules(); - const moduleNames = modules.map(mod => mod.getModuleName()); - - if (!moduleNames.length) { - log.error('No modules found in the specified directory.'); - prompter.close(); - throw errors.NOT_FOUND({}, 'No modules found in the specified directory.'); - } + projectName = await selectModule(argv, prompter, 'Choose a project to deploy', cwd); + log.info(`Selected project: ${projectName}`); + } - const { project: selectedProject } = await prompter.prompt(argv, [ - { - type: 'autocomplete', - name: 'project', - message: 'Choose a project to deploy', - options: moduleNames, - required: true + // Handle fast deploy separately as it uses a different API + if (argv.fast && recursive) { + const options: LaunchQLOptions = getEnvOptions({ + pg: { + database } - ]); - - const selected = modules.find(mod => mod.getModuleName() === selectedProject); - if (!selected) { - throw new Error(`Module ${selectedProject} not found`); - } - - const dir = selected.getModulePath()!; - log.success(`Deploying project ${selectedProject} from ${dir} to database ${database}...`); - - if (argv.fast) { - await deployFast({ - opts: options, - database, - dir, - name: selectedProject, - usePlan: true, - cache: false - }); - } else { - await deploy(options, selectedProject, database, dir, { useSqitch, useTransaction: tx }); - } - - log.success('Deployment complete.'); + }); + + // Fast deploy needs the module path, so we need to get it + // This is a limitation of the current fast deploy API + const { LaunchQLProject } = await import('@launchql/core'); + const project = new LaunchQLProject(cwd); + const modules = project.getModuleMap(); + const modulePath = modules[projectName!].path; + + await deployFast({ + opts: options, + database, + dir: modulePath, + name: projectName!, + usePlan: true, + cache: false + }); } else { - if (useSqitch) { - log.info(`Running: sqitch deploy db:pg:${database} (using legacy Sqitch)`); - execSync(`sqitch deploy db:pg:${database}`, { - cwd, - env: getSpawnEnvWithPg(pgEnv), - stdio: 'inherit' - }); - } else { - log.info(`Running: launchql migrate deploy db:pg:${database}`); - await deployCommand(pgEnv, database, cwd, { useTransaction: tx }); - } - log.success('Deployment complete.'); + await deployProject({ + database, + cwd, + recursive, + projectName, + useSqitch, + useTransaction: tx + }); } + log.success('Deployment complete.'); + return argv; }; diff --git a/packages/cli/src/commands/revert.ts b/packages/cli/src/commands/revert.ts index 37a1f3e5f..4c508bc6a 100644 --- a/packages/cli/src/commands/revert.ts +++ b/packages/cli/src/commands/revert.ts @@ -1,11 +1,8 @@ import { CLIOptions, Inquirerer, Question } from 'inquirerer'; -import { LaunchQLProject, revert } from '@launchql/core'; -import { errors, getEnvOptions, LaunchQLOptions } from '@launchql/types'; -import { getPgEnvOptions, getSpawnEnvWithPg } from 'pg-env'; import { Logger } from '@launchql/logger'; -import { revertCommand } from '@launchql/migrate'; -import { execSync } from 'child_process'; +import { revertProject } from '@launchql/migrate'; import { getTargetDatabase } from '../utils'; +import { selectModule } from '../utils/module-utils'; const log = new Logger('revert'); @@ -45,52 +42,22 @@ export default async ( log.debug(`Using current directory: ${cwd}`); - const project = new LaunchQLProject(cwd); - + let projectName: string | undefined; if (recursive) { - const modules = await project.getModules(); - const moduleNames = modules.map(mod => mod.getModuleName()); - - if (!moduleNames.length) { - log.error('No modules found in the specified directory.'); - prompter.close(); - throw errors.NOT_FOUND({}, 'No modules found in the specified directory.'); - } - - const { project: selectedProject } = await prompter.prompt(argv, [ - { - type: 'autocomplete', - name: 'project', - message: 'Choose a project to revert', - options: moduleNames, - required: true - } - ]); + projectName = await selectModule(argv, prompter, 'Choose a project to revert', cwd); + log.info(`Selected project: ${projectName}`); + } - log.success(`Reverting project ${selectedProject} on database ${database}...`); - const options: LaunchQLOptions = getEnvOptions({ - pg: { - database - } - }); + await revertProject({ + database, + cwd, + recursive, + projectName, + useSqitch, + useTransaction: tx + }); - await revert(options, selectedProject, database, cwd, { useSqitch, useTransaction: tx }); - log.success('Revert complete.'); - } else { - const pgEnv = getPgEnvOptions(); - if (useSqitch) { - log.info(`Running: sqitch revert db:pg:${database} (using legacy Sqitch)`); - execSync(`sqitch revert db:pg:${database}`, { - cwd, - env: getSpawnEnvWithPg(pgEnv), - stdio: 'inherit' - }); - } else { - log.info(`Running: launchql migrate revert db:pg:${database}`); - await revertCommand(pgEnv, database, cwd, { useTransaction: tx }); - } - log.success('Revert complete.'); - } + log.success('Revert complete.'); return argv; }; diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts index d404c640b..890bf0fd6 100644 --- a/packages/cli/src/commands/verify.ts +++ b/packages/cli/src/commands/verify.ts @@ -1,11 +1,8 @@ import { CLIOptions, Inquirerer, Question } from 'inquirerer'; -import { LaunchQLProject, verify } from '@launchql/core'; -import { errors, getEnvOptions, LaunchQLOptions } from '@launchql/types'; -import { getPgEnvOptions, getSpawnEnvWithPg } from 'pg-env'; import { Logger } from '@launchql/logger'; -import { verifyCommand } from '@launchql/migrate'; -import { execSync } from 'child_process'; +import { verifyProject } from '@launchql/migrate'; import { getTargetDatabase } from '../utils'; +import { selectModule } from '../utils/module-utils'; const log = new Logger('verify'); @@ -24,52 +21,21 @@ export default async ( log.debug(`Using current directory: ${cwd}`); - const project = new LaunchQLProject(cwd); - + let projectName: string | undefined; if (recursive) { - const modules = await project.getModules(); - const moduleNames = modules.map(mod => mod.getModuleName()); - - if (!moduleNames.length) { - log.error('No modules found in the specified directory.'); - prompter.close(); - throw errors.NOT_FOUND({}, 'No modules found in the specified directory.'); - } - - const { project: selectedProject } = await prompter.prompt(argv, [ - { - type: 'autocomplete', - name: 'project', - message: 'Choose a project to verify', - options: moduleNames, - required: true - } - ]); + projectName = await selectModule(argv, prompter, 'Choose a project to verify', cwd); + log.info(`Selected project: ${projectName}`); + } - const options: LaunchQLOptions = getEnvOptions({ - pg: { - database - } - }); + await verifyProject({ + database, + cwd, + recursive, + projectName, + useSqitch + }); - log.info(`Verifying project ${selectedProject} on database ${database}...`); - await verify(options, selectedProject, database, cwd, { useSqitch }); - log.success('Verify complete.'); - } else { - const pgEnv = getPgEnvOptions(); - if (useSqitch) { - log.info(`Running: sqitch verify db:pg:${database} (using legacy Sqitch)`); - execSync(`sqitch verify db:pg:${database}`, { - cwd, - env: getSpawnEnvWithPg(pgEnv), - stdio: 'inherit' - }); - } else { - log.info(`Running: launchql migrate verify db:pg:${database}`); - await verifyCommand(pgEnv, database, cwd); - } - log.success('Verify complete.'); - } + log.success('Verify complete.'); return argv; }; diff --git a/packages/cli/src/utils/module-utils.ts b/packages/cli/src/utils/module-utils.ts new file mode 100644 index 000000000..1ec11f8b2 --- /dev/null +++ b/packages/cli/src/utils/module-utils.ts @@ -0,0 +1,31 @@ +import { Inquirerer } from 'inquirerer'; +import { ParsedArgs } from 'minimist'; +import { getAvailableModules } from '@launchql/migrate'; +import { errors } from '@launchql/types'; + +/** + * Prompt user to select a module from available modules in the directory + */ +export async function selectModule( + argv: Partial, + prompter: Inquirerer, + message: string, + cwd: string +): Promise { + const modules = await getAvailableModules(cwd); + + if (!modules.length) { + prompter.close(); + throw errors.NOT_FOUND({}, 'No modules found in the specified directory.'); + } + + const { project } = await prompter.prompt(argv, [{ + type: 'autocomplete', + name: 'project', + message, + options: modules, + required: true + }]); + + return project; +} \ No newline at end of file diff --git a/packages/migrate/__tests__/project-commands.test.ts b/packages/migrate/__tests__/project-commands.test.ts new file mode 100644 index 000000000..0328e82c4 --- /dev/null +++ b/packages/migrate/__tests__/project-commands.test.ts @@ -0,0 +1,114 @@ +import { deployProject, revertProject, verifyProject, getAvailableModules } from '../src/project-commands'; +import { runSqitch } from '../src/sqitch-wrapper'; +import { deployCommand } from '../src/commands/deploy'; +import * as core from '@launchql/core'; + +// Mock dependencies +jest.mock('../src/sqitch-wrapper'); +jest.mock('../src/commands/deploy'); +jest.mock('../src/commands/revert'); +jest.mock('../src/commands/verify'); +jest.mock('@launchql/core'); +jest.mock('pg-env', () => ({ + getPgEnvOptions: jest.fn(() => ({ + host: 'localhost', + port: 5432, + user: 'postgres', + password: '', + database: 'postgres' + })) +})); + +describe('project-commands', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('deployProject', () => { + it('should handle non-recursive sqitch deployment', async () => { + await deployProject({ + database: 'testdb', + cwd: '/test/path', + recursive: false, + useSqitch: true + }); + + expect(runSqitch).toHaveBeenCalledWith('deploy', 'testdb', '/test/path'); + expect(deployCommand).not.toHaveBeenCalled(); + }); + + it('should handle non-recursive migrate deployment', async () => { + await deployProject({ + database: 'testdb', + cwd: '/test/path', + recursive: false, + useSqitch: false, + useTransaction: true + }); + + expect(runSqitch).not.toHaveBeenCalled(); + expect(deployCommand).toHaveBeenCalledWith( + expect.any(Object), + 'testdb', + '/test/path', + { useTransaction: true, toChange: undefined } + ); + }); + + it('should handle recursive deployment', async () => { + const mockProject = { + getModuleMap: jest.fn(() => ({ + 'test-module': { path: '/test/module/path' } + })) + }; + + (core.LaunchQLProject as jest.Mock).mockImplementation(() => mockProject); + (core.deploy as jest.Mock).mockResolvedValue(undefined); + + await deployProject({ + database: 'testdb', + cwd: '/test/path', + recursive: true, + projectName: 'test-module', + useSqitch: true, + useTransaction: true + }); + + expect(core.deploy).toHaveBeenCalledWith( + expect.any(Object), + 'test-module', + 'testdb', + '/test/module/path', + { useSqitch: true, useTransaction: true } + ); + }); + + it('should throw error if projectName is missing for recursive', async () => { + await expect(deployProject({ + database: 'testdb', + cwd: '/test/path', + recursive: true, + useSqitch: true + })).rejects.toThrow('projectName is required when recursive is true'); + }); + }); + + describe('getAvailableModules', () => { + it('should return module names', async () => { + const mockModules = [ + { getModuleName: () => 'module1' }, + { getModuleName: () => 'module2' } + ]; + + const mockProject = { + getModules: jest.fn().mockResolvedValue(mockModules) + }; + + (core.LaunchQLProject as jest.Mock).mockImplementation(() => mockProject); + + const modules = await getAvailableModules('/test/path'); + + expect(modules).toEqual(['module1', 'module2']); + }); + }); +}); \ No newline at end of file diff --git a/packages/migrate/src/index.ts b/packages/migrate/src/index.ts index 46f091367..06386d19c 100644 --- a/packages/migrate/src/index.ts +++ b/packages/migrate/src/index.ts @@ -6,4 +6,6 @@ export { readScript, scriptExists } from './utils/fs'; export { withTransaction, TransactionContext, TransactionOptions } from './utils/transaction'; export { deployCommand } from './commands/deploy'; export { revertCommand } from './commands/revert'; -export { verifyCommand } from './commands/verify'; \ No newline at end of file +export { verifyCommand } from './commands/verify'; +export { deployProject, revertProject, verifyProject, getAvailableModules, MigrationOptions } from './project-commands'; +export { runSqitch } from './sqitch-wrapper'; \ No newline at end of file diff --git a/packages/migrate/src/project-commands.ts b/packages/migrate/src/project-commands.ts new file mode 100644 index 000000000..02c793709 --- /dev/null +++ b/packages/migrate/src/project-commands.ts @@ -0,0 +1,164 @@ +import { LaunchQLProject, deploy, revert, verify } from '@launchql/core'; +import { getEnvOptions } from '@launchql/types'; +import { getPgEnvOptions } from 'pg-env'; +import { Logger } from '@launchql/logger'; +import { deployCommand } from './commands/deploy'; +import { revertCommand } from './commands/revert'; +import { verifyCommand } from './commands/verify'; +import { runSqitch } from './sqitch-wrapper'; + +const log = new Logger('project-commands'); + +export interface MigrationOptions { + database: string; + cwd: string; + recursive?: boolean; + projectName?: string; // Required if recursive=true + useSqitch?: boolean; + useTransaction?: boolean; + toChange?: string; +} + +/** + * Deploy a project - handles both recursive (multi-module) and non-recursive (single directory) deployments + */ +export async function deployProject(options: MigrationOptions): Promise { + if (options.recursive) { + if (!options.projectName) { + throw new Error('projectName is required when recursive is true'); + } + + // Use existing deploy() from core that handles dependencies + const project = new LaunchQLProject(options.cwd); + const modules = project.getModuleMap(); + + if (!modules[options.projectName]) { + throw new Error(`Module "${options.projectName}" not found`); + } + + const modulePath = modules[options.projectName].path; + log.info(`Deploying project ${options.projectName} from ${modulePath} to database ${options.database}...`); + + await deploy( + getEnvOptions({ pg: { database: options.database } }), + options.projectName, + options.database, + modulePath, + { + useSqitch: options.useSqitch, + useTransaction: options.useTransaction + } + ); + } else { + // Direct execution on current directory + if (options.useSqitch) { + await runSqitch('deploy', options.database, options.cwd); + } else { + await deployCommand( + getPgEnvOptions(), + options.database, + options.cwd, + { + useTransaction: options.useTransaction, + toChange: options.toChange + } + ); + } + } +} + +/** + * Revert a project - handles both recursive (multi-module) and non-recursive (single directory) reverts + */ +export async function revertProject(options: MigrationOptions): Promise { + if (options.recursive) { + if (!options.projectName) { + throw new Error('projectName is required when recursive is true'); + } + + // Use existing revert() from core + const project = new LaunchQLProject(options.cwd); + const modules = project.getModuleMap(); + + if (!modules[options.projectName]) { + throw new Error(`Module "${options.projectName}" not found`); + } + + log.info(`Reverting project ${options.projectName} on database ${options.database}...`); + + await revert( + getEnvOptions({ pg: { database: options.database } }), + options.projectName, + options.database, + options.cwd, + { + useSqitch: options.useSqitch, + useTransaction: options.useTransaction + } + ); + } else { + // Direct execution on current directory + if (options.useSqitch) { + await runSqitch('revert', options.database, options.cwd); + } else { + await revertCommand( + getPgEnvOptions(), + options.database, + options.cwd, + { + useTransaction: options.useTransaction, + toChange: options.toChange + } + ); + } + } +} + +/** + * Verify a project - handles both recursive (multi-module) and non-recursive (single directory) verification + */ +export async function verifyProject(options: MigrationOptions): Promise { + if (options.recursive) { + if (!options.projectName) { + throw new Error('projectName is required when recursive is true'); + } + + // Use existing verify() from core + const project = new LaunchQLProject(options.cwd); + const modules = project.getModuleMap(); + + if (!modules[options.projectName]) { + throw new Error(`Module "${options.projectName}" not found`); + } + + log.info(`Verifying project ${options.projectName} on database ${options.database}...`); + + await verify( + getEnvOptions({ pg: { database: options.database } }), + options.projectName, + options.database, + options.cwd, + { useSqitch: options.useSqitch } + ); + } else { + // Direct execution on current directory + if (options.useSqitch) { + await runSqitch('verify', options.database, options.cwd); + } else { + await verifyCommand( + getPgEnvOptions(), + options.database, + options.cwd + ); + } + } +} + +/** + * Get available modules in a directory + */ +export async function getAvailableModules(cwd: string): Promise { + const project = new LaunchQLProject(cwd); + const modules = await project.getModules(); + return modules.map(mod => mod.getModuleName()); +} \ No newline at end of file diff --git a/packages/migrate/src/sqitch-wrapper.ts b/packages/migrate/src/sqitch-wrapper.ts new file mode 100644 index 000000000..98ab0a2c9 --- /dev/null +++ b/packages/migrate/src/sqitch-wrapper.ts @@ -0,0 +1,25 @@ +import { execSync } from 'child_process'; +import { getSpawnEnvWithPg, getPgEnvOptions } from 'pg-env'; +import { Logger } from '@launchql/logger'; + +const log = new Logger('sqitch-wrapper'); + +/** + * Wrapper for executing sqitch commands + */ +export async function runSqitch( + command: 'deploy' | 'revert' | 'verify', + database: string, + cwd: string +): Promise { + const pgEnv = getPgEnvOptions(); + const cmd = `sqitch ${command} db:pg:${database}`; + + log.info(`Running: ${cmd}`); + + execSync(cmd, { + cwd, + env: getSpawnEnvWithPg(pgEnv), + stdio: 'inherit' + }); +} \ No newline at end of file