feat(config): real config reset/repair + migration framework (drop manual-delete) (#38)

Eliminates the fragile "delete the config folder before upgrading" tester
instruction, which was wrong-by-OS because the userData folder name was
derived inconsistently per platform (fee[dB]ack on macOS, slopsmith-desktop
on Linux/Windows).

A. Deterministic paths + migration framework
- Pin the userData name on every OS via app.setName('feedback-desktop') +
  build.extraMetadata.name; brand (productName 'fee[dB]ack') unchanged.
- One-time userData migration copies a legacy folder into the new one so
  upgraded users don't start fresh (atomic copy-then-rename, fail-soft).
  Runs before the single-instance lock / crashReporter, which would otherwise
  create userData and defeat the "new dir doesn't exist" gate.
- config-migrations.ts: versioned, ordered, idempotent, fail-soft migration
  runner stamped in CONFIG_DIR/config_version.json; logs the active CONFIG_DIR
  at startup (closes the Linux ~/.local/share/slopsmith shared-config gap).

B. In-app "Reset / repair configuration" (Settings panel)
- Granular options: reset app settings & caches, clear plugin state & cached
  Python deps, and full reset with default-OFF opt-ins for installed plugins /
  song library / ML caches.
- config-paths.ts is the single source of truth for per-OS path enumeration;
  the song library, installed plugins and ML caches are structurally confined
  to optInExtras and never wiped by the safe/full categories.
- Reset stops the backend, deletes immediate paths, includes SQLite WAL/SHM
  sidecars + the migration stamp on full reset, and defers Chromium/Crashpad
  state to next launch (consumed before any window reopens it). ML caches honor
  TORCH_HOME/HF_HOME. Empty selection is a no-op (backend left running).
- SECURITY: destructive resets require a native main-process confirmation
  dialog — the renderer bridge is reachable by plugin scripts, so a
  renderer-only confirm is not a sufficient gate.

Tests: node:test suites for path enumeration (per-OS + library/plugins
preserved), migration idempotency/fail-soft, reset delete pipeline guarantees,
userData migration, and deferred-deletion schedule/consume. `npm test` green
(adds a test script). codex review --base origin/main clean.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-06-26 22:13:24 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent cc0aceb365
commit 5188aab938
16 changed files with 1537 additions and 5 deletions
+60 -4
View File
@@ -60,6 +60,39 @@ import { app, BrowserWindow, ipcMain, dialog, shell, session, crashReporter, pow
import * as path from 'path';
import * as fs from 'fs';
import { execFileSync } from 'child_process';
import { migrateUserDataIfNeeded, consumePendingResetIfNeeded } from './config-bootstrap';
// Pin the userData folder name on every OS. Before this it was derived from the
// build name and differed per-OS ('fee[dB]ack' on macOS, 'slopsmith-desktop' on
// Linux/Windows), so a single "delete the config folder" instruction could never
// be right everywhere. setName() must run before ANY app.getPath()/crashReporter/
// whenReady so the deterministic path (<appData>/feedback-desktop) is used
// throughout. It does NOT change the user-facing brand (productName 'fee[dB]ack');
// with the name pinned, the path-hostile brackets never reach the filesystem.
app.setName('feedback-desktop');
// One-time copy of an existing legacy userData folder into the new one, so
// testers don't start fresh after the rename. MUST run before BOTH crashReporter
// (which creates <userData>/Crashpad) AND requestSingleInstanceLock() (which
// writes a SingletonLock into userData) — the migration gate is "new userData
// doesn't exist yet", so anything that creates it first would silently skip the
// migration and start the upgraded user fresh.
migrateUserDataIfNeeded();
// Acquire the single-instance lock now so the primary-only deferred-reset cleanup
// runs ONLY in the instance that will actually boot: a losing second instance
// must not consume the pending-reset manifest while the primary still holds
// Chromium / Crashpad files open. requestSingleInstanceLock() must be called
// exactly once; the lock branch at the bottom of this file reuses this result.
// SLOPSMITH_ALLOW_MULTIPLE=1 opts out (two builds side-by-side).
const allowMultipleInstances = process.env.SLOPSMITH_ALLOW_MULTIPLE === '1';
const hasSingleInstanceLock = allowMultipleInstances || app.requestSingleInstanceLock();
if (hasSingleInstanceLock) {
// Apply any reset deletions deferred from a previous "Reset configuration"
// run, before crashReporter / any BrowserWindow reopens Chromium state &
// Crashpad, so those held-open paths can actually be removed.
consumePendingResetIfNeeded();
}
// Enable Electron's Crashpad to capture native crashes (incl. VST/JUCE C++
// access violations) into <userData>/Crashpad/reports/ as .dmp files. Must
@@ -72,7 +105,9 @@ crashReporter.start({
uploadToServer: false,
compress: false,
});
import { startPython, stopPython, waitForPython, getPythonPort, StartupStatus, restartPython, getLanUrls } from './python';
import { startPython, stopPython, waitForPython, getPythonPort, StartupStatus, restartPython, getLanUrls, getConfigDir } from './python';
import { runConfigMigrations } from './config-migrations';
import { registerMaintenanceHandlers } from './config-reset';
import {
IPC_STARTUP_STATUS,
IPC_STARTUP_GET_STATUS,
@@ -907,6 +942,25 @@ async function startup(): Promise<void> {
createSplashWindow();
publishStartupStatus({ message: 'Starting backend service...', phase: 'booting', running: true });
// Run config-schema migrations against the active backend CONFIG_DIR before
// the backend starts. This replaces "delete the config folder before
// upgrading" with targeted, idempotent, fail-soft migrations. Logging the
// resolved CONFIG_DIR here also closes the visibility gap around the silent
// Linux ~/.local/share/slopsmith shared-config override (python.ts getConfigDir).
try {
const activeConfigDir = getConfigDir();
console.log(`[main] Active CONFIG_DIR: ${activeConfigDir}`);
runConfigMigrations(activeConfigDir, app.getVersion(), new Date().toISOString());
} catch (err) {
console.warn('[main] config migrations failed (continuing):', err);
}
// Register the "Reset / repair configuration" IPC handlers (Settings panel).
// Pass the window getter so the destructive-reset confirmation is a native,
// main-process modal (the renderer bridge is reachable by plugin scripts, so
// a renderer-only confirm is not a sufficient gate).
registerMaintenanceHandlers(() => mainWindow);
// Start Python server (Slopsmith backend)
startPython();
@@ -1076,9 +1130,11 @@ async function startup(): Promise<void> {
// another instance already owns it, surface that window (via 'second-instance'
// on the primary) and quit THIS one before startup() boots a competing backend
// or window. `SLOPSMITH_ALLOW_MULTIPLE=1` opts out so two builds can run
// side-by-side (e.g. A/B testing different versions).
const allowMultipleInstances = process.env.SLOPSMITH_ALLOW_MULTIPLE === '1';
if (!allowMultipleInstances && !app.requestSingleInstanceLock()) {
// side-by-side (e.g. A/B testing different versions). The lock was already
// acquired at the top of this file (hasSingleInstanceLock) so primary-only reset
// side effects could run before crashReporter — reuse that result here rather
// than calling requestSingleInstanceLock() a second time.
if (!hasSingleInstanceLock) {
app.quit();
} else {
if (!allowMultipleInstances) {