diff --git a/.changelog/NEXT.md b/.changelog/NEXT.md index 0a713a6a6..140f78425 100644 --- a/.changelog/NEXT.md +++ b/.changelog/NEXT.md @@ -9,3 +9,7 @@ - **Multiplication drills now ramp up instead of starting hard.** The mental-math multiplication drill used to open at a fixed 2-digit × 2-digit difficulty (e.g. `566 × 191`) for everyone. It now climbs a mastery-gated ladder — `1×1` → `1×2` → `1×1×1` → `2×2` → … — starting at single-digit × single-digit and advancing to the next rung only after you answer a level quickly *and* accurately (≥90% correct within a per-rung speed target). The drill header and the config page show your current rung and per-level mastery. On by default; turn off "Progressive difficulty" on the Multiplication card to go back to the manual Max Digits setting. - **Morse trainer audio now works on mobile Safari.** iOS Safari starts the Web Audio context suspended and only unlocks it once `resume()` fully settles. The trainer fired `resume()` without awaiting it, so the first tones were scheduled against a still-suspended clock and never sounded on iPhone/iPad. It now awaits the resume before scheduling any tone (matching the app's other audio modules), so Copy, Head Copy, and Send-mode keying all produce sound on mobile Safari. - **The Elements Song trainer is now mobile-friendly and speed-readable.** The periodic-table memorization view no longer overflows off the right edge of a phone screen: the mastery summary, search box, and Mastery/Category toggle now stack and stretch to full width on small screens, the periodic table uses smaller cells (with a scroll-hint fade so it's clear the grid pans horizontally), and the header shrinks to fit. It also gained a **Rapid Read** practice mode that RSVP-flashes the lyrics one word at a time (reusing the app's speed reader) — run the whole song from the Practice list, or tap the gauge icon on any verse to speed-read just that section. + +## Fixed + +- **PM2 standardization no longer overwrites a PM2 config PortOS didn't generate.** Standardizing an app used to always regenerate `ecosystem.config.cjs`, silently replacing a hand-written one — losing its custom ports and settings. A config PortOS didn't generate is now preserved by default (and its ports are left untouched in `.env`/Vite too); PortOS only regenerates its own previously-generated config. To deliberately replace a custom config, opt in with the overwrite flag when standardizing. diff --git a/server/lib/socketValidation.js b/server/lib/socketValidation.js index 27556c3f6..7c9e38efe 100644 --- a/server/lib/socketValidation.js +++ b/server/lib/socketValidation.js @@ -9,10 +9,13 @@ export const detectStartSchema = z.object({ path: z.string().min(1, 'path is required') }); -// standardize:start — repo path and optional provider +// standardize:start — repo path and optional provider. +// `overwriteEcosystem: true` is the explicit opt-in to regenerate an existing, +// non-PortOS-generated ecosystem.config.cjs (default false preserves it). export const standardizeStartSchema = z.object({ repoPath: z.string().min(1, 'repoPath is required'), - providerId: z.string().min(1).optional() + providerId: z.string().min(1).optional(), + overwriteEcosystem: z.boolean().optional() }); // logs:subscribe — process name and optional line count @@ -62,9 +65,12 @@ export const appUpdateSchema = z.object({ appId: z.string().min(1, 'appId is required') }); -// app:standardize — app ID for PM2 standardization +// app:standardize — app ID for PM2 standardization. +// `overwriteEcosystem: true` is the explicit opt-in to regenerate an existing, +// non-PortOS-generated ecosystem.config.cjs (default false preserves it). export const appStandardizeSchema = z.object({ - appId: z.string().min(1, 'appId is required') + appId: z.string().min(1, 'appId is required'), + overwriteEcosystem: z.boolean().optional() }); // app:deploy — app ID and optional flags for Xcode deploy diff --git a/server/routes/standardize.js b/server/routes/standardize.js index 66e1b85c5..76d4955a5 100644 --- a/server/routes/standardize.js +++ b/server/routes/standardize.js @@ -38,7 +38,7 @@ router.post('/analyze', asyncHandler(async (req, res) => { // POST /api/standardize/apply - Apply standardization changes router.post('/apply', asyncHandler(async (req, res) => { - const { repoPath, appId, plan } = req.body; + const { repoPath, appId, plan, overwriteEcosystem = false } = req.body; // Get path from appId if provided let path = repoPath; @@ -60,7 +60,7 @@ router.post('/apply', asyncHandler(async (req, res) => { console.log(`🔧 Applying PM2 standardization to: ${path}`); - const result = await pm2Standardizer.applyStandardization(path, plan); + const result = await pm2Standardizer.applyStandardization(path, plan, { overwriteEcosystem }); if (result.backupBranch) { console.log(`📦 Backup branch created: ${result.backupBranch}`); diff --git a/server/services/pm2Standardizer.js b/server/services/pm2Standardizer.js index 0d8619334..7e4f60a2b 100644 --- a/server/services/pm2Standardizer.js +++ b/server/services/pm2Standardizer.js @@ -13,6 +13,14 @@ import { getListeningPorts } from '../lib/platform.js'; const execAsync = promisify(exec); const DEFAULT_PM2_AI_TIMEOUT_MS = 180000; +// Marker written into the header of every ecosystem.config.cjs PortOS generates. +// Used both to stamp our output (generateEcosystemContent) and to recognize it +// again on a later run (applyStandardization) — a file carrying this marker is +// PortOS's own and safe to regenerate; one without it is user-authored and is +// preserved unless the caller explicitly opts in to overwrite. Keep the two uses +// on this single constant so the stamp and the detection can never drift apart. +export const PORTOS_ECOSYSTEM_MARKER = 'Generated by PortOS PM2 Standardizer'; + // Common Vite dev defaults — always avoided, since a developer machine almost // always already has something on 5173 (and 5174 is Vite's auto-fallback). const VITE_DEFAULT_PORTS = [5173, 5174]; @@ -394,7 +402,7 @@ function generateEcosystemContent(processes, appName) { .replace(/"/g, "'"); // Use single quotes for strings return `// PM2 Ecosystem Configuration -// Generated by PortOS PM2 Standardizer +// ${PORTOS_ECOSYSTEM_MARKER} module.exports = ${configStr}; `; @@ -510,11 +518,16 @@ export async function analyzeApp(repoPath, providerId = null) { /** * Apply standardization changes to the repository */ -export async function applyStandardization(repoPath, plan, { skipBackup = false } = {}) { +export async function applyStandardization( + repoPath, + plan, + { skipBackup = false, overwriteEcosystem = false } = {} +) { const results = { success: true, backupBranch: null, filesModified: [], + filesPreserved: [], errors: [] }; @@ -532,11 +545,31 @@ export async function applyStandardization(repoPath, plan, { skipBackup = false } } - // Write ecosystem.config.cjs + // Write ecosystem.config.cjs — but never silently clobber a user-authored one. + // A file carrying our marker is PortOS's own output and safe to regenerate; a + // file without it (hand-written, or another tool's) is preserved unless the + // caller explicitly opted in via `overwriteEcosystem`. Re-read from disk here + // rather than trusting the plan's `hasEcosystem` — the file may have appeared + // or changed between analyze and apply, and a read failure fails safe toward + // preserving. This is what stops "standardize an app" from destroying a custom + // config (its ports/settings) without an explicit go-ahead. const ecosystemPath = join(repoPath, 'ecosystem.config.cjs'); - await writeFile(ecosystemPath, plan.proposedChanges.ecosystemContent, 'utf-8'); - results.filesModified.push('ecosystem.config.cjs'); - console.log(`✅ Written ecosystem.config.cjs`); + const ecosystemExists = existsSync(ecosystemPath); + const existingEcosystem = ecosystemExists ? await tryReadFile(ecosystemPath) : null; + const isPortOSGenerated = + !!existingEcosystem && existingEcosystem.includes(PORTOS_ECOSYSTEM_MARKER); + const preserveEcosystem = ecosystemExists && !isPortOSGenerated && !overwriteEcosystem; + + if (preserveEcosystem) { + results.filesPreserved.push('ecosystem.config.cjs'); + console.log( + '⏭️ Preserved existing ecosystem.config.cjs (not PortOS-generated; pass overwriteEcosystem to regenerate)' + ); + } else { + await writeFile(ecosystemPath, plan.proposedChanges.ecosystemContent, 'utf-8'); + results.filesModified.push('ecosystem.config.cjs'); + console.log(`✅ Written ecosystem.config.cjs`); + } // Remove old ecosystem.config.js if we're creating .cjs const oldEcosystemJs = join(repoPath, 'ecosystem.config.js'); @@ -546,8 +579,11 @@ export async function applyStandardization(repoPath, plan, { skipBackup = false results.filesModified.push('ecosystem.config.js (removed)'); } - // Process stray ports - remove from .env files - for (const stray of plan.proposedChanges.strayPorts || []) { + // Process stray ports - remove from .env files. Skip entirely when we preserved + // the user's ecosystem: pulling ports out of their .env/vite.config only makes + // sense once PortOS's generated ecosystem owns them, so leave the user's ports + // wherever they put them. + for (const stray of preserveEcosystem ? [] : plan.proposedChanges.strayPorts || []) { if (stray.action !== 'remove') continue; const filePath = join(repoPath, stray.file); @@ -612,6 +648,9 @@ export async function applyStandardization(repoPath, plan, { skipBackup = false * @param {object} [opts] * @param {(evt: {step: string, status: string, data: object}) => void} [opts.onStep] * @param {(evt: {plan: object}) => void} [opts.onAnalyzed] + * @param {boolean} [opts.overwriteEcosystem] - Explicit opt-in to overwrite an + * existing, non-PortOS-generated ecosystem.config.cjs. Default false preserves + * a user-authored config (see applyStandardization). * @param {Function} [opts.analyze] - Injectable step impl (defaults to analyzeApp); * a test seam so the orchestration is unit-testable without real AI analysis. * @param {Function} [opts.backup] - Injectable step impl (defaults to createGitBackup). @@ -621,7 +660,14 @@ export async function applyStandardization(repoPath, plan, { skipBackup = false export async function runStandardizeFlow( repoPath, providerId = null, - { onStep, onAnalyzed, analyze = analyzeApp, backup = createGitBackup, apply = applyStandardization } = {} + { + onStep, + onAnalyzed, + overwriteEcosystem = false, + analyze = analyzeApp, + backup = createGitBackup, + apply = applyStandardization + } = {} ) { const emit = (step, status, data = {}) => onStep?.({ step, status, data }); @@ -662,7 +708,7 @@ export async function runStandardizeFlow( // Step 3: Apply changes (backup already handled above) emit('apply', 'running', { message: 'Writing ecosystem.config.cjs...' }); - const result = await apply(repoPath, analysis, { skipBackup: true }) + const result = await apply(repoPath, analysis, { skipBackup: true, overwriteEcosystem }) .catch(err => ({ success: false, errors: [err.message] })); if (result.errors?.length > 0) { @@ -670,9 +716,13 @@ export async function runStandardizeFlow( return { success: false, error: result.errors.join(', ') }; } + const preserved = result.filesPreserved || []; emit('apply', 'done', { - message: `Modified ${result.filesModified.length} files`, - filesModified: result.filesModified + message: preserved.length + ? `Modified ${result.filesModified.length} files, preserved ${preserved.length}` + : `Modified ${result.filesModified.length} files`, + filesModified: result.filesModified, + filesPreserved: preserved }); console.log(`✅ Standardization complete: ${result.filesModified.length} files modified`); @@ -683,6 +733,7 @@ export async function runStandardizeFlow( // Use the backup branch from step 2 since step 3 skipped its own backup. backupBranch: backupResult.branch || null, filesModified: result.filesModified, + filesPreserved: preserved, processes: analysis.proposedChanges.processes } }; diff --git a/server/services/pm2Standardizer.test.js b/server/services/pm2Standardizer.test.js index a47ef240e..d8a297286 100644 --- a/server/services/pm2Standardizer.test.js +++ b/server/services/pm2Standardizer.test.js @@ -1,5 +1,13 @@ -import { describe, it, expect, vi } from 'vitest'; -import { reassignCollidingPorts, runStandardizeFlow } from './pm2Standardizer.js'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { + reassignCollidingPorts, + runStandardizeFlow, + applyStandardization, + PORTOS_ECOSYSTEM_MARKER +} from './pm2Standardizer.js'; describe('reassignCollidingPorts', () => { it('moves a process off a taken port and rewrites both env.PORT and --port args', () => { @@ -116,18 +124,51 @@ describe('runStandardizeFlow', () => { const outcome = await runStandardizeFlow('/repo', 'prov-1', { analyze, backup, apply }); expect(analyze).toHaveBeenCalledWith('/repo', 'prov-1'); - // Step 3 must skip its own backup since step 2 already made one. - expect(apply).toHaveBeenCalledWith('/repo', okAnalysis, { skipBackup: true }); + // Step 3 must skip its own backup since step 2 already made one, and defaults + // to preserving an existing config (overwriteEcosystem false) when not opted in. + expect(apply).toHaveBeenCalledWith('/repo', okAnalysis, { + skipBackup: true, + overwriteEcosystem: false + }); expect(outcome).toEqual({ success: true, result: { backupBranch: 'portos-backup-123', filesModified: ['ecosystem.config.cjs'], + filesPreserved: [], processes: okAnalysis.proposedChanges.processes } }); }); + it('forwards an explicit overwriteEcosystem opt-in through to apply', async () => { + const apply = vi.fn().mockResolvedValue({ success: true, filesModified: ['ecosystem.config.cjs'], errors: [] }); + await runStandardizeFlow('/repo', null, { + overwriteEcosystem: true, + analyze: vi.fn().mockResolvedValue(okAnalysis), + backup: vi.fn().mockResolvedValue({ success: true, branch: 'b1' }), + apply + }); + expect(apply).toHaveBeenCalledWith('/repo', okAnalysis, { + skipBackup: true, + overwriteEcosystem: true + }); + }); + + it('surfaces filesPreserved from the apply result in the outcome', async () => { + const outcome = await runStandardizeFlow('/repo', null, { + analyze: vi.fn().mockResolvedValue(okAnalysis), + backup: vi.fn().mockResolvedValue({ success: true, branch: 'b1' }), + apply: vi.fn().mockResolvedValue({ + success: true, + filesModified: [], + filesPreserved: ['ecosystem.config.cjs'], + errors: [] + }) + }); + expect(outcome.result.filesPreserved).toEqual(['ecosystem.config.cjs']); + }); + it('emits ordered step + analyzed callbacks while it runs', async () => { const steps = []; const analyzed = vi.fn(); @@ -199,3 +240,94 @@ describe('runStandardizeFlow', () => { expect(outcome).toEqual({ success: false, error: 'boom' }); }); }); + +describe('applyStandardization — preserve existing ecosystem.config.cjs', () => { + let dir = null; + afterEach(() => { if (dir) rmSync(dir, { recursive: true, force: true }); dir = null; }); + + const NEW_CONTENT = `// PM2 Ecosystem Configuration\n// ${PORTOS_ECOSYSTEM_MARKER}\n\nmodule.exports = { apps: [{ name: 'new', script: 'x.js' }] };\n`; + const USER_CONFIG = "module.exports = { apps: [{ name: 'mine', script: 'server.js', env: { PORT: 5261 } }] };\n"; + const OLD_PORTOS_CONFIG = `// PM2 Ecosystem Configuration\n// ${PORTOS_ECOSYSTEM_MARKER}\n\nmodule.exports = { apps: [{ name: 'old', script: 'y.js' }] };\n`; + + const makePlan = (strayPorts = []) => ({ + currentState: { hasGit: false }, + proposedChanges: { ecosystemContent: NEW_CONTENT, createEcosystem: false, strayPorts } + }); + + const ecoPath = () => join(dir, 'ecosystem.config.cjs'); + + it('preserves a user-authored config (no PortOS marker) by default', async () => { + dir = mkdtempSync(join(tmpdir(), 'eco-preserve-')); + writeFileSync(ecoPath(), USER_CONFIG); + + const result = await applyStandardization(dir, makePlan(), { skipBackup: true }); + + expect(readFileSync(ecoPath(), 'utf-8')).toBe(USER_CONFIG); // untouched + expect(result.filesPreserved).toContain('ecosystem.config.cjs'); + expect(result.filesModified).not.toContain('ecosystem.config.cjs'); + }); + + it('regenerates a PortOS-generated config (has marker) by default', async () => { + dir = mkdtempSync(join(tmpdir(), 'eco-regen-')); + writeFileSync(ecoPath(), OLD_PORTOS_CONFIG); + + const result = await applyStandardization(dir, makePlan(), { skipBackup: true }); + + expect(readFileSync(ecoPath(), 'utf-8')).toBe(NEW_CONTENT); // overwritten + expect(result.filesModified).toContain('ecosystem.config.cjs'); + expect(result.filesPreserved).not.toContain('ecosystem.config.cjs'); + }); + + it('overwrites a user-authored config when overwriteEcosystem is true', async () => { + dir = mkdtempSync(join(tmpdir(), 'eco-force-')); + writeFileSync(ecoPath(), USER_CONFIG); + + const result = await applyStandardization(dir, makePlan(), { + skipBackup: true, + overwriteEcosystem: true + }); + + expect(readFileSync(ecoPath(), 'utf-8')).toBe(NEW_CONTENT); + expect(result.filesModified).toContain('ecosystem.config.cjs'); + }); + + it('writes a new config when none exists', async () => { + dir = mkdtempSync(join(tmpdir(), 'eco-new-')); + + const result = await applyStandardization(dir, makePlan(), { skipBackup: true }); + + expect(existsSync(ecoPath())).toBe(true); + expect(readFileSync(ecoPath(), 'utf-8')).toBe(NEW_CONTENT); + expect(result.filesModified).toContain('ecosystem.config.cjs'); + }); + + it('leaves .env stray ports alone when the config is preserved', async () => { + dir = mkdtempSync(join(tmpdir(), 'eco-stray-preserve-')); + writeFileSync(ecoPath(), USER_CONFIG); + const envBefore = 'PORT=5261\nNODE_ENV=development\n'; + writeFileSync(join(dir, '.env'), envBefore); + + await applyStandardization( + dir, + makePlan([{ action: 'remove', file: '.env', variable: 'PORT' }]), + { skipBackup: true } + ); + + // Preserving the config means we don't strip the user's ports elsewhere either. + expect(readFileSync(join(dir, '.env'), 'utf-8')).toBe(envBefore); + }); + + it('still strips .env stray ports when the config is (re)generated', async () => { + dir = mkdtempSync(join(tmpdir(), 'eco-stray-regen-')); + writeFileSync(ecoPath(), OLD_PORTOS_CONFIG); // PortOS-generated → regenerated + writeFileSync(join(dir, '.env'), 'PORT=5261\nNODE_ENV=development\n'); + + await applyStandardization( + dir, + makePlan([{ action: 'remove', file: '.env', variable: 'PORT' }]), + { skipBackup: true } + ); + + expect(readFileSync(join(dir, '.env'), 'utf-8')).not.toContain('PORT=5261'); + }); +}); diff --git a/server/services/socket.js b/server/services/socket.js index 38d85988d..69c610344 100644 --- a/server/services/socket.js +++ b/server/services/socket.js @@ -184,10 +184,11 @@ export function initSocket(io) { try { const data = validateSocketData(standardizeStartSchema, rawData, socket, 'standardize:start'); if (!data) return; - const { repoPath, providerId } = data; + const { repoPath, providerId, overwriteEcosystem = false } = data; console.log(`🔧 Starting PM2 standardization: ${repoPath}`); const outcome = await pm2Standardizer.runStandardizeFlow(repoPath, providerId, { + overwriteEcosystem, onStep: ({ step, status, data }) => { socket.emit('standardize:step', { step, status, data, timestamp: Date.now() }); }, @@ -390,15 +391,19 @@ export function initSocket(io) { // Step 3: Apply emit('apply', 'running', 'Writing ecosystem.config.cjs...'); - const result = await pm2Standardizer.applyStandardization(app.repoPath, analysis) - .catch(err => ({ success: false, errors: [err.message] })); + const result = await pm2Standardizer.applyStandardization(app.repoPath, analysis, { + overwriteEcosystem: data.overwriteEcosystem ?? false + }).catch(err => ({ success: false, errors: [err.message] })); if (result.errors?.length > 0) { emit('apply', 'error', result.errors.join(', ')); socket.emit('app:standardize:error', { message: result.errors.join(', ') }); return; } - emit('apply', 'done', `Modified ${result.filesModified.length} files`); + const preserved = result.filesPreserved || []; + emit('apply', 'done', preserved.length + ? `Modified ${result.filesModified.length} files, preserved ${preserved.length}` + : `Modified ${result.filesModified.length} files`); // Update app with new PM2 process names if (analysis.proposedChanges?.processes) { @@ -411,6 +416,7 @@ export function initSocket(io) { result: { backupBranch: result.backupBranch, filesModified: result.filesModified, + filesPreserved: preserved, processes: analysis.proposedChanges.processes } });