Skip to content

Commit 3bdb4e4

Browse files
committed
chore: wip
1 parent a62a8a5 commit 3bdb4e4

File tree

6 files changed

+218
-20
lines changed

6 files changed

+218
-20
lines changed

bun.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
},
1919
"packages/action": {
2020
"name": "launchpad-installer",
21-
"version": "0.6.1",
21+
"version": "0.6.3",
2222
"bin": {
2323
"launchpad-installer": "dist/index.js",
2424
},
@@ -35,7 +35,7 @@
3535
},
3636
"packages/launchpad": {
3737
"name": "@stacksjs/launchpad",
38-
"version": "0.6.1",
38+
"version": "0.6.3",
3939
"bin": {
4040
"launchpad": "./dist/bin/cli.js",
4141
},

packages/launchpad/src/binary-downloader.ts

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,29 @@ Thanks for helping us make Launchpad better! 🙏
748748
dbExtensions.push('pdo_sqlite', 'sqlite3')
749749
}
750750

751+
// Detect extension_dir of this PHP installation and filter only present extensions
752+
let extensionDirLine = ''
753+
try {
754+
const extBase = path.join(packageDir, 'lib', 'php', 'extensions')
755+
if (fs.existsSync(extBase)) {
756+
const subdirs = fs.readdirSync(extBase).filter(d => fs.statSync(path.join(extBase, d)).isDirectory())
757+
if (subdirs.length > 0) {
758+
const extDir = path.join(extBase, subdirs[0])
759+
extensionDirLine = `extension_dir = ${extDir}`
760+
// Filter dbExtensions to those that exist in extDir
761+
const existing = new Set(fs.readdirSync(extDir))
762+
for (let i = dbExtensions.length - 1; i >= 0; i--) {
763+
const ext = dbExtensions[i]
764+
const so = `${ext}.so`
765+
if (!existing.has(so)) {
766+
dbExtensions.splice(i, 1)
767+
}
768+
}
769+
}
770+
}
771+
}
772+
catch {}
773+
751774
const ini = [
752775
'; Launchpad php.ini (auto-generated)',
753776
'memory_limit = 512M',
@@ -757,6 +780,7 @@ Thanks for helping us make Launchpad better! 🙏
757780
'display_errors = On',
758781
'error_reporting = E_ALL',
759782
'',
783+
...(extensionDirLine ? [extensionDirLine, ''] : []),
760784
'; Enable database extensions based on project detection',
761785
...dbExtensions.map(ext => `extension=${ext}`),
762786
'',
@@ -1367,27 +1391,53 @@ export async function downloadPhpBinary(installPath: string, requestedVersion?:
13671391
'display_errors = On',
13681392
'error_reporting = E_ALL',
13691393
'',
1370-
'; Enable database extensions based on project detection',
13711394
]
13721395

1373-
// Basic detection using .env
1396+
// Detect extension_dir from installed package
1397+
let extDir = ''
1398+
try {
1399+
const extBase = path.join(result.packageDir, 'lib', 'php', 'extensions')
1400+
if (fs.existsSync(extBase)) {
1401+
const subdirs = fs.readdirSync(extBase).filter(d => fs.statSync(path.join(extBase, d)).isDirectory())
1402+
if (subdirs.length > 0) {
1403+
extDir = path.join(extBase, subdirs[0])
1404+
}
1405+
}
1406+
}
1407+
catch {}
1408+
1409+
if (extDir) {
1410+
iniLines.push(`extension_dir = ${extDir}`)
1411+
iniLines.push('')
1412+
}
1413+
1414+
// Basic detection using .env, but only enable extensions that exist in extDir
1415+
const existing = new Set<string>(extDir && fs.existsSync(extDir) ? fs.readdirSync(extDir) : [])
1416+
const enableIfExists = (ext: string) => {
1417+
const so = `${ext}.so`
1418+
if (!extDir || existing.has(so)) {
1419+
iniLines.push(`extension=${ext}`)
1420+
}
1421+
}
1422+
1423+
iniLines.push('; Enable database extensions based on project detection')
13741424
try {
13751425
const envPath = path.join(process.cwd(), '.env')
13761426
if (fs.existsSync(envPath)) {
13771427
const envContent = fs.readFileSync(envPath, 'utf-8')
13781428
const dbConnMatch = envContent.match(/^DB_CONNECTION=(.*)$/m)
13791429
const dbConn = dbConnMatch?.[1]?.trim().toLowerCase()
13801430
if (dbConn === 'pgsql' || dbConn === 'postgres' || dbConn === 'postgresql') {
1381-
iniLines.push('extension=pdo_pgsql')
1382-
iniLines.push('extension=pgsql')
1431+
enableIfExists('pdo_pgsql')
1432+
enableIfExists('pgsql')
13831433
}
13841434
else if (dbConn === 'mysql' || dbConn === 'mariadb') {
1385-
iniLines.push('extension=pdo_mysql')
1386-
iniLines.push('extension=mysqli')
1435+
enableIfExists('pdo_mysql')
1436+
enableIfExists('mysqli')
13871437
}
13881438
else if (dbConn === 'sqlite') {
1389-
iniLines.push('extension=pdo_sqlite')
1390-
iniLines.push('extension=sqlite3')
1439+
enableIfExists('pdo_sqlite')
1440+
enableIfExists('sqlite3')
13911441
}
13921442
}
13931443
}

packages/launchpad/src/dev/dump.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,38 @@ async function ensureProjectPhpIni(projectDir: string, envDir: string): Promise<
257257
async function executepostSetup(projectDir: string, commands: PostSetupCommand[]): Promise<string[]> {
258258
const results: string[] = []
259259

260+
// Helper: wait for PostgreSQL if project uses it
261+
async function waitForPostgresIfNeeded(): Promise<void> {
262+
try {
263+
const { parseEnvFile } = await import('../dev-setup')
264+
const env = parseEnvFile(path.join(projectDir, '.env'))
265+
const usesPg = (env.DB_CONNECTION || '').toLowerCase().includes('pg')
266+
if (!usesPg)
267+
return
268+
269+
const host = env.DB_HOST || '127.0.0.1'
270+
const port = env.DB_PORT || '5432'
271+
272+
const pgIsReady = (await import('../utils')).findBinaryInPath('pg_isready') || 'pg_isready'
273+
// Probe up to 15 times with backoff
274+
for (let i = 0; i < 15; i++) {
275+
const ok = await new Promise<boolean>((resolve) => {
276+
// eslint-disable-next-line ts/no-require-imports
277+
const { spawn } = require('node:child_process')
278+
const p = spawn(pgIsReady, ['-h', host, '-p', port], { stdio: 'pipe' })
279+
p.on('close', (code: number) => resolve(code === 0))
280+
p.on('error', () => resolve(false))
281+
})
282+
if (ok)
283+
break
284+
await new Promise(r => setTimeout(r, Math.min(500 + i * 500, 5000)))
285+
}
286+
// Small grace delay after ready
287+
await new Promise(r => setTimeout(r, 250))
288+
}
289+
catch {}
290+
}
291+
260292
for (const command of commands) {
261293
try {
262294
const shouldRun = evaluateCommandCondition(command.condition, projectDir)
@@ -296,6 +328,14 @@ async function executepostSetup(projectDir: string, commands: PostSetupCommand[]
296328
results.push(`🚀 Running in background: ${command.description}`)
297329
}
298330
else {
331+
// If we're about to run Laravel migrations, ensure DB is ready
332+
if (command.command.includes('artisan') && command.command.includes('migrate')) {
333+
try {
334+
process.stderr.write('⏳ Ensuring database is ready before running migrations...\n')
335+
}
336+
catch {}
337+
await waitForPostgresIfNeeded()
338+
}
299339
execSync(command.command, {
300340
cwd: projectDir,
301341
env: execEnv,
@@ -1342,8 +1382,34 @@ async function setupProjectServices(projectDir: string, sniffResult: any, showMe
13421382
console.log(`✅ ${serviceName} service started successfully`)
13431383
}
13441384

1345-
// Special handling for PostgreSQL: create project database
1385+
// Special handling for PostgreSQL: wait for readiness and create project database
13461386
if ((serviceName === 'postgres' || serviceName === 'postgresql') && hasPostgresInDeps) {
1387+
if (showMessages) {
1388+
console.log('⏳ Verifying PostgreSQL readiness...')
1389+
}
1390+
// Ensure postgres is actually accepting connections
1391+
try {
1392+
const { findBinaryInPath } = await import('../utils')
1393+
const pgIsReady = findBinaryInPath('pg_isready') || 'pg_isready'
1394+
// Probe up to 10 times
1395+
for (let i = 0; i < 10; i++) {
1396+
const probe = await new Promise<boolean>((resolve) => {
1397+
// eslint-disable-next-line ts/no-require-imports
1398+
const { spawn } = require('node:child_process')
1399+
const p = spawn(pgIsReady, ['-h', '127.0.0.1', '-p', '5432'], { stdio: 'pipe' })
1400+
p.on('close', (code: number) => resolve(code === 0))
1401+
p.on('error', () => resolve(false))
1402+
})
1403+
if (probe)
1404+
break
1405+
await new Promise(r => setTimeout(r, Math.min(1000 * (i + 1), 5000)))
1406+
}
1407+
}
1408+
catch {}
1409+
1410+
if (showMessages) {
1411+
console.log('🔧 Creating project PostgreSQL database...')
1412+
}
13471413
const projectName = path.basename(projectDir).replace(/\W/g, '_')
13481414
try {
13491415
await createProjectDatabase(projectName, {

packages/launchpad/src/services/definitions.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export const SERVICE_DEFINITIONS: Record<string, ServiceDefinition> = {
1313
description: 'PostgreSQL database server',
1414
packageDomain: 'postgresql.org',
1515
executable: 'postgres',
16-
args: ['-D', '{dataDir}'],
16+
args: ['-D', '{dataDir}', '-p', '{port}', '-c', 'listen_addresses=127.0.0.1'],
1717
env: {
1818
PGDATA: '{dataDir}',
1919
},
@@ -31,7 +31,7 @@ export const SERVICE_DEFINITIONS: Record<string, ServiceDefinition> = {
3131
'unicode.org^73',
3232
],
3333
healthCheck: {
34-
command: ['pg_isready', '-p', '5432'],
34+
command: ['pg_isready', '-h', '127.0.0.1', '-p', '5432'],
3535
expectedExitCode: 0,
3636
timeout: 5,
3737
interval: 30,
@@ -41,8 +41,10 @@ export const SERVICE_DEFINITIONS: Record<string, ServiceDefinition> = {
4141
postStartCommands: [
4242
// Create application database and user for any project type
4343
['createdb', '-h', '127.0.0.1', '-p', '5432', '{projectDatabase}'],
44-
['psql', '-h', '127.0.0.1', '-p', '5432', '-d', 'postgres', '-c', 'CREATE USER IF NOT EXISTS {dbUsername} WITH PASSWORD \'{dbPassword}\';'],
45-
['psql', '-h', '127.0.0.1', '-p', '5432', '-d', 'postgres', '-c', 'GRANT ALL PRIVILEGES ON DATABASE {projectDatabase} TO {dbUsername};'],
44+
// Ensure default postgres role exists for framework defaults
45+
['psql', '-h', '127.0.0.1', '-p', '5432', '-d', 'postgres', '-c', 'DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = \'postgres\') THEN CREATE ROLE postgres SUPERUSER LOGIN; END IF; END $$;'],
46+
['psql', '-h', '127.0.0.1', '-p', '5432', '-d', 'postgres', '-c', 'DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = \'{dbUsername}\') THEN CREATE ROLE {dbUsername} LOGIN PASSWORD \'{dbPassword}\'; END IF; END $$;'],
47+
['psql', '-h', '127.0.0.1', '-p', '5432', '-d', 'postgres', '-c', 'ALTER DATABASE {projectDatabase} OWNER TO {dbUsername}; GRANT ALL PRIVILEGES ON DATABASE {projectDatabase} TO {dbUsername};'],
4648
['psql', '-h', '127.0.0.1', '-p', '5432', '-d', 'postgres', '-c', 'GRANT CREATE ON SCHEMA public TO {dbUsername};'],
4749
['psql', '-h', '127.0.0.1', '-p', '5432', '-d', 'postgres', '-c', 'GRANT USAGE ON SCHEMA public TO {dbUsername};'],
4850
],

packages/launchpad/src/services/manager.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { platform } from 'node:os'
66
import path from 'node:path'
77
import process from 'node:process'
88
import { config } from '../config'
9+
import { logUniqueMessage } from '../logging'
910
import { findBinaryInEnvironment, findBinaryInPath } from '../utils'
1011
import { createDefaultServiceConfig, getServiceDefinition } from './definitions'
1112
import { generateLaunchdPlist, generateSystemdService, getServiceFilePath, isPlatformSupported, writeLaunchdPlist, writeSystemdService } from './platform'
@@ -1074,7 +1075,7 @@ async function autoInitializeDatabase(service: ServiceInstance): Promise<boolean
10741075
}
10751076
}
10761077

1077-
execSync(`${command} -D "${dataDir}" --auth-local=trust --auth-host=md5`, {
1078+
execSync(`${command} -D "${dataDir}" --auth-local=trust --auth-host=trust`, {
10781079
stdio: config.verbose ? 'inherit' : 'pipe',
10791080
timeout: 60000,
10801081
env,
@@ -1140,25 +1141,33 @@ async function executePostStartCommands(service: ServiceInstance): Promise<void>
11401141

11411142
// Wait for the service to be fully ready, especially for databases
11421143
if (definition.name === 'postgres' || definition.name === 'mysql') {
1144+
// Immediate feedback so it doesn't look frozen after the "started successfully" line
1145+
logUniqueMessage(`⏳ Waiting for ${definition.displayName} to be ready...`, true)
11431146
// For databases, wait longer in CI environments and check if they're actually ready
11441147
const isCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true'
1145-
const waitTime = isCI ? 10000 : 5000 // 10s in CI, 5s locally
1148+
const waitTime = isCI ? 15000 : 8000 // 15s in CI, 8s locally to allow first cold start
11461149
await new Promise(resolve => setTimeout(resolve, waitTime))
11471150

11481151
// Try to verify the service is responding before running post-start commands
11491152
if (definition.healthCheck) {
1150-
for (let i = 0; i < 5; i++) {
1153+
// Try up to 10 times with exponential backoff
1154+
for (let i = 0; i < 10; i++) {
11511155
try {
11521156
const healthResult = await checkServiceHealth(service)
11531157
if (healthResult)
11541158
break
11551159
}
11561160
catch {
11571161
// Health check failed, wait a bit more
1158-
await new Promise(resolve => setTimeout(resolve, 1000))
1162+
const backoff = Math.min(1000 * (i + 1), 5000)
1163+
await new Promise(resolve => setTimeout(resolve, backoff))
11591164
}
11601165
}
1166+
logUniqueMessage(`✅ ${definition.displayName} is accepting connections`, true)
11611167
}
1168+
1169+
// Announce post-start setup phase for databases
1170+
logUniqueMessage(`🔧 Running ${definition.displayName} post-start setup...`, true)
11621171
}
11631172
else {
11641173
// For other services, use the original wait time
@@ -1222,6 +1231,10 @@ async function executePostStartCommands(service: ServiceInstance): Promise<void>
12221231
}
12231232
}
12241233
}
1234+
1235+
if (definition.name === 'postgres' || definition.name === 'mysql') {
1236+
logUniqueMessage(`✅ ${definition.displayName} post-start setup completed`, true)
1237+
}
12251238
}
12261239

12271240
/**

packages/launchpad/src/services/platform.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,72 @@ export function generateLaunchdPlist(service: ServiceInstance): LaunchdPlist {
4646
// Find the executable path
4747
const executablePath = findBinaryInPath(definition.executable) || definition.executable
4848

49+
// Compute runtime PATH and dynamic library search paths for packages started by launchd
50+
// launchd does not inherit shell env, so we must include env-specific paths here.
51+
const envVars: Record<string, string> = {}
52+
53+
try {
54+
const binDir = path.dirname(executablePath)
55+
const versionDir = path.dirname(binDir)
56+
const domainDir = path.dirname(versionDir)
57+
const envRoot = path.dirname(domainDir)
58+
59+
// Only apply when executable lives inside a Launchpad environment
60+
const looksLikeEnv = fs.existsSync(envRoot) && fs.existsSync(path.join(envRoot, 'bin'))
61+
62+
if (looksLikeEnv) {
63+
// Build PATH to include env bin/sbin first
64+
const envBin = path.join(envRoot, 'bin')
65+
const envSbin = path.join(envRoot, 'sbin')
66+
const basePath = process.env.PATH || ''
67+
envVars.PATH = [envBin, envSbin, basePath].filter(Boolean).join(':')
68+
69+
// Discover all package lib directories under this env root
70+
const libraryDirs: string[] = []
71+
72+
const pushIfExists = (p: string) => {
73+
try {
74+
if (fs.existsSync(p) && !libraryDirs.includes(p))
75+
libraryDirs.push(p)
76+
}
77+
catch {}
78+
}
79+
80+
// Common top-level lib dirs
81+
pushIfExists(path.join(envRoot, 'lib'))
82+
pushIfExists(path.join(envRoot, 'lib64'))
83+
84+
// Scan domain/version directories for lib folders
85+
try {
86+
const entries = fs.readdirSync(envRoot, { withFileTypes: true })
87+
for (const entry of entries) {
88+
if (!entry.isDirectory())
89+
continue
90+
if (['bin', 'sbin', 'share', 'include', 'etc', 'pkgs'].includes(entry.name))
91+
continue
92+
const domainPath = path.join(envRoot, entry.name)
93+
const versions = fs.readdirSync(domainPath, { withFileTypes: true }).filter(v => v.isDirectory() && v.name.startsWith('v'))
94+
for (const ver of versions) {
95+
pushIfExists(path.join(domainPath, ver.name, 'lib'))
96+
pushIfExists(path.join(domainPath, ver.name, 'lib64'))
97+
}
98+
}
99+
}
100+
catch {}
101+
102+
if (libraryDirs.length > 0) {
103+
const existingDyld = process.env.DYLD_LIBRARY_PATH || ''
104+
envVars.DYLD_LIBRARY_PATH = [libraryDirs.join(':'), existingDyld].filter(Boolean).join(':')
105+
// Also set fallback for good measure
106+
const existingFallback = process.env.DYLD_FALLBACK_LIBRARY_PATH || ''
107+
envVars.DYLD_FALLBACK_LIBRARY_PATH = [libraryDirs.join(':'), existingFallback].filter(Boolean).join(':')
108+
}
109+
}
110+
}
111+
catch {
112+
// Best-effort only
113+
}
114+
49115
return {
50116
Label: `com.launchpad.${definition.name || service.name}`,
51117
ProgramArguments: [executablePath, ...resolvedArgs],
@@ -69,11 +135,12 @@ export function generateLaunchdPlist(service: ServiceInstance): LaunchdPlist {
69135
return [k, resolved]
70136
})),
71137
...Object.fromEntries(Object.entries(service.config || {}).map(([k, v]) => [k, String(v)])),
138+
...envVars,
72139
},
73140
StandardOutPath: service.logFile || path.join(logDir || '', `${definition.name || service.name}.log`),
74141
StandardErrorPath: service.logFile || path.join(logDir || '', `${definition.name || service.name}.log`),
75142
RunAtLoad: service.enabled || false,
76-
KeepAlive: true, // Always keep alive for now
143+
KeepAlive: { SuccessfulExit: false },
77144
UserName: process.env.USER || 'root',
78145
}
79146
}

0 commit comments

Comments
 (0)