# 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 (`