From 7f1ff7fd86cf37b693fe734cbff305c06b4f62e0 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 25 Oct 2025 09:59:57 -1000 Subject: [PATCH 1/9] fix(billing): should allow restoring subscription (#1728) * fix(already-cancelled-sub): UI should allow restoring subscription * restore functionality fixed * fix --- apps/sim/app/api/billing/portal/route.ts | 7 +- .../cancel-subscription.tsx | 106 +++++++++--------- .../components/subscription/subscription.tsx | 1 + apps/sim/lib/billing/core/billing.ts | 12 ++ apps/sim/stores/subscription/types.ts | 1 + 5 files changed, 70 insertions(+), 57 deletions(-) diff --git a/apps/sim/app/api/billing/portal/route.ts b/apps/sim/app/api/billing/portal/route.ts index 017fbb8bd7..959a83cd7f 100644 --- a/apps/sim/app/api/billing/portal/route.ts +++ b/apps/sim/app/api/billing/portal/route.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' import { subscription as subscriptionTable, user } from '@sim/db/schema' -import { and, eq } from 'drizzle-orm' +import { and, eq, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { requireStripeClient } from '@/lib/billing/stripe-client' @@ -38,7 +38,10 @@ export async function POST(request: NextRequest) { .where( and( eq(subscriptionTable.referenceId, organizationId), - eq(subscriptionTable.status, 'active') + or( + eq(subscriptionTable.status, 'active'), + eq(subscriptionTable.cancelAtPeriodEnd, true) + ) ) ) .limit(1) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/cancel-subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/cancel-subscription.tsx index 1f5ea569aa..fd81cec55e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/cancel-subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/cancel-subscription/cancel-subscription.tsx @@ -12,7 +12,6 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { useSession, useSubscription } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' import { getBaseUrl } from '@/lib/urls/utils' @@ -30,6 +29,7 @@ interface CancelSubscriptionProps { } subscriptionData?: { periodEnd?: Date | null + cancelAtPeriodEnd?: boolean } } @@ -127,35 +127,48 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub const subscriptionStatus = getSubscriptionStatus() const activeOrgId = activeOrganization?.id - // For team/enterprise plans, get the subscription ID from organization store - if ((subscriptionStatus.isTeam || subscriptionStatus.isEnterprise) && activeOrgId) { - const orgSubscription = useOrganizationStore.getState().subscriptionData + if (isCancelAtPeriodEnd) { + if (!betterAuthSubscription.restore) { + throw new Error('Subscription restore not available') + } + + let referenceId: string + let subscriptionId: string | undefined + + if ((subscriptionStatus.isTeam || subscriptionStatus.isEnterprise) && activeOrgId) { + const orgSubscription = useOrganizationStore.getState().subscriptionData + referenceId = activeOrgId + subscriptionId = orgSubscription?.id + } else { + // For personal subscriptions, use user ID and let better-auth find the subscription + referenceId = session.user.id + subscriptionId = undefined + } + + logger.info('Restoring subscription', { referenceId, subscriptionId }) - if (orgSubscription?.id && orgSubscription?.cancelAtPeriodEnd) { - // Restore the organization subscription - if (!betterAuthSubscription.restore) { - throw new Error('Subscription restore not available') - } - - const result = await betterAuthSubscription.restore({ - referenceId: activeOrgId, - subscriptionId: orgSubscription.id, - }) - logger.info('Organization subscription restored successfully', result) + // Build restore params - only include subscriptionId if we have one (team/enterprise) + const restoreParams: any = { referenceId } + if (subscriptionId) { + restoreParams.subscriptionId = subscriptionId } + + const result = await betterAuthSubscription.restore(restoreParams) + + logger.info('Subscription restored successfully', result) } - // Refresh state and close await refresh() if (activeOrgId) { await loadOrganizationSubscription(activeOrgId) await refreshOrganization().catch(() => {}) } + setIsDialogOpen(false) } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Failed to keep subscription' + const errorMessage = error instanceof Error ? error.message : 'Failed to restore subscription' setError(errorMessage) - logger.error('Failed to keep subscription', { error }) + logger.error('Failed to restore subscription', { error }) } finally { setIsLoading(false) } @@ -190,19 +203,15 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub const periodEndDate = getPeriodEndDate() // Check if subscription is set to cancel at period end - const isCancelAtPeriodEnd = (() => { - const subscriptionStatus = getSubscriptionStatus() - if (subscriptionStatus.isTeam || subscriptionStatus.isEnterprise) { - return useOrganizationStore.getState().subscriptionData?.cancelAtPeriodEnd === true - } - return false - })() + const isCancelAtPeriodEnd = subscriptionData?.cancelAtPeriodEnd === true return ( <>
- Manage Subscription + + {isCancelAtPeriodEnd ? 'Restore Subscription' : 'Manage Subscription'} + {isCancelAtPeriodEnd && (

You'll keep access until {formatDate(periodEndDate)} @@ -217,10 +226,12 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub 'h-8 rounded-[8px] font-medium text-xs transition-all duration-200', error ? 'border-red-500 text-red-500 dark:border-red-500 dark:text-red-500' - : 'text-muted-foreground hover:border-red-500 hover:bg-red-500 hover:text-white dark:hover:border-red-500 dark:hover:bg-red-500' + : isCancelAtPeriodEnd + ? 'text-muted-foreground hover:border-green-500 hover:bg-green-500 hover:text-white dark:hover:border-green-500 dark:hover:bg-green-500' + : 'text-muted-foreground hover:border-red-500 hover:bg-red-500 hover:text-white dark:hover:border-red-500 dark:hover:bg-red-500' )} > - {error ? 'Error' : 'Manage'} + {error ? 'Error' : isCancelAtPeriodEnd ? 'Restore' : 'Manage'}

@@ -228,11 +239,11 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub - {isCancelAtPeriodEnd ? 'Manage' : 'Cancel'} {subscription.plan} subscription? + {isCancelAtPeriodEnd ? 'Restore' : 'Cancel'} {subscription.plan} subscription? {isCancelAtPeriodEnd - ? 'Your subscription is set to cancel at the end of the billing period. You can reactivate it or manage other settings.' + ? 'Your subscription is set to cancel at the end of the billing period. Would you like to keep your subscription active?' : `You'll be redirected to Stripe to manage your subscription. You'll keep access until ${formatDate( periodEndDate )}, then downgrade to free plan.`}{' '} @@ -260,38 +271,23 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub setIsDialogOpen(false) : handleKeep} disabled={isLoading} > - Keep Subscription + {isCancelAtPeriodEnd ? 'Cancel' : 'Keep Subscription'} {(() => { const subscriptionStatus = getSubscriptionStatus() - if ( - subscriptionStatus.isPaid && - (activeOrganization?.id - ? useOrganizationStore.getState().subscriptionData?.cancelAtPeriodEnd - : false) - ) { + if (subscriptionStatus.isPaid && isCancelAtPeriodEnd) { return ( - - - -
- - Continue - -
-
- -

Subscription will be cancelled at end of billing period

-
-
-
+ + {isLoading ? 'Restoring...' : 'Restore Subscription'} + ) } return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx index 9ac78581ef..b69b499aff 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx @@ -523,6 +523,7 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { }} subscriptionData={{ periodEnd: subscriptionData?.periodEnd || null, + cancelAtPeriodEnd: subscriptionData?.cancelAtPeriodEnd, }} />
diff --git a/apps/sim/lib/billing/core/billing.ts b/apps/sim/lib/billing/core/billing.ts index 48085eb821..55b6a207f7 100644 --- a/apps/sim/lib/billing/core/billing.ts +++ b/apps/sim/lib/billing/core/billing.ts @@ -220,6 +220,7 @@ export async function getSimplifiedBillingSummary( metadata: any stripeSubscriptionId: string | null periodEnd: Date | string | null + cancelAtPeriodEnd?: boolean // Usage details usage: { current: number @@ -318,6 +319,7 @@ export async function getSimplifiedBillingSummary( metadata: subscription.metadata || null, stripeSubscriptionId: subscription.stripeSubscriptionId || null, periodEnd: subscription.periodEnd || null, + cancelAtPeriodEnd: subscription.cancelAtPeriodEnd || undefined, // Usage details usage: { current: usageData.currentUsage, @@ -393,6 +395,7 @@ export async function getSimplifiedBillingSummary( metadata: subscription?.metadata || null, stripeSubscriptionId: subscription?.stripeSubscriptionId || null, periodEnd: subscription?.periodEnd || null, + cancelAtPeriodEnd: subscription?.cancelAtPeriodEnd || undefined, // Usage details usage: { current: currentUsage, @@ -450,5 +453,14 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') { lastPeriodCost: 0, daysRemaining: 0, }, + ...(type === 'organization' && { + organizationData: { + seatCount: 0, + memberCount: 0, + totalBasePrice: 0, + totalCurrentUsage: 0, + totalOverage: 0, + }, + }), } } diff --git a/apps/sim/stores/subscription/types.ts b/apps/sim/stores/subscription/types.ts index c0de147d45..643694b795 100644 --- a/apps/sim/stores/subscription/types.ts +++ b/apps/sim/stores/subscription/types.ts @@ -29,6 +29,7 @@ export interface SubscriptionData { metadata: any | null stripeSubscriptionId: string | null periodEnd: Date | null + cancelAtPeriodEnd?: boolean usage: UsageData billingBlocked?: boolean } From ec648dac3bd7217105d7b4b60fddfa4465f12c0d Mon Sep 17 00:00:00 2001 From: Sundaram Kumar Jha Date: Sat, 8 Nov 2025 16:00:19 +0530 Subject: [PATCH 2/9] feat(improvement): Add mock implementation for chalk module --- packages/cli/__tests__/__mocks__/chalk.ts | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 packages/cli/__tests__/__mocks__/chalk.ts diff --git a/packages/cli/__tests__/__mocks__/chalk.ts b/packages/cli/__tests__/__mocks__/chalk.ts new file mode 100644 index 0000000000..49ec13fa17 --- /dev/null +++ b/packages/cli/__tests__/__mocks__/chalk.ts @@ -0,0 +1,9 @@ +const chalk = { + blue: (str: string) => str, + green: (str: string) => str, + red: (str: string) => str, + yellow: (str: string) => str, + bold: (str: string) => str, +}; + +export default chalk; From fb92cb5e8fcac4e64cbc6c51ce0c310d5e653751 Mon Sep 17 00:00:00 2001 From: Sundaram Kumar Jha Date: Sat, 8 Nov 2025 16:02:35 +0530 Subject: [PATCH 3/9] feat: Add setup for testing environment and mocks --- packages/cli/__tests__/setup.ts | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 packages/cli/__tests__/setup.ts diff --git a/packages/cli/__tests__/setup.ts b/packages/cli/__tests__/setup.ts new file mode 100644 index 0000000000..707010b9e0 --- /dev/null +++ b/packages/cli/__tests__/setup.ts @@ -0,0 +1,8 @@ +import { TextEncoder, TextDecoder } from 'util'; + +// Set NODE_ENV to test +process.env.NODE_ENV = 'test'; + +// Mock global TextEncoder and TextDecoder for Node.js < 11 +global.TextEncoder = TextEncoder as any; +global.TextDecoder = TextDecoder as any; From 40db89c902c7ede58eddba62e54b72478bf456c3 Mon Sep 17 00:00:00 2001 From: Sundaram Kumar Jha Date: Sat, 8 Nov 2025 16:03:29 +0530 Subject: [PATCH 4/9] feat(tests): Implement unit tests for SimStudio CLI Add unit tests for SimStudio CLI functionality including secret generation, port availability checks, Docker status, command execution, and database operations. --- packages/cli/__tests__/index.test.ts | 381 +++++++++++++++++++++++++++ 1 file changed, 381 insertions(+) create mode 100644 packages/cli/__tests__/index.test.ts diff --git a/packages/cli/__tests__/index.test.ts b/packages/cli/__tests__/index.test.ts new file mode 100644 index 0000000000..fa1af527a4 --- /dev/null +++ b/packages/cli/__tests__/index.test.ts @@ -0,0 +1,381 @@ +import { execSync, spawn } from 'child_process'; +import { existsSync, mkdirSync } from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; +import * as indexModule from '../src/index'; + +jest.mock('child_process'); +jest.mock('fs'); +jest.mock('os'); +jest.mock('path'); +jest.mock('readline'); + +const mockExecSync = execSync as jest.MockedFunction < typeof execSync > ; +const mockSpawn = spawn as jest.MockedFunction < typeof spawn > ; +const mockExistsSync = existsSync as jest.MockedFunction < typeof existsSync > ; +const mockMkdirSync = mkdirSync as jest.MockedFunction < typeof mkdirSync > ; +const mockHomedir = homedir as jest.MockedFunction < typeof homedir > ; +const mockJoin = join as jest.MockedFunction < typeof join > ; + +describe('SimStudio CLI', () => { + let config: indexModule.Config; + let mockSpawnProcess: any; + let consoleLogSpy: jest.SpyInstance; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + + config = { + ...indexModule.DEFAULT_CONFIG, + port: 3000, + realtimePort: 3002, + betterAuthSecret: 'test-secret-32chars-long-enough', + encryptionKey: 'test-encryption-32chars-long', + } + as indexModule.Config; + + mockHomedir.mockReturnValue('/home/user'); + mockJoin.mockImplementation((...args) => args.join('/')); + + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + // Mock spawn return value + mockSpawnProcess = { + on: jest.fn().mockImplementation((event: string, cb: Function) => { + if (event === 'close') cb(0); + return mockSpawnProcess; + }), + }; + mockSpawn.mockReturnValue(mockSpawnProcess); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + describe('generateSecret', () => { + it('should generate a secret of specified length', () => { + const secret = indexModule.generateSecret(16); + expect(secret).toHaveLength(16); + expect(secret).toMatch(/^[a-zA-Z0-9]+$/); + }); + + it('should default to 32 characters', () => { + const secret = indexModule.generateSecret(); + expect(secret).toHaveLength(32); + }); + }); + + describe('isPortAvailable', () => { + it('should return true if port is available (command throws)', async () => { + mockExecSync.mockImplementation(() => { + throw new Error('Port not in use'); + }); + + const available = await indexModule.isPortAvailable(3000); + expect(available).toBe(true); + }); + + it('should return false if port is in use (command succeeds)', async () => { + mockExecSync.mockReturnValue(Buffer.from('output')); + + const available = await indexModule.isPortAvailable(3000); + expect(available).toBe(false); + }); + }); + + describe('isDockerRunning', () => { + it('should resolve true if Docker info succeeds', async () => { + mockSpawnProcess.on.mockImplementation((event: string, cb: Function) => { + if (event === 'close') cb(0); + return mockSpawnProcess; + }); + + const running = await indexModule.isDockerRunning(); + expect(running).toBe(true); + expect(mockSpawn).toHaveBeenCalledWith('docker', ['info'], { + stdio: 'ignore' + }); + }); + + it('should resolve false if Docker info fails', async () => { + mockSpawnProcess.on.mockImplementation((event: string, cb: Function) => { + if (event === 'close') cb(1); + return mockSpawnProcess; + }); + + const running = await indexModule.isDockerRunning(); + expect(running).toBe(false); + }); + + it('should resolve false on spawn error', async () => { + const errorProcess: any = { + on: jest.fn((event: string, cb: Function) => { + if (event === 'error') cb(new Error('spawn error')); + return errorProcess; + }), + }; + mockSpawn.mockReturnValueOnce(errorProcess as any); + + const running = await indexModule.isDockerRunning(); + expect(running).toBe(false); + }); + }); + + describe('runCommand', () => { + it('should resolve true if command succeeds (code 0)', async () => { + mockSpawnProcess.on.mockImplementation((event: string, cb: Function) => { + if (event === 'close') cb(0); + return mockSpawnProcess; + }); + + const success = await indexModule.runCommand(['docker', 'ps']); + expect(success).toBe(true); + expect(mockSpawn).toHaveBeenCalledWith('docker', ['ps'], { + stdio: 'inherit' + }); + }); + + it('should resolve false if command fails (code 1)', async () => { + mockSpawnProcess.on.mockImplementation((event: string, cb: Function) => { + if (event === 'close') cb(1); + return mockSpawnProcess; + }); + + const success = await indexModule.runCommand(['docker', 'ps']); + expect(success).toBe(false); + }); + + it('should resolve false on spawn error', async () => { + const errorProcess: any = { + on: jest.fn((event: string, cb: Function) => { + if (event === 'error') cb(new Error('error')); + return errorProcess; + }), + }; + mockSpawn.mockReturnValueOnce(errorProcess as any); + + const success = await indexModule.runCommand(['docker', 'ps']); + expect(success).toBe(false); + }); + }); + + describe('pullImage', () => { + it('should return true if pull succeeds', async () => { + const success = await indexModule.pullImage('test:image'); + expect(success).toBe(true); + expect(mockSpawn).toHaveBeenCalledWith('docker', ['pull', 'test:image'], { + stdio: 'inherit' + }); + }); + + it('should return false if pull fails', async () => { + mockSpawnProcess.on.mockImplementation((event: string, cb: Function) => { + if (event === 'close') cb(1); + return mockSpawnProcess; + }); + + const success = await indexModule.pullImage('test:image'); + expect(success).toBe(false); + }); + }); + + describe('stopAndRemoveContainer', () => { + it('should stop and remove container successfully', async () => { + await indexModule.stopAndRemoveContainer('test-container'); + expect(mockSpawn).toHaveBeenCalledWith('docker', ['stop', 'test-container'], { + stdio: 'inherit' + }); + expect(mockSpawn).toHaveBeenCalledWith('docker', ['rm', 'test-container'], { + stdio: 'inherit' + }); + }); + }); + + describe('cleanupExistingContainers', () => { + it('should call stopAndRemove for all containers', async () => { + await indexModule.cleanupExistingContainers(config); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Cleaning up')); + }); + }); + + describe('ensureDataDir', () => { + it('should create directory if it does not exist', () => { + mockExistsSync.mockReturnValueOnce(false); + + const success = indexModule.ensureDataDir('/test/dir'); + expect(success).toBe(true); + expect(mockMkdirSync).toHaveBeenCalledWith('/test/dir', { + recursive: true + }); + }); + + it('should return true if directory exists', () => { + mockExistsSync.mockReturnValueOnce(true); + + const success = indexModule.ensureDataDir('/test/dir'); + expect(success).toBe(true); + expect(mockMkdirSync).not.toHaveBeenCalled(); + }); + + it('should return false on mkdir error', () => { + mockExistsSync.mockReturnValueOnce(false); + mockMkdirSync.mockImplementation(() => { + throw new Error('mkdir error'); + }); + + const success = indexModule.ensureDataDir('/test/dir'); + expect(success).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + }); + + describe('startDatabase', () => { + it('should construct and run DB start command successfully', async () => { + const success = await indexModule.startDatabase(config); + expect(success).toBe(true); + expect(mockSpawn).toHaveBeenCalledWith('docker', expect.arrayContaining([ + 'run', '-d', '--name', config.dbContainer, + '--network', config.networkName, + ]), { + stdio: 'inherit' + }); + }); + + it('should return false if command fails', async () => { + mockSpawnProcess.on.mockImplementation((event: string, cb: Function) => { + if (event === 'close') cb(1); + return mockSpawnProcess; + }); + + const success = await indexModule.startDatabase(config); + expect(success).toBe(false); + }); + }); + + describe('waitForPgReady', () => { + it('should resolve true if PG becomes ready quickly', async () => { + let attempts = 0; + mockExecSync.mockImplementation(() => { + attempts++; + if (attempts === 2) { + return Buffer.from('ready'); + } + throw new Error('not ready'); + }); + + const ready = await indexModule.waitForPgReady('test-db', 5000); + expect(ready).toBe(true); + expect(mockExecSync).toHaveBeenCalled(); + }); + + it('should resolve false after timeout', async () => { + mockExecSync.mockImplementation(() => { + throw new Error('not ready'); + }); + + const ready = await indexModule.waitForPgReady('test-db', 100); + expect(ready).toBe(false); + }); + }); + + describe('runMigrations', () => { + it('should construct and run migrations command successfully', async () => { + const success = await indexModule.runMigrations(config); + expect(success).toBe(true); + expect(mockSpawn).toHaveBeenCalledWith('docker', expect.arrayContaining([ + 'run', '--rm', '--name', config.migrationsContainer, + '--network', config.networkName, + ]), { + stdio: 'inherit' + }); + }); + + it('should return false if command fails', async () => { + mockSpawnProcess.on.mockImplementation((event: string, cb: Function) => { + if (event === 'close') cb(1); + return mockSpawnProcess; + }); + + const success = await indexModule.runMigrations(config); + expect(success).toBe(false); + }); + }); + + describe('startRealtime', () => { + it('should construct and run Realtime start command successfully', async () => { + const success = await indexModule.startRealtime(config); + expect(success).toBe(true); + expect(mockSpawn).toHaveBeenCalledWith('docker', expect.arrayContaining([ + 'run', '-d', '--name', config.realtimeContainer, + '--network', config.networkName, + ]), { + stdio: 'inherit' + }); + }); + + it('should return false if command fails', async () => { + mockSpawnProcess.on.mockImplementation((event: string, cb: Function) => { + if (event === 'close') cb(1); + return mockSpawnProcess; + }); + + const success = await indexModule.startRealtime(config); + expect(success).toBe(false); + }); + }); + + describe('startApp', () => { + it('should construct and run App start command successfully', async () => { + const success = await indexModule.startApp(config); + expect(success).toBe(true); + expect(mockSpawn).toHaveBeenCalledWith('docker', expect.arrayContaining([ + 'run', '-d', '--name', config.appContainer, + '--network', config.networkName, + ]), { + stdio: 'inherit' + }); + }); + + it('should return false if command fails', async () => { + mockSpawnProcess.on.mockImplementation((event: string, cb: Function) => { + if (event === 'close') cb(1); + return mockSpawnProcess; + }); + + const success = await indexModule.startApp(config); + expect(success).toBe(false); + }); + }); + + describe('printSuccess', () => { + it('should log success messages and stop command', () => { + indexModule.printSuccess(config); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Sim is now running')); + }); + }); + + describe('setupShutdownHandlers', () => { + it('should set up shutdown handlers', () => { + const mockRl = { + on: jest.fn(), + close: jest.fn(), + }; + const mockCreateInterface = require('readline').createInterface; + mockCreateInterface.mockReturnValue(mockRl); + + const processOnSpy = jest.spyOn(process, 'on'); + + indexModule.setupShutdownHandlers(config); + + expect(mockCreateInterface).toHaveBeenCalled(); + expect(processOnSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function)); + expect(processOnSpy).toHaveBeenCalledWith('uncaughtException', expect.any(Function)); + + processOnSpy.mockRestore(); + }); + }); +}); From 3c348a57b5fc5547b0677292674f29c63e61774b Mon Sep 17 00:00:00 2001 From: Sundaram Kumar Jha Date: Sat, 8 Nov 2025 16:04:32 +0530 Subject: [PATCH 5/9] refactor(cli): Enhance configuration and Docker command handling Refactor configuration handling and improve Docker commands. --- packages/cli/src/index.ts | 612 +++++++++++++++++++++++++------------- 1 file changed, 409 insertions(+), 203 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0e95d65e1b..4259cb8956 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,282 +5,488 @@ import { existsSync, mkdirSync } from 'fs' import { homedir } from 'os' import { join } from 'path' import { createInterface } from 'readline' +import { randomBytes } from 'crypto' import chalk from 'chalk' import { Command } from 'commander' -const NETWORK_NAME = 'simstudio-network' -const DB_CONTAINER = 'simstudio-db' -const MIGRATIONS_CONTAINER = 'simstudio-migrations' -const REALTIME_CONTAINER = 'simstudio-realtime' -const APP_CONTAINER = 'simstudio-app' -const DEFAULT_PORT = '3000' +export interface Config { + port: number + pullImages: boolean + yes: boolean + dataDir: string + networkName: string + dbContainer: string + migrationsContainer: string + realtimeContainer: string + appContainer: string + dbImage: string + migrationsImage: string + realtimeImage: string + appImage: string + postgresUser: string + postgresPassword: string + postgresDb: string + realtimePort: number + betterAuthSecret: string + encryptionKey: string +} -const program = new Command() +export const DEFAULT_CONFIG: Partial = { + port: 3000, + pullImages: true, + yes: false, + dataDir: join(homedir(), '.simstudio', 'data'), + networkName: 'simstudio-network', + dbContainer: 'simstudio-db', + migrationsContainer: 'simstudio-migrations', + realtimeContainer: 'simstudio-realtime', + appContainer: 'simstudio-app', + dbImage: 'pgvector/pgvector:pg17', + migrationsImage: 'ghcr.io/simstudioai/migrations:latest', + realtimeImage: 'ghcr.io/simstudioai/realtime:latest', + appImage: 'ghcr.io/simstudioai/simstudio:latest', + postgresUser: 'postgres', + postgresPassword: 'postgres', + postgresDb: 'simstudio', + realtimePort: 3002, + betterAuthSecret: '', + encryptionKey: '', +} -program.name('simstudio').description('Run Sim using Docker').version('0.1.0') +const program = new Command() + .name('simstudio') + .description('Run Sim using Docker') + .version('0.1.0') program - .option('-p, --port ', 'Port to run Sim on', DEFAULT_PORT) - .option('-y, --yes', 'Skip interactive prompts and use defaults') + .option('-p, --port ', 'Port to run Sim on', `${DEFAULT_CONFIG.port}`) + .option('-r, --realtime-port ', 'Port for Realtime server', `${DEFAULT_CONFIG.realtimePort}`) + .option('-d, --data-dir ', 'Data directory for persistent storage', DEFAULT_CONFIG.dataDir) .option('--no-pull', 'Skip pulling the latest Docker images') + .option('-y, --yes', 'Skip interactive prompts and use defaults') -function isDockerRunning(): Promise { - return new Promise((resolve) => { - const docker = spawn('docker', ['info']) +/** + * Generates a random secret string of specified length. + * @param length - The length of the secret. + * @returns Base64-encoded random bytes as string. + */ +export function generateSecret(length: number = 32): string { + return randomBytes(length).toString('base64').replace(/[^a-zA-Z0-9]/g, '').substring(0, length) +} + +/** + * Validates if a port is available (simple check via netstat-like command). + * @param port - The port to check. + * @returns True if port is available. + */ +export async function isPortAvailable(port: number): Promise { + try { + execSync(`lsof -i :${port} || netstat -an | grep :${port} || ss -tuln | grep :${port}`, { stdio: 'ignore' }) + return false // Port in use if command succeeds without error + } catch { + return true // Port available + } +} - docker.on('close', (code) => { - resolve(code === 0) - }) +/** + * Checks if Docker is running. + * @returns Promise resolving to true if Docker is running. + */ +export async function isDockerRunning(): Promise { + return new Promise((resolve) => { + const docker = spawn('docker', ['info'], { stdio: 'ignore' }) + docker.on('close', (code) => resolve(code === 0)) + docker.on('error', () => resolve(false)) }) } -async function runCommand(command: string[]): Promise { +/** + * Runs a shell command asynchronously, inheriting stdio for output. + * @param command - Array of command and args. + * @returns Promise resolving to true if command succeeded. + */ +export async function runCommand(command: string[]): Promise { return new Promise((resolve) => { - const process = spawn(command[0], command.slice(1), { stdio: 'inherit' }) - process.on('error', () => { - resolve(false) - }) - process.on('close', (code) => { - resolve(code === 0) - }) + const [cmd, ...args] = command + const process = spawn(cmd, args, { stdio: 'inherit' }) + process.on('error', () => resolve(false)) + process.on('close', (code) => resolve(code === 0)) }) } -async function ensureNetworkExists(): Promise { +/** + * Ensures the Docker network exists. + * @param networkName - Name of the network. + * @returns Promise resolving to true if network exists or was created. + */ +export async function ensureNetworkExists(networkName: string): Promise { try { - const networks = execSync('docker network ls --format "{{.Name}}"').toString() - if (!networks.includes(NETWORK_NAME)) { - console.log(chalk.blue(`๐Ÿ”„ Creating Docker network '${NETWORK_NAME}'...`)) - return await runCommand(['docker', 'network', 'create', NETWORK_NAME]) + const networksOutput = execSync('docker network ls --format "{{.Name}}"', { encoding: 'utf8' }) + if (!networksOutput.trim().split('\n').includes(networkName)) { + console.log(chalk.blue(`๐Ÿ”„ Creating Docker network '${networkName}'...`)) + return await runCommand(['docker', 'network', 'create', networkName]) } + console.log(chalk.blue(`โœ… Docker network '${networkName}' already exists.`)) return true } catch (error) { - console.error('Failed to check networks:', error) + console.error(chalk.red(`โŒ Failed to ensure network '${networkName}':`), error) return false } } -async function pullImage(image: string): Promise { +/** + * Pulls a Docker image if specified. + * @param image - The image name and tag. + * @returns Promise resolving to true if pull succeeded. + */ +export async function pullImage(image: string): Promise { console.log(chalk.blue(`๐Ÿ”„ Pulling image ${image}...`)) return await runCommand(['docker', 'pull', image]) } -async function stopAndRemoveContainer(name: string): Promise { +/** + * Stops and removes a container if it exists. + * @param name - Container name. + */ +export async function stopAndRemoveContainer(name: string): Promise { try { - execSync(`docker stop ${name} 2>/dev/null || true`) - execSync(`docker rm ${name} 2>/dev/null || true`) - } catch (_error) { - // Ignore errors, container might not exist + await runCommand(['docker', 'stop', name]) + await runCommand(['docker', 'rm', name]) + } catch (error) { + // Ignore if container doesn't exist + console.debug(`Container ${name} not found or already stopped.`) } } -async function cleanupExistingContainers(): Promise { - console.log(chalk.blue('๐Ÿงน Cleaning up any existing containers...')) - await stopAndRemoveContainer(APP_CONTAINER) - await stopAndRemoveContainer(DB_CONTAINER) - await stopAndRemoveContainer(MIGRATIONS_CONTAINER) - await stopAndRemoveContainer(REALTIME_CONTAINER) +/** + * Cleans up all existing containers. + * @param config - Configuration object. + * @returns Promise resolving when cleanup is complete. + */ +export async function cleanupExistingContainers(config: Config): Promise { + console.log(chalk.blue('๐Ÿงน Cleaning up existing containers...')) + await Promise.all([ + stopAndRemoveContainer(config.appContainer), + stopAndRemoveContainer(config.dbContainer), + stopAndRemoveContainer(config.migrationsContainer), + stopAndRemoveContainer(config.realtimeContainer), + ]) } -async function main() { - const options = program.parse().opts() +/** + * Creates the data directory if it doesn't exist. + * @param dataDir - Path to data directory. + * @returns True if directory was created or exists. + */ +export function ensureDataDir(dataDir: string): boolean { + if (!existsSync(dataDir)) { + try { + mkdirSync(dataDir, { recursive: true }) + console.log(chalk.blue(`๐Ÿ“ Created data directory: ${dataDir}`)) + return true + } catch (error) { + console.error(chalk.red(`โŒ Failed to create data directory '${dataDir}':`), error) + return false + } + } + console.log(chalk.blue(`โœ… Data directory exists: ${dataDir}`)) + return true +} - console.log(chalk.blue('๐Ÿš€ Starting Sim...')) +/** + * Starts the PostgreSQL container. + * @param config - Configuration object. + * @returns Promise resolving to true if DB started successfully. + */ +export async function startDatabase(config: Config): Promise { + console.log(chalk.blue('๐Ÿ”„ Starting PostgreSQL database...')) + const volume = `${config.dataDir}/postgres:/var/lib/postgresql/data` + const dbPort = '5432:5432' + const envVars = [ + '-e', `POSTGRES_USER=${config.postgresUser}`, + '-e', `POSTGRES_PASSWORD=${config.postgresPassword}`, + '-e', `POSTGRES_DB=${config.postgresDb}`, + ] + const command = [ + 'docker', 'run', '-d', '--name', config.dbContainer, + '--network', config.networkName, + '-v', volume, + '-p', dbPort, + ...envVars, + config.dbImage, + ] + return await runCommand(command) +} - // Check if Docker is installed and running - const dockerRunning = await isDockerRunning() - if (!dockerRunning) { - console.error( - chalk.red('โŒ Docker is not running or not installed. Please start Docker and try again.') - ) - process.exit(1) +/** + * Waits for PostgreSQL to be ready with timeout. + * @param containerName - DB container name. + * @param timeoutMs - Timeout in milliseconds (default 5 minutes). + * @returns Promise resolving to true if ready within timeout. + */ +export async function waitForPgReady(containerName: string, timeoutMs: number = 300000): Promise { + console.log(chalk.blue('โณ Waiting for PostgreSQL to be ready...')) + const startTime = Date.now() + while (Date.now() - startTime < timeoutMs) { + try { + execSync(`docker exec ${containerName} pg_isready -U ${DEFAULT_CONFIG.postgresUser!}`, { stdio: 'ignore' }) + console.log(chalk.green('โœ… PostgreSQL is ready!')) + return true + } catch { + // Wait 2s between checks + await new Promise(resolve => setTimeout(resolve, 2000)) + } } + return false +} + +/** + * Runs database migrations. + * @param config - Configuration object. + * @returns Promise resolving to true if migrations succeeded. + */ +export async function runMigrations(config: Config): Promise { + console.log(chalk.blue('๐Ÿ”„ Running database migrations...')) + const dbUrl = `postgresql://${config.postgresUser}:${config.postgresPassword}@${config.dbContainer}:5432/${config.postgresDb}` + const command = [ + 'docker', 'run', '--rm', '--name', config.migrationsContainer, + '--network', config.networkName, + '-e', `DATABASE_URL=${dbUrl}`, + config.migrationsImage, 'bun', 'run', 'db:migrate', + ] + return await runCommand(command) +} + +/** + * Starts the Realtime server container. + * @param config - Configuration object. + * @returns Promise resolving to true if Realtime started successfully. + */ +export async function startRealtime(config: Config): Promise { + console.log(chalk.blue('๐Ÿ”„ Starting Realtime Server...')) + const dbUrl = `postgresql://${config.postgresUser}:${config.postgresPassword}@${config.dbContainer}:5432/${config.postgresDb}` + const appUrl = `http://localhost:${config.port}` + const realtimePortMapping = `${config.realtimePort}:3002` + const envVars = [ + '-e', `DATABASE_URL=${dbUrl}`, + '-e', `BETTER_AUTH_URL=${appUrl}`, + '-e', `NEXT_PUBLIC_APP_URL=${appUrl}`, + '-e', `BETTER_AUTH_SECRET=${config.betterAuthSecret}`, + ] + const command = [ + 'docker', 'run', '-d', '--name', config.realtimeContainer, + '--network', config.networkName, + '-p', realtimePortMapping, + ...envVars, + config.realtimeImage, + ] + return await runCommand(command) +} - // Use port from options, with 3000 as default - const port = options.port +/** + * Starts the main Sim application container. + * @param config - Configuration object. + * @returns Promise resolving to true if App started successfully. + */ +export async function startApp(config: Config): Promise { + console.log(chalk.blue('๐Ÿ”„ Starting Sim application...')) + const dbUrl = `postgresql://${config.postgresUser}:${config.postgresPassword}@${config.dbContainer}:5432/${config.postgresDb}` + const appUrl = `http://localhost:${config.port}` + const portMapping = `${config.port}:3000` + const envVars = [ + '-e', `DATABASE_URL=${dbUrl}`, + '-e', `BETTER_AUTH_URL=${appUrl}`, + '-e', `NEXT_PUBLIC_APP_URL=${appUrl}`, + '-e', `BETTER_AUTH_SECRET=${config.betterAuthSecret}`, + '-e', `ENCRYPTION_KEY=${config.encryptionKey}`, + ] + const command = [ + 'docker', 'run', '-d', '--name', config.appContainer, + '--network', config.networkName, + '-p', portMapping, + ...envVars, + config.appImage, + ] + return await runCommand(command) +} - // Pull latest images if not skipped - if (options.pull) { - await pullImage('ghcr.io/simstudioai/simstudio:latest') - await pullImage('ghcr.io/simstudioai/migrations:latest') - await pullImage('ghcr.io/simstudioai/realtime:latest') - await pullImage('pgvector/pgvector:pg17') +/** + * Prints success message and stop instructions. + * @param config - Configuration object. + */ +export function printSuccess(config: Config): void { + console.log(chalk.green(`โœ… Sim is now running at ${chalk.bold(`http://localhost:${config.port}`)}`)) + console.log(chalk.green(`โœ… Realtime server is running at ${chalk.bold(`http://localhost:${config.realtimePort}`)}`)) + const stopCmd = `docker stop ${config.appContainer} ${config.dbContainer} ${config.realtimeContainer}` + console.log(chalk.yellow(`๐Ÿ›‘ To stop all containers, run: ${chalk.bold(stopCmd)}`)) + + // Warn if secrets were auto-generated (not provided via env vars) + const hasEnvSecrets = process.env.BETTER_AUTH_SECRET && process.env.ENCRYPTION_KEY + if (!hasEnvSecrets) { + console.log(chalk.yellow('โš ๏ธ Auto-generated secrets are for development only. Set BETTER_AUTH_SECRET and ENCRYPTION_KEY env vars for production.')) } +} - // Ensure Docker network exists - if (!(await ensureNetworkExists())) { - console.error(chalk.red('โŒ Failed to create Docker network')) - process.exit(1) +/** + * Sets up signal handlers for graceful shutdown. + * @param config - Configuration object. + */ +export function setupShutdownHandlers(config: Config): void { + const signals = ['SIGINT', 'SIGTERM'] + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }) + + const shutdown = async (signal: string) => { + console.log(chalk.yellow(`\n๐Ÿ›‘ Received ${signal}. Stopping Sim...`)) + await Promise.all([ + stopAndRemoveContainer(config.appContainer), + stopAndRemoveContainer(config.realtimeContainer), + stopAndRemoveContainer(config.dbContainer), + ]) + console.log(chalk.green('โœ… Sim has been stopped gracefully.')) + rl.close() + process.exit(0) } - // Clean up any existing containers - await cleanupExistingContainers() + rl.on('SIGINT', () => shutdown('SIGINT')) + process.on('SIGTERM', () => shutdown('SIGTERM')) - // Create data directory - const dataDir = join(homedir(), '.simstudio', 'data') - if (!existsSync(dataDir)) { - try { - mkdirSync(dataDir, { recursive: true }) - } catch (_error) { - console.error(chalk.red(`โŒ Failed to create data directory: ${dataDir}`)) + // Handle uncaught exceptions + process.on('uncaughtException', (err) => { + console.error(chalk.red('โŒ Uncaught Exception:'), err) + shutdown('uncaughtException') + }) +} + +/** + * Main entry point. + */ +export async function main(): Promise { + const opts = program.parse().opts() + const config: Config = { + ...DEFAULT_CONFIG, + port: parseInt(opts.port as string, 10), + realtimePort: parseInt(opts.realtimePort as string, 10), + dataDir: opts.dataDir as string, + pullImages: !(opts.noPull as boolean), + yes: opts.yes as boolean, + betterAuthSecret: process.env.BETTER_AUTH_SECRET || generateSecret(), + encryptionKey: process.env.ENCRYPTION_KEY || generateSecret(), + } as Config + + // Validation + if (isNaN(config.port) || config.port < 1 || config.port > 65535) { + console.error(chalk.red('โŒ Invalid port. Must be between 1 and 65535.')) + process.exit(1) + } + if (isNaN(config.realtimePort) || config.realtimePort < 1 || config.realtimePort > 65535) { + console.error(chalk.red('โŒ Invalid realtime port. Must be between 1 and 65535.')) + process.exit(1) + } + if (config.port === config.realtimePort) { + console.error(chalk.red('โŒ App port and Realtime port must be different.')) + process.exit(1) + } + if (!config.betterAuthSecret || config.betterAuthSecret.length < 32) { + console.error(chalk.red('โŒ BETTER_AUTH_SECRET must be at least 32 characters. Set BETTER_AUTH_SECRET env var.')) + process.exit(1) + } + if (!config.encryptionKey || config.encryptionKey.length < 32) { + console.error(chalk.red('โŒ ENCRYPTION_KEY must be at least 32 characters. Set ENCRYPTION_KEY env var.')) + process.exit(1) + } + + // Check port availability if not --yes + if (!config.yes) { + const appAvailable = await isPortAvailable(config.port) + const realtimeAvailable = await isPortAvailable(config.realtimePort) + if (!appAvailable || !realtimeAvailable) { + console.error(chalk.red(`โŒ Port ${!appAvailable ? config.port : config.realtimePort} is already in use.`)) process.exit(1) } } - // Start PostgreSQL container - console.log(chalk.blue('๐Ÿ”„ Starting PostgreSQL database...')) - const dbSuccess = await runCommand([ - 'docker', - 'run', - '-d', - '--name', - DB_CONTAINER, - '--network', - NETWORK_NAME, - '-e', - 'POSTGRES_USER=postgres', - '-e', - 'POSTGRES_PASSWORD=postgres', - '-e', - 'POSTGRES_DB=simstudio', - '-v', - `${dataDir}/postgres:/var/lib/postgresql/data`, - '-p', - '5432:5432', - 'pgvector/pgvector:pg17', - ]) + console.log(chalk.blue('๐Ÿš€ Starting Sim...')) - if (!dbSuccess) { - console.error(chalk.red('โŒ Failed to start PostgreSQL')) + // Check Docker + if (!(await isDockerRunning())) { + console.error(chalk.red('โŒ Docker is not running or not installed. Please start Docker and try again.')) process.exit(1) } - // Wait for PostgreSQL to be ready - console.log(chalk.blue('โณ Waiting for PostgreSQL to be ready...')) - let pgReady = false - for (let i = 0; i < 30; i++) { - try { - execSync(`docker exec ${DB_CONTAINER} pg_isready -U postgres`) - pgReady = true - break - } catch (_error) { - await new Promise((resolve) => setTimeout(resolve, 1000)) + // Pull images + if (config.pullImages) { + const pullPromises = [ + config.dbImage, + config.migrationsImage, + config.realtimeImage, + config.appImage, + ].map(pullImage) + const results = await Promise.allSettled(pullPromises) + if (results.some(r => r.status === 'rejected' || (r.status === 'fulfilled' && !r.value))) { + console.error(chalk.red('โŒ Failed to pull one or more images.')) + process.exit(1) } } - if (!pgReady) { - console.error(chalk.red('โŒ PostgreSQL failed to become ready')) + // Ensure network + if (!(await ensureNetworkExists(config.networkName))) { + console.error(chalk.red('โŒ Failed to ensure Docker network.')) process.exit(1) } - // Run migrations - console.log(chalk.blue('๐Ÿ”„ Running database migrations...')) - const migrationsSuccess = await runCommand([ - 'docker', - 'run', - '--rm', - '--name', - MIGRATIONS_CONTAINER, - '--network', - NETWORK_NAME, - '-e', - `DATABASE_URL=postgresql://postgres:postgres@${DB_CONTAINER}:5432/simstudio`, - 'ghcr.io/simstudioai/migrations:latest', - 'bun', - 'run', - 'db:migrate', - ]) + // Cleanup + await cleanupExistingContainers(config) - if (!migrationsSuccess) { - console.error(chalk.red('โŒ Failed to run migrations')) + // Ensure data dir + if (!ensureDataDir(config.dataDir)) { process.exit(1) } - // Start the realtime server - console.log(chalk.blue('๐Ÿ”„ Starting Realtime Server...')) - const realtimeSuccess = await runCommand([ - 'docker', - 'run', - '-d', - '--name', - REALTIME_CONTAINER, - '--network', - NETWORK_NAME, - '-p', - '3002:3002', - '-e', - `DATABASE_URL=postgresql://postgres:postgres@${DB_CONTAINER}:5432/simstudio`, - '-e', - `BETTER_AUTH_URL=http://localhost:${port}`, - '-e', - `NEXT_PUBLIC_APP_URL=http://localhost:${port}`, - '-e', - 'BETTER_AUTH_SECRET=your_auth_secret_here', - 'ghcr.io/simstudioai/realtime:latest', - ]) - - if (!realtimeSuccess) { - console.error(chalk.red('โŒ Failed to start Realtime Server')) + // Start DB + if (!(await startDatabase(config))) { + console.error(chalk.red('โŒ Failed to start PostgreSQL.')) process.exit(1) } - // Start the main application - console.log(chalk.blue('๐Ÿ”„ Starting Sim...')) - const appSuccess = await runCommand([ - 'docker', - 'run', - '-d', - '--name', - APP_CONTAINER, - '--network', - NETWORK_NAME, - '-p', - `${port}:3000`, - '-e', - `DATABASE_URL=postgresql://postgres:postgres@${DB_CONTAINER}:5432/simstudio`, - '-e', - `BETTER_AUTH_URL=http://localhost:${port}`, - '-e', - `NEXT_PUBLIC_APP_URL=http://localhost:${port}`, - '-e', - 'BETTER_AUTH_SECRET=your_auth_secret_here', - '-e', - 'ENCRYPTION_KEY=your_encryption_key_here', - 'ghcr.io/simstudioai/simstudio:latest', - ]) + // Wait for DB + if (!(await waitForPgReady(config.dbContainer))) { + console.error(chalk.red('โŒ PostgreSQL failed to become ready within 30s.')) + process.exit(1) + } - if (!appSuccess) { - console.error(chalk.red('โŒ Failed to start Sim')) + // Run migrations + if (!(await runMigrations(config))) { + console.error(chalk.red('โŒ Failed to run migrations.')) process.exit(1) } - console.log(chalk.green(`โœ… Sim is now running at ${chalk.bold(`http://localhost:${port}`)}`)) - console.log( - chalk.yellow( - `๐Ÿ›‘ To stop all containers, run: ${chalk.bold('docker stop simstudio-app simstudio-db simstudio-realtime')}` - ) - ) + // Start Realtime + if (!(await startRealtime(config))) { + console.error(chalk.red('โŒ Failed to start Realtime Server.')) + process.exit(1) + } - // Handle Ctrl+C - const rl = createInterface({ - input: process.stdin, - output: process.stdout, - }) + // Start App + if (!(await startApp(config))) { + console.error(chalk.red('โŒ Failed to start Sim application.')) + process.exit(1) + } - rl.on('SIGINT', async () => { - console.log(chalk.yellow('\n๐Ÿ›‘ Stopping Sim...')) + printSuccess(config) + setupShutdownHandlers(config) - // Stop containers - await stopAndRemoveContainer(APP_CONTAINER) - await stopAndRemoveContainer(DB_CONTAINER) - await stopAndRemoveContainer(REALTIME_CONTAINER) + // Keep process alive + process.stdin.resume() +} - console.log(chalk.green('โœ… Sim has been stopped')) - process.exit(0) +// Only run main if this is the main module (not during testing) +// Check if running directly (not being imported for testing) +if (process.env.NODE_ENV !== 'test') { + main().catch((error) => { + console.error(chalk.red('โŒ An unexpected error occurred:'), error) + process.exit(1) }) } - -main().catch((error) => { - console.error(chalk.red('โŒ An error occurred:'), error) - process.exit(1) -}) From a32f6c8b19e67fa008092609e9bf94745904437b Mon Sep 17 00:00:00 2001 From: Sundaram Kumar Jha Date: Sat, 8 Nov 2025 16:05:51 +0530 Subject: [PATCH 6/9] refactor(scripts): Update package.json with new scripts and dependencies --- packages/cli/package.json | 74 ++++++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 9 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index bc1ca47962..4a3806d262 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -10,7 +10,11 @@ "scripts": { "build": "bun run build:tsc", "build:tsc": "tsc", - "prepublishOnly": "bun run build" + "prepublishOnly": "bun run build", + "dev": "bun --watch index.ts", + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage" }, "files": [ "dist" @@ -31,19 +35,25 @@ "author": "Sim", "license": "Apache-2.0", "dependencies": { - "chalk": "^4.1.2", - "commander": "^11.1.0", - "dotenv": "^16.3.1", - "inquirer": "^8.2.6", - "listr2": "^6.6.1" + "chalk": "^5.3.0", + "commander": "^12.1.0", + "dotenv": "^16.4.5", + "inquirer": "^10.0.6", + "listr2": "^8.0.1" }, "devDependencies": { + "@types/chalk": "^2.2.2", + "@types/commander": "^2.12.2", "@types/inquirer": "^8.2.6", - "@types/node": "^20.5.1", - "typescript": "^5.1.6" + "@types/jest": "^29.5.12", + "@types/node": "^22.5.0", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "ts-node": "^10.9.2", + "typescript": "^5.6.3" }, "engines": { - "node": ">=16" + "node": ">=18" }, "turbo": { "tasks": { @@ -53,5 +63,51 @@ ] } } + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "moduleFileExtensions": [ + "ts", + "js" + ], + "testMatch": [ + "**/__tests__/**/*.test.ts" + ], + "collectCoverageFrom": [ + "src/**/*.ts", + "!src/**/*.d.ts" + ], + "coverageThreshold": { + "global": { + "branches": 8, + "functions": 70, + "lines": 55, + "statements": 55 + } + }, + "setupFilesAfterEnv": [ + "/__tests__/setup.ts" + ], + "transform": { + "^.+\\.ts$": [ + "ts-jest", + { + "tsconfig": { + "module": "ESNext", + "target": "ES2020" + } + } + ] + }, + "transformIgnorePatterns": [ + "node_modules/(?!(chalk)/)" + ], + "moduleNameMapper": { + "^chalk$": "/__tests__/__mocks__/chalk.ts" + }, + "testEnvironmentOptions": { + "NODE_ENV": "test" + } } } From 629745a25aecd19caf3cf17dadd2c950f8bcb55c Mon Sep 17 00:00:00 2001 From: Sundaram Kumar Jha Date: Sat, 8 Nov 2025 16:16:31 +0530 Subject: [PATCH 7/9] refactor: type assertion of configs Removed unnecessary line break in type assertion. --- packages/cli/__tests__/index.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli/__tests__/index.test.ts b/packages/cli/__tests__/index.test.ts index fa1af527a4..3afd1122a3 100644 --- a/packages/cli/__tests__/index.test.ts +++ b/packages/cli/__tests__/index.test.ts @@ -32,8 +32,7 @@ describe('SimStudio CLI', () => { realtimePort: 3002, betterAuthSecret: 'test-secret-32chars-long-enough', encryptionKey: 'test-encryption-32chars-long', - } - as indexModule.Config; + } as indexModule.Config; mockHomedir.mockReturnValue('/home/user'); mockJoin.mockImplementation((...args) => args.join('/')); From 2451dcda0c8b88244ac2d1631d51538d16e0dc22 Mon Sep 17 00:00:00 2001 From: Sundaram Kumar Jha Date: Sat, 8 Nov 2025 23:18:10 +0530 Subject: [PATCH 8/9] refactor: port availability check, cross platform fix and improve logging Refactor port availability check to use net module and improve PostgreSQL readiness logging. --- packages/cli/src/index.ts | 60 ++++++++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 4259cb8956..b2423f34d8 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -75,17 +75,28 @@ export function generateSecret(length: number = 32): string { } /** - * Validates if a port is available (simple check via netstat-like command). - * @param port - The port to check. - * @returns True if port is available. + * Validates if a port is available on the local machine. + * Works on Linux, macOS and Windows. */ export async function isPortAvailable(port: number): Promise { - try { - execSync(`lsof -i :${port} || netstat -an | grep :${port} || ss -tuln | grep :${port}`, { stdio: 'ignore' }) - return false // Port in use if command succeeds without error - } catch { - return true // Port available - } + return new Promise((resolve) => { + const server = require('net').createServer(); + + server.once('error', (err: any) => { + if (err.code === 'EADDRINUSE') { + resolve(false); // port taken + } else { + resolve(true); + } + }); + + server.once('listening', () => { + server.close(() => resolve(true)); // port free + }); + + // `::` binds to IPv6 + IPv4 on most systems + server.listen(port, '::'); + }); } /** @@ -222,22 +233,33 @@ export async function startDatabase(config: Config): Promise { * Waits for PostgreSQL to be ready with timeout. * @param containerName - DB container name. * @param timeoutMs - Timeout in milliseconds (default 5 minutes). - * @returns Promise resolving to true if ready within timeout. */ -export async function waitForPgReady(containerName: string, timeoutMs: number = 300000): Promise { - console.log(chalk.blue('โณ Waiting for PostgreSQL to be ready...')) - const startTime = Date.now() +export async function waitForPgReady( + containerName: string, + timeoutMs: number = 300_000 // 5 minutes = 300000 ms +): Promise { + console.log(chalk.blue('Waiting for PostgreSQL to be ready...')); + const startTime = Date.now(); + while (Date.now() - startTime < timeoutMs) { try { - execSync(`docker exec ${containerName} pg_isready -U ${DEFAULT_CONFIG.postgresUser!}`, { stdio: 'ignore' }) - console.log(chalk.green('โœ… PostgreSQL is ready!')) - return true + execSync( + `docker exec ${containerName} pg_isready -U ${DEFAULT_CONFIG.postgresUser!}`, + { stdio: 'ignore' } + ); + console.log(chalk.green('PostgreSQL is ready!')); + return true; } catch { - // Wait 2s between checks - await new Promise(resolve => setTimeout(resolve, 2000)) + await new Promise((r) => setTimeout(r, 2000)); } } - return false + + const minutes = Math.floor(timeoutMs / 60_000); + const seconds = (timeoutMs % 60_000) / 1_000; + console.error( + chalk.red(`PostgreSQL failed to become ready within ${minutes}m${seconds}s.`) + ); + return false; } /** From a46dd78f986cf58a02ac87d816817f043716ad88 Mon Sep 17 00:00:00 2001 From: Sundaram Kumar Jha Date: Sat, 8 Nov 2025 23:19:15 +0530 Subject: [PATCH 9/9] refactor: tests for configuration and port availability --- packages/cli/__tests__/index.test.ts | 79 ++++++++++++++++++++++------ 1 file changed, 64 insertions(+), 15 deletions(-) diff --git a/packages/cli/__tests__/index.test.ts b/packages/cli/__tests__/index.test.ts index 3afd1122a3..b446f73008 100644 --- a/packages/cli/__tests__/index.test.ts +++ b/packages/cli/__tests__/index.test.ts @@ -27,11 +27,25 @@ describe('SimStudio CLI', () => { jest.clearAllMocks(); config = { - ...indexModule.DEFAULT_CONFIG, port: 3000, + pullImages: true, + yes: false, + dataDir: '/home/user/.simstudio/data', + networkName: 'simstudio-network', + dbContainer: 'simstudio-db', + migrationsContainer: 'simstudio-migrations', + realtimeContainer: 'simstudio-realtime', + appContainer: 'simstudio-app', + dbImage: 'pgvector/pgvector:pg17', + migrationsImage: 'ghcr.io/simstudioai/migrations:latest', + realtimeImage: 'ghcr.io/simstudioai/realtime:latest', + appImage: 'ghcr.io/simstudioai/simstudio:latest', + postgresUser: 'postgres', + postgresPassword: 'postgres', + postgresDb: 'simstudio', realtimePort: 3002, - betterAuthSecret: 'test-secret-32chars-long-enough', - encryptionKey: 'test-encryption-32chars-long', + betterAuthSecret: 'test-secret-32chars-long-enough-1234567890', + encryptionKey: 'test-encryption-32chars-long-1234567890abcd', } as indexModule.Config; mockHomedir.mockReturnValue('/home/user'); @@ -69,21 +83,56 @@ describe('SimStudio CLI', () => { }); describe('isPortAvailable', () => { - it('should return true if port is available (command throws)', async () => { - mockExecSync.mockImplementation(() => { - throw new Error('Port not in use'); + let net: any; + + beforeEach(() => { + net = require('net'); + }); + + it('should return true if port is available', async () => { + jest.spyOn(net, 'createServer').mockImplementation(() => { + return { + once: jest.fn().mockImplementation((event: string, cb: () => void) => { + if (event === 'listening') setImmediate(cb); + }), + listen: jest.fn(), + close: jest.fn().mockImplementation((cb: () => void) => cb && cb()), + } as any; }); const available = await indexModule.isPortAvailable(3000); expect(available).toBe(true); }); - it('should return false if port is in use (command succeeds)', async () => { - mockExecSync.mockReturnValue(Buffer.from('output')); + it('should return false if port is in use (EADDRINUSE)', async () => { + jest.spyOn(net, 'createServer').mockImplementation(() => { + return { + once: jest.fn().mockImplementation((event: string, cb: (err?: any) => void) => { + if (event === 'error') setImmediate(() => cb({ code: 'EADDRINUSE' })); + }), + listen: jest.fn(), + close: jest.fn(), + } as any; + }); const available = await indexModule.isPortAvailable(3000); expect(available).toBe(false); }); + + it('should return true on any other error (cannot determine)', async () => { + jest.spyOn(net, 'createServer').mockImplementation(() => { + return { + once: jest.fn().mockImplementation((event: string, cb: (err?: any) => void) => { + if (event === 'error') setImmediate(() => cb({ code: 'EPERM' })); + }), + listen: jest.fn(), + close: jest.fn(), + } as any; + }); + + const available = await indexModule.isPortAvailable(3000); + expect(available).toBe(true); + }); }); describe('isDockerRunning', () => { @@ -256,28 +305,28 @@ describe('SimStudio CLI', () => { }); describe('waitForPgReady', () => { - it('should resolve true if PG becomes ready quickly', async () => { + it('should resolve true if PG becomes ready', async () => { let attempts = 0; mockExecSync.mockImplementation(() => { attempts++; - if (attempts === 2) { - return Buffer.from('ready'); - } + if (attempts === 2) return Buffer.from('ready'); throw new Error('not ready'); }); const ready = await indexModule.waitForPgReady('test-db', 5000); expect(ready).toBe(true); - expect(mockExecSync).toHaveBeenCalled(); }); - it('should resolve false after timeout', async () => { + it('should resolve false after timeout and print correct message', async () => { mockExecSync.mockImplementation(() => { throw new Error('not ready'); }); - const ready = await indexModule.waitForPgReady('test-db', 100); + const ready = await indexModule.waitForPgReady('test-db', 200); expect(ready).toBe(false); + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('failed to become ready within 0m0.2s') + ); }); });