From 4a7b3061eddcd1939e8387aa32ec3bb60cb1710e Mon Sep 17 00:00:00 2001 From: Ryan Lancaster Date: Wed, 18 Mar 2026 13:05:14 -0400 Subject: [PATCH] Harden MVP runtime and fix packaged desktop white screen --- .env | 9 +++ README.md | 85 ++++++++++++++++++++++++++- backend/index.js | 9 ++- docker-compose.yml | 2 + frontend/electron/main.cjs | 35 +++++++++-- frontend/package.json | 2 +- frontend/vite.config.js | 1 + main.js | 17 +----- package.json | 7 +++ scripts/adhoc-sign.js | 29 ++++++++- scripts/mvp-check.cjs | 97 +++++++++++++++++++++++++++++++ scripts/persistence-self-test.cjs | 3 +- scripts/ports-report.cjs | 89 ++++++++++++++++++++++++++++ scripts/wiring-smoke-test.cjs | 3 +- 14 files changed, 359 insertions(+), 29 deletions(-) create mode 100644 scripts/mvp-check.cjs create mode 100644 scripts/ports-report.cjs diff --git a/.env b/.env index 61140ae..880b036 100644 --- a/.env +++ b/.env @@ -4,3 +4,12 @@ PORT_BACKEND=4000 # Express REST API → http://localhost:4000 PORT_FRONTEND_DEV=5173 # Vite dev server → http://localhost:5173 +PORT_FRONTEND_DEV_FALLBACK=5174 # Optional local fallback if 5173 is already in use + +# Derived URLs for this project (kept explicit to avoid cross-project confusion) +APP_URL=http://localhost:5173 +VITE_API_URL=http://localhost:4000/api +API_BASE=http://localhost:4000/api + +# Browser origins allowed to call backend +CORS_ORIGIN=http://localhost:5173,http://localhost:5174,http://localhost:4000,http://10.0.0.252:5173,http://10.0.0.252:5174,null diff --git a/README.md b/README.md index 1a7903e..47617b2 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,75 @@ Quick links - Frontend: `frontend/` - Backend: `backend/` +Dedicated ports list (single source of truth) + +This project keeps all port assignments in the root `.env` file so services do not get mixed with other projects: + +- `PORT_BACKEND` (API) +- `PORT_FRONTEND_DEV` (Vite dev) +- `PORT_FRONTEND_DEV_FALLBACK` (optional fallback) +- `APP_URL`, `API_BASE`, `VITE_API_URL` (URL defaults tied to those ports) + +View the current dedicated map and whether each port is already in use: + +```bash +npm run ports:list +``` + +Canonical MVP startup (from project root) + +1) Install dependencies + +```bash +npm install +npm install --prefix backend +npm install --prefix frontend +``` + +2) Start backend API + +```bash +docker compose up -d +curl http://localhost:4000/health +``` + +3) Run web app (browser) + +```bash +npm run dev --prefix frontend +``` + +Or run backend + web frontend together from the root: + +```bash +npm run dev:web +``` + +Stop backend + web frontend from the root: + +```bash +npm run dev:web:stop +``` + +4) Run desktop app (Electron + Vite) + +```bash +npm run dev +``` + +For production-style desktop launch from built files: + +```bash +npm run build:renderer +npm run start +``` + +5) Build desktop app + +```bash +npm run build:desktop +``` + Development 1) Backend (fast, using Docker): @@ -155,4 +224,18 @@ npm run test:wiring Optional env vars for this test: - `API_BASE` (default `http://localhost:4000/api`) -- `WRITE_API_KEY` (required if write endpoints are protected) \ No newline at end of file +- `WRITE_API_KEY` (required if write endpoints are protected) + +MVP readiness check + +Run one command to verify backend health, route wiring, and persistence: + +```bash +npm run test:mvp +``` + +To also verify persistence across a backend restart: + +```bash +npm run test:mvp:restart +``` \ No newline at end of file diff --git a/backend/index.js b/backend/index.js index 4adfaff..e1457b2 100644 --- a/backend/index.js +++ b/backend/index.js @@ -214,13 +214,16 @@ const app = express(); app.use(helmet()); app.use(morgan(process.env.MORGAN_FORMAT || 'combined')); +const defaultBackendPort = String(process.env.PORT || process.env.PORT_BACKEND || '4000'); +const defaultFrontendPort = String(process.env.PORT_FRONTEND_DEV || '5173'); + const configuredOrigins = (process.env.CORS_ORIGIN || '') .split(',') .map(o => o.trim()) .filter(Boolean); const allowedOrigins = configuredOrigins.length ? configuredOrigins - : ['http://localhost:5173', 'http://localhost:4000', 'null']; + : [`http://localhost:${defaultFrontendPort}`, `http://localhost:${defaultBackendPort}`, 'null']; const corsOptions = { credentials: true, @@ -544,7 +547,7 @@ app.post('/api/projects/:id/invites', limiter, requireApiKey, async (req, res) = // Fire email if SMTP configured and email provided if (req.body.email) { - const appUrl = process.env.APP_URL || 'http://localhost:5173'; + const appUrl = process.env.APP_URL || `http://localhost:${defaultFrontendPort}`; notify({ notifyEmail: req.body.email, subject: `You've been invited to "${project.name}"`, @@ -602,6 +605,6 @@ app.post('/api/invites/:token/accept', limiter, async (req, res) => { res.json({ projectId: invite.projectId, projectName: invite.projectName }); }); -const PORT = process.env.PORT || 4000; +const PORT = process.env.PORT || process.env.PORT_BACKEND || 4000; const HOST = process.env.HOST || '0.0.0.0'; app.listen(PORT, HOST, () => console.log(`Backend running on ${HOST}:${PORT}`)); diff --git a/docker-compose.yml b/docker-compose.yml index 95d6aac..00af711 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,8 @@ services: - "${PORT_BACKEND:-4000}:${PORT_BACKEND:-4000}" environment: - PORT=${PORT_BACKEND:-4000} + - PORT_FRONTEND_DEV=${PORT_FRONTEND_DEV:-5173} + - APP_URL=${APP_URL:-http://localhost:5173} - CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:5173,http://localhost:4000,null} - WRITE_API_KEY=${WRITE_API_KEY:-} volumes: diff --git a/frontend/electron/main.cjs b/frontend/electron/main.cjs index b02ac1f..7e340d1 100644 --- a/frontend/electron/main.cjs +++ b/frontend/electron/main.cjs @@ -2,12 +2,26 @@ const { app, BrowserWindow, ipcMain } = require('electron') const path = require('path') const fs = require('fs') -const isDev = process.env.NODE_ENV !== 'production' -const devUrl = 'http://localhost:5173' +const devServerUrl = process.env.ELECTRON_DEV_SERVER_URL || process.env.VITE_DEV_SERVER_URL || '' +const isDev = !app.isPackaged && Boolean(devServerUrl) const API_URL_KEY = 'runtime_api_url' const DEFAULT_API_URL = process.env.VITE_API_URL || 'http://localhost:4000/api' let mainWindow +function resolveRendererEntry() { + const candidates = [ + path.join(__dirname, '..', 'dist', 'index.html'), + path.join(process.resourcesPath || '', 'app.asar', 'dist', 'index.html'), + path.join(process.resourcesPath || '', 'app.asar.unpacked', 'dist', 'index.html'), + ] + + for (const candidate of candidates) { + if (candidate && fs.existsSync(candidate)) return candidate + } + + return candidates[0] +} + function normalizeApiUrl(value) { if (!value || typeof value !== 'string') return null const trimmed = value.trim().replace(/\/+$/, '') @@ -88,11 +102,24 @@ function createWindow() { } }) + mainWindow.webContents.on('did-fail-load', (_event, code, description, validatedUrl) => { + console.error(`Renderer failed to load (${code}): ${description} @ ${validatedUrl}`) + }) + if (isDev) { - mainWindow.loadURL(devUrl) + mainWindow.loadURL(devServerUrl).catch(err => { + console.error('Failed to load dev server URL, falling back to local dist file.', err) + const rendererEntry = resolveRendererEntry() + mainWindow.loadFile(rendererEntry).catch(loadErr => { + console.error('Fallback renderer load failed.', loadErr) + }) + }) mainWindow.webContents.openDevTools() } else { - mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html')) + const rendererEntry = resolveRendererEntry() + mainWindow.loadFile(rendererEntry).catch(err => { + console.error(`Failed to load renderer entry at ${rendererEntry}`, err) + }) } } diff --git a/frontend/package.json b/frontend/package.json index 33e1711..0f5efb3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,7 @@ "build": "vite build", "preview": "vite preview", "build:renderer": "vite build", - "electron:dev": "concurrently \"vite\" \"wait-on http://localhost:5173 && electron .\"", + "electron:dev": "concurrently \"vite\" \"wait-on http://localhost:${PORT_FRONTEND_DEV:-5173} && ELECTRON_DEV_SERVER_URL=http://localhost:${PORT_FRONTEND_DEV:-5173} electron .\"", "electron:build": "vite build && electron-builder", "start": "electron ." }, diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 12a02c6..6d05445 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -9,6 +9,7 @@ export default defineConfig(({ mode }) => { base: './', plugins: [react()], server: { + host: env.VITE_DEV_HOST || '0.0.0.0', port: parseInt(env.PORT_FRONTEND_DEV) || 5173, }, } diff --git a/main.js b/main.js index 9a077d9..26adea8 100644 --- a/main.js +++ b/main.js @@ -1,15 +1,2 @@ -const { app, BrowserWindow } = require('electron'); -const path = require('path'); - -app.whenReady().then(() => { - const win = new BrowserWindow({ - width: 1400, - height: 900, - webPreferences: { nodeIntegration: false }, - titleBarStyle: 'hiddenInset', // mac-native title bar - icon: path.join(__dirname, 'icon.png') - }); - win.loadFile(path.join(__dirname, 'dist', 'index.html')); -}); - -app.on('window-all-closed', () => process.platform !== 'darwin' && app.quit()); +// Root Electron shim: delegate to the maintained process entrypoint. +require('./frontend/electron/main.cjs'); diff --git a/package.json b/package.json index 48f80be..2f7e2b6 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,12 @@ "version": "1.0.0", "main": "main.js", "scripts": { + "dev": "npm run electron:dev --prefix frontend", + "dev:web": "docker compose up -d && npm run dev --prefix frontend -- --host ${VITE_DEV_HOST:-0.0.0.0} --port ${PORT_FRONTEND_DEV:-5173}", + "ports:list": "node scripts/ports-report.cjs", + "dev:web:stop": "lsof -tiTCP:5173 -sTCP:LISTEN | xargs kill 2>/dev/null || true && docker compose down", "start": "electron .", + "start:desktop": "electron .", "build": "electron-builder", "build:desktop": "npm run build:renderer && npm run sync:dist && npm run build", "build:renderer": "npm run build --prefix frontend", @@ -11,6 +16,8 @@ "test:wiring": "node scripts/wiring-smoke-test.cjs", "test:persistence": "node scripts/persistence-self-test.cjs", "test:persistence:restart": "node scripts/persistence-self-test.cjs --restart-backend", + "test:mvp": "node scripts/mvp-check.cjs", + "test:mvp:restart": "node scripts/mvp-check.cjs --restart-backend", "build:verified": "npm run test:persistence && npm run build", "build:verified:restart": "npm run test:persistence:restart && npm run build", "release:verified": "npm run test:persistence && npm run build:renderer && npm run sync:dist && npm run build", diff --git a/scripts/adhoc-sign.js b/scripts/adhoc-sign.js index f8bca86..ba08738 100644 --- a/scripts/adhoc-sign.js +++ b/scripts/adhoc-sign.js @@ -9,7 +9,30 @@ exports.default = async function (context) { `${context.packager.appInfo.productFilename}.app`, ); console.log(`Ad-hoc signing: ${appPath}`); - execSync(`xattr -cr "${appPath}"`); - execSync(`codesign --force --deep --sign - "${appPath}"`); - console.log('Ad-hoc signing complete.'); + + // Remove Finder/iCloud metadata that can make codesign fail with + // "resource fork, Finder information, or similar detritus not allowed". + const cleanupCmds = [ + `xattr -cr "${appPath}"`, + `dot_clean -m "${appPath}"`, + `find "${appPath}" -name '._*' -delete`, + `xattr -r -d com.apple.FinderInfo "${appPath}"`, + `xattr -r -d com.apple.ResourceFork "${appPath}"`, + `xattr -r -d com.apple.quarantine "${appPath}"`, + ]; + + for (const cmd of cleanupCmds) { + try { + execSync(cmd, { stdio: 'ignore' }); + } catch { + // Cleanup commands are best-effort. + } + } + + try { + execSync(`codesign --force --deep --sign - "${appPath}"`, { stdio: 'inherit' }); + console.log('Ad-hoc signing complete.'); + } catch (err) { + console.warn('Ad-hoc signing skipped due to codesign error. Build artifacts are still usable locally.'); + } }; diff --git a/scripts/mvp-check.cjs b/scripts/mvp-check.cjs new file mode 100644 index 0000000..3fcfc03 --- /dev/null +++ b/scripts/mvp-check.cjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node + +const { spawnSync } = require('child_process'); + +const args = new Set(process.argv.slice(2)); +if (args.has('--help') || args.has('-h')) { + console.log('Project Hub MVP readiness check'); + console.log(''); + console.log('Usage:'); + console.log(' node scripts/mvp-check.cjs [--restart-backend]'); + console.log(''); + console.log('Environment:'); + console.log(' API_BASE default: http://localhost:4000/api'); + console.log(' WRITE_API_KEY optional x-api-key for write endpoints'); + process.exit(0); +} + +const backendPort = process.env.PORT_BACKEND || '4000'; +const API_BASE = process.env.API_BASE || `http://localhost:${backendPort}/api`; +const HEALTH_URL = API_BASE.replace(/\/api\/?$/, '') + '/health'; +const RESTART_BACKEND = args.has('--restart-backend'); + +function log(msg) { + console.log(`[mvp-check] ${msg}`); +} + +function formatCheck(status, name, detail = '') { + const suffix = detail ? `: ${detail}` : ''; + console.log(`[mvp-check] ${status} ${name}${suffix}`); +} + +async function checkHealth() { + try { + const response = await fetch(HEALTH_URL, { signal: AbortSignal.timeout(5000) }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return { ok: true }; + } catch (err) { + return { ok: false, detail: err.message }; + } +} + +function runNodeScript(scriptPath, scriptArgs = []) { + const result = spawnSync(process.execPath, [scriptPath, ...scriptArgs], { + env: process.env, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }); + + const output = [result.stdout || '', result.stderr || ''].join('').trim(); + const lines = output ? output.split(/\r?\n/).filter(Boolean) : []; + const tail = lines.slice(-6).join(' | '); + + if (result.error) { + return { ok: false, detail: result.error.message }; + } + if (result.status !== 0) { + return { ok: false, detail: tail || `exit ${result.status}` }; + } + return { ok: true, detail: tail }; +} + +async function run() { + log('Starting MVP hardening checks...'); + log(`API base: ${API_BASE}`); + + const results = []; + + const health = await checkHealth(); + results.push({ name: 'backend-health', ...health }); + formatCheck(health.ok ? 'PASS' : 'FAIL', 'backend-health', health.detail || HEALTH_URL); + + const wiring = runNodeScript('scripts/wiring-smoke-test.cjs'); + results.push({ name: 'wiring-smoke', ...wiring }); + formatCheck(wiring.ok ? 'PASS' : 'FAIL', 'wiring-smoke', wiring.detail); + + const persistenceArgs = RESTART_BACKEND ? ['--restart-backend'] : []; + const persistence = runNodeScript('scripts/persistence-self-test.cjs', persistenceArgs); + results.push({ name: RESTART_BACKEND ? 'persistence-restart' : 'persistence', ...persistence }); + formatCheck(persistence.ok ? 'PASS' : 'FAIL', RESTART_BACKEND ? 'persistence-restart' : 'persistence', persistence.detail); + + const failed = results.filter(r => !r.ok); + if (failed.length > 0) { + log('NOT READY'); + log(`Failed checks: ${failed.map(f => f.name).join(', ')}`); + process.exit(1); + } + + log('READY'); + log('All automated MVP hardening checks passed.'); +} + +run().catch(err => { + log(`NOT READY: ${err.message}`); + process.exit(1); +}); diff --git a/scripts/persistence-self-test.cjs b/scripts/persistence-self-test.cjs index d3b9856..e105e78 100644 --- a/scripts/persistence-self-test.cjs +++ b/scripts/persistence-self-test.cjs @@ -15,7 +15,8 @@ if (args.has('--help') || args.has('-h')) { process.exit(0); } -const API_BASE = process.env.API_BASE || 'http://localhost:4000/api'; +const backendPort = process.env.PORT_BACKEND || '4000'; +const API_BASE = process.env.API_BASE || `http://localhost:${backendPort}/api`; const HEALTH_URL = API_BASE.replace(/\/api\/?$/, '') + '/health'; const WRITE_API_KEY = process.env.WRITE_API_KEY || ''; const SHOULD_RESTART = args.has('--restart-backend'); diff --git a/scripts/ports-report.cjs b/scripts/ports-report.cjs new file mode 100644 index 0000000..521998d --- /dev/null +++ b/scripts/ports-report.cjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const net = require('net'); + +const root = process.cwd(); +const envPath = path.join(root, '.env'); + +function parseEnv(filePath) { + if (!fs.existsSync(filePath)) return {}; + const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/); + const env = {}; + + for (const raw of lines) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + const idx = line.indexOf('='); + if (idx <= 0) continue; + + const key = line.slice(0, idx).trim(); + let value = line.slice(idx + 1).trim(); + + // Strip trailing comments for unquoted values. + if (!value.startsWith('"') && !value.startsWith("'")) { + const hashIdx = value.indexOf(' #'); + if (hashIdx >= 0) value = value.slice(0, hashIdx).trim(); + } + + env[key] = value; + } + + return env; +} + +function checkPortOpen(port, host = '127.0.0.1', timeoutMs = 500) { + return new Promise(resolve => { + const socket = new net.Socket(); + let settled = false; + + const done = (isOpen) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(isOpen); + }; + + socket.setTimeout(timeoutMs); + socket.once('connect', () => done(true)); + socket.once('timeout', () => done(false)); + socket.once('error', () => done(false)); + socket.connect(Number(port), host); + }); +} + +async function run() { + const env = parseEnv(envPath); + + const backendPort = env.PORT_BACKEND || '4000'; + const frontendPort = env.PORT_FRONTEND_DEV || '5173'; + const frontendFallback = env.PORT_FRONTEND_DEV_FALLBACK || '5174'; + + const ports = [ + { name: 'Backend API', key: 'PORT_BACKEND', value: backendPort }, + { name: 'Frontend dev', key: 'PORT_FRONTEND_DEV', value: frontendPort }, + { name: 'Frontend fallback', key: 'PORT_FRONTEND_DEV_FALLBACK', value: frontendFallback }, + ]; + + console.log('[ports] Dedicated ports for this project'); + console.log(`[ports] Source: ${envPath}`); + console.log(''); + + for (const p of ports) { + const isOpen = await checkPortOpen(p.value); + const state = isOpen ? 'IN USE' : 'free'; + console.log(`[ports] ${p.key}=${p.value} (${p.name}) -> ${state}`); + } + + console.log(''); + console.log(`[ports] APP_URL=${env.APP_URL || `http://localhost:${frontendPort}`}`); + console.log(`[ports] API_BASE=${env.API_BASE || `http://localhost:${backendPort}/api`}`); + console.log(`[ports] VITE_API_URL=${env.VITE_API_URL || `http://localhost:${backendPort}/api`}`); + if (env.CORS_ORIGIN) console.log(`[ports] CORS_ORIGIN=${env.CORS_ORIGIN}`); +} + +run().catch(err => { + console.error(`[ports] Failed: ${err.message}`); + process.exit(1); +}); diff --git a/scripts/wiring-smoke-test.cjs b/scripts/wiring-smoke-test.cjs index ab36da1..41d79b6 100644 --- a/scripts/wiring-smoke-test.cjs +++ b/scripts/wiring-smoke-test.cjs @@ -1,6 +1,7 @@ #!/usr/bin/env node -const API_BASE = process.env.API_BASE || 'http://localhost:4000/api'; +const backendPort = process.env.PORT_BACKEND || '4000'; +const API_BASE = process.env.API_BASE || `http://localhost:${backendPort}/api`; const HEALTH_URL = API_BASE.replace(/\/api\/?$/, '') + '/health'; const WRITE_API_KEY = process.env.WRITE_API_KEY || '';