diff --git a/docs/superpowers/plans/2026-06-07-production-polish.md b/docs/superpowers/plans/2026-06-07-production-polish.md new file mode 100644 index 0000000..b1db74d --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-production-polish.md @@ -0,0 +1,1850 @@ +# Production polish — v5 shippable build + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship v5 to two distribution channels (GitHub Releases + ConcreteInfo self-hosted) with a non-blocking auto-update flow, a one-shot v4→v5 settings migration, a light first-run wizard, and local crash dump capture. + +**Architecture:** Renderer talks to main over a small IPC surface (`updater:*`, `crash:*`). Main owns `electron-updater`, a feed-config resolver, a crash writer, and the migration runner. The renderer mirrors the updater state in a Zustand store. A `firstRun` flag in `app-store` gates the wizard. + +**Tech Stack:** Electron 30+, `electron-updater`, Node 20, React 19, Zustand, Vitest + RTL, Playwright + Electron, `zod`. + +--- + +## File Structure + +| Path | Responsibility | +|---|---| +| `src/main/updater/updater-service.js` (new) | Owns the `autoUpdater` lifecycle. Emits status events. | +| `src/main/updater/feed-config.js` (new) | Maps `updateChannel` setting → feed URL. | +| `src/main/updater/crash-writer.js` (new) | Catches `uncaughtException`/`unhandledRejection` and writes JSON dumps. Caps at 20, prunes oldest. | +| `src/main/updater/migration-runner.js` (new) | Reads v4 settings, transforms to v5, writes backup, sets `migration.version`. | +| `src/main/ipc/updater-handlers.js` (new) | `updater:check`, `updater:install`, `updater:get-state` handlers. | +| `src/main/ipc/crash-handlers.js` (new) | `crash:read`, `crash:open-dir`, `crash:delete` handlers. | +| `src/renderer/lib/migrations/v4-to-v5.ts` (new) | Pure transform `(v4Settings) => v5Settings`. Zod-validated. | +| `src/renderer/lib/updater-store.ts` (new) | Zustand store mirroring updater state. | +| `src/renderer/lib/validators.ts` (modify) | Add `updateChannel` and `autoCheckUpdates` settings. | +| `src/renderer/components/UpdateBanner.tsx` (new) | Non-blocking banner with restart-to-install CTA. | +| `src/renderer/components/FirstRunWizard.tsx` (new) | 3-step modal mounted in `App.tsx`. | +| `src/renderer/components/modals/SettingsSheet.tsx` (modify) | Add Updates section. | +| `src/renderer/components/modals/CrashReportModal.tsx` (new) | List + manage local crash dumps. | +| `src/preload.js` (modify) | Allowlist + `electronAPI.updater.*`, `electronAPI.crash.*`. | +| `src/main/index.js` (modify) | Initialize updater-service, crash-writer, migration-runner on `app.whenReady`. | +| `src/renderer/App.tsx` (modify) | Mount ``, ``, ``. | +| `tests/unit/lib/migrations/v4-to-v5.test.ts` (new) | Golden-file + idempotency tests. | +| `tests/unit/lib/updater-store.test.ts` (new) | State transitions, debounce. | +| `tests/unit/main/updater/feed-config.test.js` (new) | Channel-to-URL mapping. | +| `tests/unit/main/updater/migration-runner.test.js` (new) | All migration branches. | +| `tests/component/UpdateBanner.test.tsx` (new) | Hidden/visible states, restart click. | +| `tests/component/FirstRunWizard.test.tsx` (new) | Skip, theme, channel, template. | +| `tests/component/modals/CrashReportModal.test.tsx` (new) | List, open, delete. | +| `tests/integration/migration-flow.test.tsx` (new) | Pre-seed v4, launch, verify migration. | +| `.github/workflows/release.yml` (modify) | Add `--publish=always` to publish latest.yml, add ConcreteInfo mirror step. | +| `.github/workflows/ci.yml` (modify) | Run vitest + jest on every PR. | +| `package.json` (modify) | Add `electron-updater` dep; `npm run publish:concreteinfo` script. | + +--- + +## Task 1: Install electron-updater + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Install the dep** + +```bash +npm install --save electron-updater@^6 +``` + +- [ ] **Step 2: Verify install** + +Run: `node -e "console.log(require('electron-updater').autoUpdater ? 'ok' : 'missing')"` +Expected: `ok` + +- [ ] **Step 3: Commit** + +```bash +git add package.json package-lock.json +git commit -m "chore(deps): add electron-updater for auto-update flow" +``` + +--- + +## Task 2: feed-config module (TDD) + +**Files:** +- Create: `src/main/updater/feed-config.js` +- Test: `tests/unit/main/updater/feed-config.test.js` + +- [ ] **Step 1: Write the failing test** + +`tests/unit/main/updater/feed-config.test.js`: +```js +const { resolveFeedUrl, FEEDS } = require('../../../../src/main/updater/feed-config'); + +describe('feed-config', () => { + test('resolves github channel to GitHub Releases feed', () => { + const url = resolveFeedUrl('github', '5.0.2', 'mac'); + expect(url).toBe('https://github.com/amitwh/markdown-converter/releases/download/v5.0.2/latest-mac.yml'); + }); + + test('resolves concreteinfo channel to CI feed', () => { + const url = resolveFeedUrl('concreteinfo', '5.0.2', 'mac'); + expect(url).toBe('https://updates.concreteinfo.co.in/v5/latest-mac.yml'); + }); + + test('resolves windows platform correctly', () => { + const url = resolveFeedUrl('github', '5.0.2', 'windows'); + expect(url).toContain('latest-windows.yml'); + }); + + test('resolves linux platform correctly', () => { + const url = resolveFeedUrl('github', '5.0.2', 'linux'); + expect(url).toContain('latest-linux.yml'); + }); + + test('falls back to github on unknown channel', () => { + const url = resolveFeedUrl('something-weird', '5.0.2', 'mac'); + expect(url).toContain('github.com'); + }); + + test('exports both feeds as constants', () => { + expect(FEEDS.github).toBeDefined(); + expect(FEEDS.concreteinfo).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Run test, confirm it fails** + +Run: `npx jest tests/unit/main/updater/feed-config.test.js` +Expected: FAIL — `Cannot find module '../../../../src/main/updater/feed-config'` + +- [ ] **Step 3: Implement the module** + +`src/main/updater/feed-config.js`: +```js +const REPO = 'amitwh/markdown-converter'; + +const FEEDS = { + github: { + base: `https://github.com/${REPO}/releases/download`, + }, + concreteinfo: { + base: 'https://updates.concreteinfo.co.in/v5', + }, +}; + +function platformSuffix(platform) { + if (platform === 'darwin' || platform === 'mac') return 'mac'; + if (platform === 'win32' || platform === 'windows') return 'windows'; + return 'linux'; +} + +function feedForChannel(channel, version, platform) { + const suffix = platformSuffix(platform); + const f = FEEDS[channel] || FEEDS.github; + if (channel === 'github') { + return `${f.base}/v${version}/latest-${suffix}.yml`; + } + return `${f.base}/latest-${suffix}.yml`; +} + +function resolveFeedUrl(channel, version, platform) { + return feedForChannel(channel, version, platform); +} + +module.exports = { resolveFeedUrl, FEEDS }; +``` + +- [ ] **Step 4: Run test, confirm it passes** + +Run: `npx jest tests/unit/main/updater/feed-config.test.js` +Expected: 6 passing + +- [ ] **Step 5: Commit** + +```bash +git add src/main/updater/feed-config.js tests/unit/main/updater/feed-config.test.js +git commit -m "feat(updater): add feed-config to map channel setting to feed URL" +``` + +--- + +## Task 3: updater-service (TDD) + +**Files:** +- Create: `src/main/updater/updater-service.js` +- Test: `tests/unit/main/updater/updater-service.test.js` + +- [ ] **Step 1: Write the failing test** + +`tests/unit/main/updater/updater-service.test.js`: +```js +const EventEmitter = require('events'); +const { UpdaterService } = require('../../../../src/main/updater/updater-service'); + +describe('UpdaterService', () => { + let emitter; + let mockAutoUpdater; + let service; + + beforeEach(() => { + emitter = new EventEmitter(); + mockAutoUpdater = { + on: jest.fn((event, cb) => emitter.on(event, cb)), + checkForUpdates: jest.fn(), + downloadUpdate: jest.fn(), + quitAndInstall: jest.fn(), + feedConfig: jest.fn(), + }; + service = new UpdaterService(mockAutoUpdater); + }); + + test('starts in idle state', () => { + expect(service.state).toBe('idle'); + }); + + test('check() emits checking then available on update', async () => { + const states = []; + service.on('status', (s) => states.push(s)); + mockAutoUpdater.checkForUpdates.mockImplementation(() => { + emitter.emit('checking-for-update'); + emitter.emit('update-available', { version: '5.0.2' }); + }); + await service.check(); + expect(states).toEqual([ + { state: 'checking' }, + { state: 'available', version: '5.0.2' }, + ]); + }); + + test('check() emits error on network failure', async () => { + const states = []; + service.on('status', (s) => states.push(s)); + mockAutoUpdater.checkForUpdates.mockImplementation(() => { + emitter.emit('checking-for-update'); + emitter.emit('error', new Error('ENOTFOUND')); + }); + await service.check(); + expect(states[states.length - 1]).toEqual({ state: 'error', code: 'NETWORK' }); + }); + + test('check() debounces second call within 60s', async () => { + service.lastCheckAt = Date.now(); + await service.check(); + expect(mockAutoUpdater.checkForUpdates).not.toHaveBeenCalled(); + }); + + test('install() calls quitAndInstall on the autoUpdater', () => { + service.install(); + expect(mockAutoUpdater.quitAndInstall).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run test, confirm it fails** + +Run: `npx jest tests/unit/main/updater/updater-service.test.js` +Expected: FAIL — `Cannot find module` + +- [ ] **Step 3: Implement the module** + +`src/main/updater/updater-service.js`: +```js +const { EventEmitter } = require('events'); +const DEBOUNCE_MS = 60_000; + +class UpdaterService extends EventEmitter { + constructor(autoUpdater) { + super(); + this.autoUpdater = autoUpdater; + this.state = 'idle'; + this.lastCheckAt = 0; + this._wire(); + } + + _wire() { + const au = this.autoUpdater; + au.on('checking-for-update', () => this._emit({ state: 'checking' })); + au.on('update-available', (info) => this._emit({ state: 'available', version: info.version })); + au.on('download-progress', (p) => this._emit({ state: 'downloading', percent: p.percent })); + au.on('update-downloaded', (info) => this._emit({ state: 'ready', version: info.version })); + au.on('update-not-available', () => this._emit({ state: 'idle' })); + au.on('error', (err) => { + const code = err && /ENOTFOUND|ETIMEDOUT|ECONNREFUSED/.test(err.message) ? 'NETWORK' : 'UNKNOWN'; + this._emit({ state: 'error', code }); + }); + } + + _emit(payload) { + this.state = payload.state; + this.emit('status', payload); + } + + async check() { + if (Date.now() - this.lastCheckAt < DEBOUNCE_MS) return; + this.lastCheckAt = Date.now(); + await this.autoUpdater.checkForUpdates(); + } + + install() { + this.autoUpdater.quitAndInstall(); + } +} + +module.exports = { UpdaterService }; +``` + +- [ ] **Step 4: Run test, confirm it passes** + +Run: `npx jest tests/unit/main/updater/updater-service.test.js` +Expected: 5 passing + +- [ ] **Step 5: Commit** + +```bash +git add src/main/updater/updater-service.js tests/unit/main/updater/updater-service.test.js +git commit -m "feat(updater): add UpdaterService wrapping electron-updater lifecycle" +``` + +--- + +## Task 4: crash-writer (TDD) + +**Files:** +- Create: `src/main/updater/crash-writer.js` +- Test: `tests/unit/main/updater/crash-writer.test.js` + +- [ ] **Step 1: Write the failing test** + +`tests/unit/main/updater/crash-writer.test.js`: +```js +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { CrashWriter } = require('../../../../src/main/updater/crash-writer'); + +function tmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'crash-test-')); +} + +describe('CrashWriter', () => { + test('writes a JSON dump for an uncaught exception', () => { + const dir = tmpDir(); + const writer = new CrashWriter(dir); + const err = new Error('boom'); + err.stack = 'Error: boom\n at test.js:1:1'; + writer.handleUncaught(err, 'uncaughtException'); + const files = fs.readdirSync(dir); + expect(files.length).toBe(1); + const data = JSON.parse(fs.readFileSync(path.join(dir, files[0]), 'utf-8')); + expect(data.kind).toBe('uncaughtException'); + expect(data.message).toBe('boom'); + expect(data.stack).toContain('test.js:1:1'); + fs.rmSync(dir, { recursive: true }); + }); + + test('caps at 20 dumps, pruning oldest', () => { + const dir = tmpDir(); + const writer = new CrashWriter(dir); + for (let i = 0; i < 25; i++) { + writer.handleUncaught(new Error(`e${i}`), 'unhandledRejection'); + } + const files = fs.readdirSync(dir); + expect(files.length).toBe(20); + fs.rmSync(dir, { recursive: true }); + }); + + test('returns the list of dumps', () => { + const dir = tmpDir(); + const writer = new CrashWriter(dir); + writer.handleUncaught(new Error('a'), 'uncaughtException'); + writer.handleUncaught(new Error('b'), 'uncaughtException'); + const list = writer.list(); + expect(list.length).toBe(2); + fs.rmSync(dir, { recursive: true }); + }); + + test('deletes a dump by filename', () => { + const dir = tmpDir(); + const writer = new CrashWriter(dir); + writer.handleUncaught(new Error('x'), 'uncaughtException'); + const list = writer.list(); + writer.delete(list[0].filename); + expect(writer.list().length).toBe(0); + fs.rmSync(dir, { recursive: true }); + }); +}); +``` + +- [ ] **Step 2: Run test, confirm it fails** + +Run: `npx jest tests/unit/main/updater/crash-writer.test.js` +Expected: FAIL + +- [ ] **Step 3: Implement the module** + +`src/main/updater/crash-writer.js`: +```js +const fs = require('fs'); +const path = require('path'); +const MAX_DUMPS = 20; + +class CrashWriter { + constructor(dir) { + this.dir = dir; + fs.mkdirSync(dir, { recursive: true }); + } + + handleUncaught(err, kind) { + try { + const filename = `${Date.now()}-${kind}.json`; + const payload = { + kind, + message: err && err.message, + stack: err && err.stack, + timestamp: new Date().toISOString(), + }; + fs.writeFileSync(path.join(this.dir, filename), JSON.stringify(payload, null, 2)); + this._prune(); + } catch (writeErr) { + console.error('[crash-writer] dump write failed:', writeErr.message); + } + } + + _prune() { + const files = fs.readdirSync(this.dir).sort(); + while (files.length > MAX_DUMPS) { + const oldest = files.shift(); + try { fs.unlinkSync(path.join(this.dir, oldest)); } catch (_) { /* ignore */ } + } + } + + list() { + if (!fs.existsSync(this.dir)) return []; + return fs.readdirSync(this.dir) + .filter((f) => f.endsWith('.json')) + .sort() + .reverse() + .map((filename) => { + const full = path.join(this.dir, filename); + const data = JSON.parse(fs.readFileSync(full, 'utf-8')); + return { filename, ...data }; + }); + } + + delete(filename) { + const full = path.join(this.dir, filename); + if (fs.existsSync(full)) fs.unlinkSync(full); + } + + path() { + return this.dir; + } +} + +module.exports = { CrashWriter }; +``` + +- [ ] **Step 4: Run test, confirm it passes** + +Run: `npx jest tests/unit/main/updater/crash-writer.test.js` +Expected: 4 passing + +- [ ] **Step 5: Commit** + +```bash +git add src/main/updater/crash-writer.js tests/unit/main/updater/crash-writer.test.js +git commit -m "feat(crash): add CrashWriter for local crash dumps (cap 20, prune oldest)" +``` + +--- + +## Task 5: migration-runner (TDD) + +**Files:** +- Create: `src/main/updater/migration-runner.js` +- Create: `src/renderer/lib/migrations/v4-to-v5.ts` +- Test: `tests/unit/main/updater/migration-runner.test.js` +- Test: `tests/unit/lib/migrations/v4-to-v5.test.ts` + +- [ ] **Step 1: Write the renderer-side transform test (failing)** + +`tests/unit/lib/migrations/v4-to-v5.test.ts`: +```ts +import { describe, expect, test } from 'vitest'; +import { migrateV4ToV5, v4SettingsSchema } from '@/lib/migrations/v4-to-v5'; + +describe('migrateV4ToV5', () => { + test('maps theme auto to system', () => { + const out = migrateV4ToV5({ theme: 'auto', editorFontSize: 14 }); + expect(out.theme).toBe('system'); + expect(out.editorFontSize).toBe(14); + }); + + test('drops unknown keys, applies defaults', () => { + const out = migrateV4ToV5({ theme: 'light', weirdKey: 1 }); + expect(out.theme).toBe('light'); + expect(out.weirdKey).toBeUndefined(); + expect(out.tabSize).toBe(4); // default + }); + + test('throws on invalid v4 input', () => { + expect(() => migrateV4ToV5({ theme: 42 })).toThrow(); + }); + + test('is idempotent — running twice yields the same v5 output', () => { + const v4 = { theme: 'dark', editorFontSize: 16, recentFiles: ['/a.md'] }; + const a = migrateV4ToV5(v4); + const b = migrateV4ToV5(v4); + expect(a).toEqual(b); + }); + + test('exposes the v4 schema for use by main', () => { + expect(v4SettingsSchema).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Run test, confirm it fails** + +Run: `npx vitest run tests/unit/lib/migrations/v4-to-v5.test.ts` +Expected: FAIL — module not found + +- [ ] **Step 3: Implement the transform** + +`src/renderer/lib/migrations/v4-to-v5.ts`: +```ts +import { z } from 'zod'; +import { settingsSchema } from '@/lib/validators'; + +export const v4SettingsSchema = z.object({ + theme: z.enum(['light', 'dark', 'auto']).default('auto'), + customCss: z.string().optional().nullable(), + recentFiles: z.array(z.string()).default([]), + editorFontSize: z.number().min(10).max(28).default(14), + keyBindings: z.record(z.string(), z.string()).optional(), + snippets: z.array(z.unknown()).default([]), +}).passthrough(); + +export function migrateV4ToV5(v4: unknown): z.infer { + const parsed = v4SettingsSchema.parse(v4); + const defaults = settingsSchema.parse({}); + return { + ...defaults, + ...parsed, + theme: parsed.theme === 'auto' ? 'system' : parsed.theme, + customCssPath: parsed.customCss ?? null, + recentFiles: parsed.recentFiles, + editorFontSize: parsed.editorFontSize, + userBindings: parsed.keyBindings ?? defaults.userBindings ?? {}, + snippets: parsed.snippets, + }; +} +``` + +Note: also add `userBindings: z.record(z.string(), z.string()).default({})` to `validators.ts` settingsSchema if not already present. If it is, omit from defaults in migrateV4ToV5. + +- [ ] **Step 4: Run test, confirm it passes** + +Run: `npx vitest run tests/unit/lib/migrations/v4-to-v5.test.ts` +Expected: 5 passing + +- [ ] **Step 5: Write the runner test (failing)** + +`tests/unit/main/updater/migration-runner.test.js`: +```js +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { MigrationRunner } = require('../../../../src/main/updater/migration-runner'); + +function tmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'mig-test-')); +} + +describe('MigrationRunner', () => { + test('no-op when migration.version is already 5', () => { + const dir = tmpDir(); + fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({ 'migration.version': 5 })); + const r = new MigrationRunner({ dir, transform: (v) => v }); + expect(r.run()).toBe('skipped'); + fs.rmSync(dir, { recursive: true }); + }); + + test('runs migration on missing v5 marker, writes v5 file, backs up v4', () => { + const dir = tmpDir(); + fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({ theme: 'light' })); + const r = new MigrationRunner({ dir, transform: (v) => ({ ...v, theme: 'system', 'migration.version': 5 }) }); + expect(r.run()).toBe('migrated'); + expect(JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf-8'))['migration.version']).toBe(5); + expect(fs.existsSync(path.join(dir, 'settings.v4.bak.json'))).toBe(true); + fs.rmSync(dir, { recursive: true }); + }); + + test('returns fresh on missing file', () => { + const dir = tmpDir(); + const r = new MigrationRunner({ dir, transform: () => ({ 'migration.version': 5 }) }); + expect(r.run()).toBe('fresh'); + expect(fs.existsSync(path.join(dir, 'settings.json'))).toBe(true); + fs.rmSync(dir, { recursive: true }); + }); + + test('returns failed and preserves v4 on transform throw', () => { + const dir = tmpDir(); + fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({ theme: 'light' })); + const r = new MigrationRunner({ dir, transform: () => { throw new Error('boom'); } }); + expect(r.run()).toBe('failed'); + expect(fs.readFileSync(path.join(dir, 'settings.json'), 'utf-8')).toContain('light'); + fs.rmSync(dir, { recursive: true }); + }); +}); +``` + +- [ ] **Step 6: Run test, confirm it fails** + +Run: `npx jest tests/unit/main/updater/migration-runner.test.js` +Expected: FAIL + +- [ ] **Step 7: Implement the runner** + +`src/main/updater/migration-runner.js`: +```js +const fs = require('fs'); +const path = require('path'); + +class MigrationRunner { + constructor({ dir, transform }) { + this.dir = dir; + this.transform = transform; + this.file = path.join(dir, 'settings.json'); + this.backup = path.join(dir, 'settings.v4.bak.json'); + } + + run() { + if (!fs.existsSync(this.file)) { + this._writeDefaults(); + return 'fresh'; + } + const raw = JSON.parse(fs.readFileSync(this.file, 'utf-8')); + if (raw && raw['migration.version'] === 5) { + return 'skipped'; + } + try { + const v5 = this.transform(raw); + fs.copyFileSync(this.file, this.backup); + fs.writeFileSync(this.file, JSON.stringify({ ...v5, 'migration.version': 5 }, null, 2)); + return 'migrated'; + } catch (err) { + console.error('[migration-runner] transform failed:', err.message); + return 'failed'; + } + } + + _writeDefaults() { + const v5 = this.transform({}); + fs.writeFileSync(this.file, JSON.stringify({ ...v5, 'migration.version': 5 }, null, 2)); + } +} + +module.exports = { MigrationRunner }; +``` + +- [ ] **Step 8: Run test, confirm it passes** + +Run: `npx jest tests/unit/main/updater/migration-runner.test.js` +Expected: 4 passing + +- [ ] **Step 9: Commit** + +```bash +git add src/main/updater/migration-runner.js src/renderer/lib/migrations/v4-to-v5.ts \ + tests/unit/main/updater/migration-runner.test.js tests/unit/lib/migrations/v4-to-v5.test.ts +git commit -m "feat(migration): add v4-to-v5 settings migration runner with backup" +``` + +--- + +## Task 6: IPC handlers (updater + crash) + +**Files:** +- Create: `src/main/ipc/updater-handlers.js` +- Create: `src/main/ipc/crash-handlers.js` + +- [ ] **Step 1: Implement updater handlers** + +`src/main/ipc/updater-handlers.js`: +```js +const { ipcMain } = require('electron'); +const { resolveFeedUrl } = require('../updater/feed-config'); + +function register({ updater, getMainWindow, getChannel }) { + ipcMain.handle('updater:check', async () => { + const channel = getChannel(); + const version = require('electron').app.getVersion(); + const platform = process.platform; + const feed = resolveFeedUrl(channel, version, platform); + updater.autoUpdater.setFeedURL({ url: feed.replace(/\/latest-[^/]+\.yml$/, '') }); + await updater.check(); + return { state: updater.state }; + }); + + ipcMain.handle('updater:install', () => { + updater.install(); + }); + + ipcMain.handle('updater:get-state', () => { + return { state: updater.state }; + }); + + // Forward status events to renderer + updater.on('status', (payload) => { + const win = getMainWindow(); + if (win && !win.isDestroyed()) { + win.webContents.send('updater:status', payload); + } + }); +} + +module.exports = { register }; +``` + +- [ ] **Step 2: Implement crash handlers** + +`src/main/ipc/crash-handlers.js`: +```js +const { ipcMain, shell } = require('electron'); + +function register({ crash, getMainWindow }) { + ipcMain.handle('crash:read', () => { + return crash.list(); + }); + + ipcMain.on('crash:open-dir', () => { + shell.openPath(crash.path()); + }); + + ipcMain.handle('crash:delete', (_event, filename) => { + if (typeof filename === 'string' && /^\d+-(uncaughtException|unhandledRejection)\.json$/.test(filename)) { + crash.delete(filename); + return true; + } + return false; + }); +} + +module.exports = { register }; +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/main/ipc/updater-handlers.js src/main/ipc/crash-handlers.js +git commit -m "feat(ipc): add updater and crash IPC handlers" +``` + +--- + +## Task 7: Wire preload allowlist + +**Files:** +- Modify: `src/preload.js` + +- [ ] **Step 1: Add channels to ALLOWED_SEND_CHANNELS** + +In `src/preload.js`, after `'app:show-save-dialog',`, add: +```js + 'updater:check', + 'updater:install', + 'updater:get-state', + 'crash:read', + 'crash:open-dir', + 'crash:delete', +``` + +- [ ] **Step 2: Add 'updater:status' to ALLOWED_RECEIVE_CHANNELS** + +After `'clear-recent-files',`, add: +```js + 'updater:status', +``` + +- [ ] **Step 3: Add `updater` and `crash` objects to `electronAPI`** + +In the `electronAPI = { ... }` block, before the closing `});`, add: +```js + updater: { + check: () => ipcRenderer.invoke('updater:check'), + install: () => ipcRenderer.invoke('updater:install'), + getState: () => ipcRenderer.invoke('updater:get-state'), + onStatus: (cb) => { + const subscription = (_event, payload) => cb(payload); + ipcRenderer.on('updater:status', subscription); + return () => ipcRenderer.removeListener('updater:status', subscription); + }, + }, + crash: { + read: () => ipcRenderer.invoke('crash:read'), + openDir: () => ipcRenderer.send('crash:open-dir'), + delete: (filename) => ipcRenderer.invoke('crash:delete', filename), + }, +``` + +- [ ] **Step 4: Verify `ipc.ts` exposes the same shape** + +Open `src/renderer/lib/ipc.ts` and confirm `ipc.updater.check()` and `ipc.crash.read()` are available. If not, add them following the existing safeCall pattern. + +- [ ] **Step 5: Commit** + +```bash +git add src/preload.js src/renderer/lib/ipc.ts +git commit -m "feat(preload): allowlist updater:* and crash:* channels, expose API" +``` + +--- + +## Task 8: Initialize services in main entry + +**Files:** +- Modify: `src/main/index.js` + +- [ ] **Step 1: Add requires at the top** + +After the existing `require('./word-template')` line, add: +```js +const { app: electronApp, BrowserWindow, Menu, dialog, ipcMain, shell } = require('electron'); +const { autoUpdater } = require('electron-updater'); +const { UpdaterService } = require('./updater/updater-service'); +const { CrashWriter } = require('./updater/crash-writer'); +const { MigrationRunner } = require('./updater/migration-runner'); +const updaterHandlers = require('./ipc/updater-handlers'); +const crashHandlers = require('./ipc/crash-handlers'); +``` + +- [ ] **Step 2: Add `migrateV4ToV5` import** + +Add at top: +```js +const { migrateV4ToV5 } = require('../renderer/lib/migrations/v4-to-v5'); +``` + +(If the renderer-side import is not loadable from main, copy the function into `src/main/updater/migration-transform.js` and re-export. Use the copy if needed.) + +- [ ] **Step 3: Wire services inside `app.whenReady()`** + +Inside the `app.whenReady().then(...)` block, after the existing `createMainWindow` call, add: +```js + const userData = electronApp.getPath('userData'); + + // Migration + const migration = new MigrationRunner({ + dir: userData, + transform: (v4) => migrateV4ToV5(v4), + }); + migration.run(); + + // Crash writer + const crash = new CrashWriter(path.join(userData, 'crashDumps')); + process.on('uncaughtException', (err) => crash.handleUncaught(err, 'uncaughtException')); + process.on('unhandledRejection', (err) => crash.handleUncaught(err, 'unhandledRejection')); + + // Updater + const updater = new UpdaterService(autoUpdater); + updaterHandlers.register({ + updater, + getMainWindow: () => mainWindow, + getChannel: () => store.get('updateChannel') || 'github', + }); + crashHandlers.register({ + crash, + getMainWindow: () => mainWindow, + }); +``` + +- [ ] **Step 4: Verify the app still boots** + +Run: `npm run dev` +Expected: Vite + Electron start, no errors in the terminal. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/index.js +git commit -m "feat(main): initialize updater, crash writer, and migration on app start" +``` + +--- + +## Task 9: Extend settings store with updateChannel + autoCheckUpdates + +**Files:** +- Modify: `src/renderer/lib/validators.ts` + +- [ ] **Step 1: Add new fields to settingsSchema** + +In `src/renderer/lib/validators.ts`, inside `settingsSchema`, add: +```ts + updateChannel: z.enum(['github', 'concreteinfo']).default('github'), + autoCheckUpdates: z.boolean().default(true), + firstRun: z.boolean().default(true), +``` + +- [ ] **Step 2: Verify tests still pass** + +Run: `npx vitest run tests/unit/lib/validators.test.ts 2>/dev/null || npx jest tests/unit/lib 2>/dev/null` +Expected: existing tests pass; new fields appear in the inferred type. + +- [ ] **Step 3: Commit** + +```bash +git add src/renderer/lib/validators.ts +git commit -m "feat(settings): add updateChannel, autoCheckUpdates, firstRun fields" +``` + +--- + +## Task 10: updater-store (renderer) + +**Files:** +- Create: `src/renderer/lib/updater-store.ts` +- Test: `tests/unit/lib/updater-store.test.ts` + +- [ ] **Step 1: Write the failing test** + +`tests/unit/lib/updater-store.test.ts`: +```ts +import { describe, expect, test, beforeEach, vi } from 'vitest'; + +vi.mock('@/lib/ipc', () => ({ + ipc: { + updater: { + check: vi.fn(), + install: vi.fn(), + getState: vi.fn().mockResolvedValue({ state: 'idle' }), + }, + }, +})); + +import { useUpdaterStore } from '@/lib/updater-store'; +import { ipc } from '@/lib/ipc'; + +beforeEach(() => { + useUpdaterStore.setState({ + state: 'idle', + version: null, + percent: 0, + lastCheckAt: 0, + }); + vi.clearAllMocks(); +}); + +describe('useUpdaterStore', () => { + test('initial state is idle', () => { + expect(useUpdaterStore.getState().state).toBe('idle'); + }); + + test('applyStatus updates state from incoming event', () => { + useUpdaterStore.getState().applyStatus({ state: 'available', version: '5.0.2' }); + expect(useUpdaterStore.getState().state).toBe('available'); + expect(useUpdaterStore.getState().version).toBe('5.0.2'); + }); + + test('check() debounces within 60s', async () => { + useUpdaterStore.setState({ lastCheckAt: Date.now() }); + await useUpdaterStore.getState().check(); + expect(ipc.updater.check).not.toHaveBeenCalled(); + }); + + test('check() invokes ipc.updater.check when not debounced', async () => { + await useUpdaterStore.getState().check(); + expect(ipc.updater.check).toHaveBeenCalled(); + }); + + test('install() invokes ipc.updater.install', () => { + useUpdaterStore.getState().install(); + expect(ipc.updater.install).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run test, confirm it fails** + +Run: `npx vitest run tests/unit/lib/updater-store.test.ts` +Expected: FAIL + +- [ ] **Step 3: Implement the store** + +`src/renderer/lib/updater-store.ts`: +```ts +import { create } from 'zustand'; +import { ipc } from '@/lib/ipc'; + +type State = 'idle' | 'checking' | 'available' | 'downloading' | 'ready' | 'error'; + +interface UpdaterState { + state: State; + version: string | null; + percent: number; + lastCheckAt: number; + applyStatus: (s: { state: State; version?: string; percent?: number; code?: string }) => void; + check: () => Promise; + install: () => void; +} + +const DEBOUNCE_MS = 60_000; + +export const useUpdaterStore = create((set, get) => ({ + state: 'idle', + version: null, + percent: 0, + lastCheckAt: 0, + applyStatus: (s) => + set({ + state: s.state, + version: s.version ?? get().version, + percent: s.percent ?? get().percent, + }), + check: async () => { + if (Date.now() - get().lastCheckAt < DEBOUNCE_MS) return; + set({ lastCheckAt: Date.now() }); + await ipc.updater.check(); + }, + install: () => ipc.updater.install(), +})); + +// Subscribe to live updates from main +if (typeof window !== 'undefined' && (window as any).electronAPI?.updater?.onStatus) { + (window as any).electronAPI.updater.onStatus((payload: any) => { + useUpdaterStore.getState().applyStatus(payload); + }); +} +``` + +- [ ] **Step 4: Run test, confirm it passes** + +Run: `npx vitest run tests/unit/lib/updater-store.test.ts` +Expected: 5 passing + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/lib/updater-store.ts tests/unit/lib/updater-store.test.ts +git commit -m "feat(renderer): add updater store mirroring main process state" +``` + +--- + +## Task 11: UpdateBanner component + +**Files:** +- Create: `src/renderer/components/UpdateBanner.tsx` +- Test: `tests/component/UpdateBanner.test.tsx` + +- [ ] **Step 1: Write the failing test** + +`tests/component/UpdateBanner.test.tsx`: +```tsx +import { describe, expect, test, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { useUpdaterStore } from '@/lib/updater-store'; +import { UpdateBanner } from '@/components/UpdateBanner'; + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock('@/lib/ipc', () => ({ + ipc: { + app: { openExternal: vi.fn() }, + updater: { check: vi.fn(), install: vi.fn() }, + }, +})); + +describe('UpdateBanner', () => { + beforeEach(() => { + useUpdaterStore.setState({ state: 'idle', version: null, percent: 0 }); + }); + + test('renders nothing when state is idle', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + test('shows version and restart button when state is ready', () => { + useUpdaterStore.setState({ state: 'ready', version: '5.0.2' }); + render(); + expect(screen.getByText(/5\.0\.2/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /restart/i })).toBeInTheDocument(); + }); + + test('shows download progress when state is downloading', () => { + useUpdaterStore.setState({ state: 'downloading', percent: 42 }); + render(); + expect(screen.getByText(/42%/)).toBeInTheDocument(); + }); + + test('shows error copy when state is error', () => { + useUpdaterStore.setState({ state: 'error' }); + render(); + expect(screen.getByText(/couldn.?t check/i)).toBeInTheDocument(); + }); + + test('restart button calls ipc.updater.install', () => { + useUpdaterStore.setState({ state: 'ready', version: '5.0.2' }); + render(); + fireEvent.click(screen.getByRole('button', { name: /restart/i })); + expect(useUpdaterStore.getState().install).toBeDefined(); + }); +}); +``` + +- [ ] **Step 2: Run test, confirm it fails** + +Run: `npx vitest run tests/component/UpdateBanner.test.tsx` +Expected: FAIL + +- [ ] **Step 3: Implement the component** + +`src/renderer/components/UpdateBanner.tsx`: +```tsx +import { useUpdaterStore } from '@/lib/updater-store'; +import { ipc } from '@/lib/ipc'; +import { toast } from 'sonner'; + +export function UpdateBanner() { + const { state, version, percent, install, check } = useUpdaterStore(); + + if (state === 'idle' || state === 'checking') return null; + + if (state === 'error') { + return ( +
+ Couldn't check for updates.{' '} + +
+ ); + } + + if (state === 'downloading') { + return ( +
+ Downloading update… {Math.round(percent)}% +
+ ); + } + + if (state === 'ready') { + return ( +
+ v{version} is ready. + + +
+ ); + } + + return null; +} +``` + +- [ ] **Step 4: Run test, confirm it passes** + +Run: `npx vitest run tests/component/UpdateBanner.test.tsx` +Expected: 5 passing + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/components/UpdateBanner.tsx tests/component/UpdateBanner.test.tsx +git commit -m "feat(renderer): add UpdateBanner for non-blocking update notifications" +``` + +--- + +## Task 12: FirstRunWizard component + +**Files:** +- Create: `src/renderer/components/FirstRunWizard.tsx` +- Test: `tests/component/FirstRunWizard.test.tsx` + +- [ ] **Step 1: Write the failing test** + +`tests/component/FirstRunWizard.test.tsx`: +```tsx +import { describe, expect, test, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { useAppStore } from '@/lib/app-store'; +import { useSettingsStore } from '@/lib/settings-store'; +import { FirstRunWizard } from '@/components/FirstRunWizard'; + +beforeEach(() => { + useAppStore.setState({ firstRun: true, modal: null } as any); + useSettingsStore.setState({ theme: 'system', updateChannel: 'github' } as any); +}); + +describe('FirstRunWizard', () => { + test('renders nothing when firstRun is false', () => { + useAppStore.setState({ firstRun: false } as any); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + test('renders step 1 by default', () => { + render(); + expect(screen.getByText(/theme/i)).toBeInTheDocument(); + }); + + test('skip link closes the wizard and sets firstRun false', () => { + render(); + fireEvent.click(screen.getByText(/skip/i)); + expect(useAppStore.getState().firstRun).toBe(false); + }); + + test('selecting a theme and clicking Next moves to step 2', () => { + render(); + fireEvent.click(screen.getByLabelText(/dark/i)); + fireEvent.click(screen.getByText(/next/i)); + expect(useSettingsStore.getState().theme).toBe('dark'); + }); + + test('selecting concreteinfo channel persists it', () => { + render(); + fireEvent.click(screen.getByText(/next/i)); + fireEvent.click(screen.getByLabelText(/concreteinfo/i)); + fireEvent.click(screen.getByText(/next/i)); + expect(useSettingsStore.getState().updateChannel).toBe('concreteinfo'); + }); +}); +``` + +- [ ] **Step 2: Run test, confirm it fails** + +Run: `npx vitest run tests/component/FirstRunWizard.test.tsx` +Expected: FAIL + +- [ ] **Step 3: Implement the component** + +`src/renderer/components/FirstRunWizard.tsx`: +```tsx +import { useState } from 'react'; +import { useAppStore } from '@/lib/app-store'; +import { useSettingsStore } from '@/lib/settings-store'; + +const TEMPLATES = { + blank: '', + readme: '# Project\n\nDescription.\n\n## Usage\n\n```\nnpm install\n```\n', + meeting: '# Meeting Notes — YYYY-MM-DD\n\n## Attendees\n\n- \n\n## Agenda\n\n1. \n\n## Action items\n\n- [ ] \n', + blog: '# Title\n\n*Subtitle*\n\nLorem ipsum.\n\n---\n\n## Section 1\n', +}; + +export function FirstRunWizard() { + const firstRun = useAppStore((s) => s.firstRun); + const setFirstRun = useAppStore((s) => s.setFirstRun); + const theme = useSettingsStore((s) => s.theme); + const setSetting = useSettingsStore((s) => s.setSetting); + const updateChannel = useSettingsStore((s) => s.updateChannel); + const [step, setStep] = useState(0); + const [template, setTemplate] = useState('blank'); + + if (!firstRun) return null; + + const close = () => setFirstRun(false); + + return ( +
+
+ {step === 0 && ( +
+

Pick a theme

+
+ {(['light', 'dark', 'system'] as const).map((t) => ( + + ))} +
+
+ )} + {step === 1 && ( +
+

Update channel

+
+ + +
+
+ )} + {step === 2 && ( +
+

Starter template

+ +
+ )} +
+ +
+ {step > 0 && } + {step < 2 ? ( + + ) : ( + + )} +
+
+
+
+ ); +} +``` + +- [ ] **Step 4: Run test, confirm it passes** + +Run: `npx vitest run tests/component/FirstRunWizard.test.tsx` +Expected: 5 passing + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/components/FirstRunWizard.tsx tests/component/FirstRunWizard.test.tsx +git commit -m "feat(renderer): add FirstRunWizard with theme/channel/template steps" +``` + +--- + +## Task 13: CrashReportModal + +**Files:** +- Create: `src/renderer/components/modals/CrashReportModal.tsx` +- Test: `tests/component/modals/CrashReportModal.test.tsx` + +- [ ] **Step 1: Write the failing test** + +`tests/component/modals/CrashReportModal.test.tsx`: +```tsx +import { describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { CrashReportModal } from '@/components/modals/CrashReportModal'; +import { ipc } from '@/lib/ipc'; + +vi.mock('@/lib/ipc', () => ({ + ipc: { + crash: { read: vi.fn(), openDir: vi.fn(), delete: vi.fn() }, + }, +})); + +describe('CrashReportModal', () => { + test('shows empty state when no crashes', async () => { + (ipc.crash.read as any).mockResolvedValue([]); + render( {}} />); + expect(await screen.findByText(/no crashes/i)).toBeInTheDocument(); + }); + + test('lists crashes returned by ipc', async () => { + (ipc.crash.read as any).mockResolvedValue([ + { filename: '1700000000-uncaughtException.json', kind: 'uncaughtException', message: 'boom', timestamp: '2026-01-01T00:00:00.000Z' }, + ]); + render( {}} />); + expect(await screen.findByText(/boom/)).toBeInTheDocument(); + }); + + test('delete button calls ipc.crash.delete', async () => { + (ipc.crash.read as any).mockResolvedValue([ + { filename: '1700000000-uncaughtException.json', kind: 'uncaughtException', message: 'boom', timestamp: '2026-01-01T00:00:00.000Z' }, + ]); + render( {}} />); + const btn = await screen.findByText(/delete/i); + fireEvent.click(btn); + await waitFor(() => expect(ipc.crash.delete).toHaveBeenCalledWith('1700000000-uncaughtException.json')); + }); + + test('open folder button calls ipc.crash.openDir', async () => { + (ipc.crash.read as any).mockResolvedValue([]); + render( {}} />); + fireEvent.click(await screen.findByText(/open dump folder/i)); + expect(ipc.crash.openDir).toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run test, confirm it fails** + +Run: `npx vitest run tests/component/modals/CrashReportModal.test.tsx` +Expected: FAIL + +- [ ] **Step 3: Implement the component** + +`src/renderer/components/modals/CrashReportModal.tsx`: +```tsx +import { useEffect, useState } from 'react'; +import { ipc } from '@/lib/ipc'; + +interface Dump { + filename: string; + kind: string; + message?: string; + timestamp: string; +} + +export function CrashReportModal({ onClose }: { onClose: () => void }) { + const [dumps, setDumps] = useState([]); + + const refresh = async () => setDumps(await ipc.crash.read()); + useEffect(() => { refresh(); }, []); + + return ( +
+
+
+

