Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changelog/NEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
14 changes: 10 additions & 4 deletions server/lib/socketValidation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions server/routes/standardize.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}`);
Expand Down
75 changes: 63 additions & 12 deletions server/services/pm2Standardizer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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};
`;
Expand Down Expand Up @@ -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: []
};

Expand All @@ -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');
Expand All @@ -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);
Expand Down Expand Up @@ -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).
Expand All @@ -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 });

Expand Down Expand Up @@ -662,17 +708,21 @@ 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) {
emit('apply', 'error', { message: result.errors.join(', ') });
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`);
Expand All @@ -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
}
};
Expand Down
140 changes: 136 additions & 4 deletions server/services/pm2Standardizer.test.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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');
});
});
Loading