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 (
-
-
-
-
-
-
- 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
}
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;
diff --git a/packages/cli/__tests__/index.test.ts b/packages/cli/__tests__/index.test.ts
new file mode 100644
index 0000000000..b446f73008
--- /dev/null
+++ b/packages/cli/__tests__/index.test.ts
@@ -0,0 +1,429 @@
+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 = {
+ 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-1234567890',
+ encryptionKey: 'test-encryption-32chars-long-1234567890abcd',
+ } 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', () => {
+ 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 (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', () => {
+ 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', 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);
+ });
+
+ 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', 200);
+ expect(ready).toBe(false);
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ expect.stringContaining('failed to become ready within 0m0.2s')
+ );
+ });
+ });
+
+ 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();
+ });
+ });
+});
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;
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"
+ }
}
}
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 0e95d65e1b..b2423f34d8 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -5,282 +5,510 @@ 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)
+}
- docker.on('close', (code) => {
- resolve(code === 0)
- })
+/**
+ * Validates if a port is available on the local machine.
+ * Works on Linux, macOS and Windows.
+ */
+export async function isPortAvailable(port: number): Promise {
+ 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, '::');
+ });
+}
+
+/**
+ * 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).
+ */
+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;
+ } catch {
+ await new Promise((r) => setTimeout(r, 2000));
+ }
}
- // Use port from options, with 3000 as default
- const port = options.port
+ 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;
+}
- // 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')
+/**
+ * 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)
+}
+
+/**
+ * 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)
+}
+
+/**
+ * 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)
-})