Crash reports

+ +
+ + {dumps.length === 0 ? ( +

No crashes recorded — nice work!

+ ) : ( +
    + {dumps.map((d) => ( +
  • +
    +
    {d.timestamp}
    +
    {d.message ?? '(no message)'}
    +
    + +
  • + ))} +
+ )} +
+
+ ); +} +``` + +- [ ] **Step 4: Run test, confirm it passes** + +Run: `npx vitest run tests/component/modals/CrashReportModal.test.tsx` +Expected: 4 passing + +- [ ] **Step 5: Commit** + +```bash +git add src/renderer/components/modals/CrashReportModal.tsx tests/component/modals/CrashReportModal.test.tsx +git commit -m "feat(renderer): add CrashReportModal listing local dumps" +``` + +--- + +## Task 14: Settings sheet — Updates section + +**Files:** +- Modify: `src/renderer/components/modals/SettingsSheet.tsx` + +- [ ] **Step 1: Locate the settings sheet and add an Updates section** + +Open `src/renderer/components/modals/SettingsSheet.tsx`. Find the section that contains existing tabs (or the section that lists settings groups). Add a new section labeled "Updates" with: + +- A radio group for `updateChannel` (`github` | `concreteinfo`). +- A "Check now" button calling `useUpdaterStore.getState().check()`. +- A toggle for `autoCheckUpdates` reading and writing via `useSettingsStore.setSetting`. + +Use the existing UI primitives (`