Files
markdown-converter/docs/superpowers/plans/2026-06-07-production-polish.md
Amit Haridas 5b81988f30 docs(plan): production polish implementation plan
21 tasks, 90 actionable steps. TDD throughout. Each step shows the
exact code and the exact command with expected output.

Tasks:
1. Install electron-updater
2-5. Updater service, crash writer, migration runner, feed config (TDD)
6-7. IPC handlers + preload allowlist
8. Wire services in main entry
9-10. Settings + updater store
11-13. UpdateBanner, FirstRunWizard, CrashReportModal
14-16. Settings sheet updates + App.tsx mounts
17-18. CI + release workflows (publish latest.yml, mirror to CI)
19. Docs
20-21. E2E verification + tag v5.1.0

After this plan executes, the remaining v5+ sub-projects are:
- Cloud/licensing (next spec)
- Plugin system
- Performance + accessibility
2026-06-07 23:47:15 +05:30

1851 lines
58 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<UpdateBanner>`, `<FirstRunWizard>`, `<CrashReportModal>`. |
| `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<typeof settingsSchema> {
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<void>;
install: () => void;
}
const DEBOUNCE_MS = 60_000;
export const useUpdaterStore = create<UpdaterState>((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(<UpdateBanner />);
expect(container.firstChild).toBeNull();
});
test('shows version and restart button when state is ready', () => {
useUpdaterStore.setState({ state: 'ready', version: '5.0.2' });
render(<UpdateBanner />);
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(<UpdateBanner />);
expect(screen.getByText(/42%/)).toBeInTheDocument();
});
test('shows error copy when state is error', () => {
useUpdaterStore.setState({ state: 'error' });
render(<UpdateBanner />);
expect(screen.getByText(/couldn.?t check/i)).toBeInTheDocument();
});
test('restart button calls ipc.updater.install', () => {
useUpdaterStore.setState({ state: 'ready', version: '5.0.2' });
render(<UpdateBanner />);
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 (
<div data-testid="update-banner" role="status" className="bg-amber-50 border-b border-amber-200 px-4 py-2 text-sm">
Couldn't check for updates.{' '}
<button onClick={async () => { try { await check(); } catch (e: any) { toast.error(e.message); } }} className="underline">
Try again
</button>
</div>
);
}
if (state === 'downloading') {
return (
<div data-testid="update-banner" className="bg-blue-50 border-b border-blue-200 px-4 py-2 text-sm">
Downloading update… {Math.round(percent)}%
</div>
);
}
if (state === 'ready') {
return (
<div data-testid="update-banner" className="bg-green-50 border-b border-green-200 px-4 py-2 text-sm flex items-center gap-3">
<span>v{version} is ready.</span>
<button onClick={() => ipc.app.openExternal(`https://github.com/amitwh/markdown-converter/releases/tag/v${version}`)} className="underline">
View release notes
</button>
<button onClick={install} className="px-3 py-1 rounded bg-brand text-white">
Restart to update
</button>
</div>
);
}
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(<FirstRunWizard />);
expect(container.firstChild).toBeNull();
});
test('renders step 1 by default', () => {
render(<FirstRunWizard />);
expect(screen.getByText(/theme/i)).toBeInTheDocument();
});
test('skip link closes the wizard and sets firstRun false', () => {
render(<FirstRunWizard />);
fireEvent.click(screen.getByText(/skip/i));
expect(useAppStore.getState().firstRun).toBe(false);
});
test('selecting a theme and clicking Next moves to step 2', () => {
render(<FirstRunWizard />);
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(<FirstRunWizard />);
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<keyof typeof TEMPLATES>('blank');
if (!firstRun) return null;
const close = () => setFirstRun(false);
return (
<div data-testid="first-run-wizard" role="dialog" aria-modal="true" className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center">
<div className="bg-white dark:bg-neutral-900 rounded-lg p-6 w-[28rem] shadow-xl">
{step === 0 && (
<div>
<h2 className="text-lg font-semibold mb-2">Pick a theme</h2>
<div className="flex gap-2 mb-4">
{(['light', 'dark', 'system'] as const).map((t) => (
<label key={t} className="flex items-center gap-1">
<input type="radio" name="theme" checked={theme === t} onChange={() => setSetting('theme', t)} />
{t}
</label>
))}
</div>
</div>
)}
{step === 1 && (
<div>
<h2 className="text-lg font-semibold mb-2">Update channel</h2>
<div className="flex flex-col gap-2 mb-4">
<label className="flex items-center gap-2">
<input type="radio" name="channel" checked={updateChannel === 'github'} onChange={() => setSetting('updateChannel', 'github')} />
GitHub Releases (public)
</label>
<label className="flex items-center gap-2">
<input type="radio" name="channel" checked={updateChannel === 'concreteinfo'} onChange={() => setSetting('updateChannel', 'concreteinfo')} />
ConcreteInfo self-hosted
</label>
</div>
</div>
)}
{step === 2 && (
<div>
<h2 className="text-lg font-semibold mb-2">Starter template</h2>
<select value={template} onChange={(e) => setTemplate(e.target.value as any)} className="border rounded px-2 py-1 w-full mb-4">
<option value="blank">Blank</option>
<option value="readme">README</option>
<option value="meeting">Meeting notes</option>
<option value="blog">Blog post</option>
</select>
</div>
)}
<div className="flex justify-between items-center mt-4">
<button onClick={close} className="text-sm text-neutral-500">Skip</button>
<div className="flex gap-2">
{step > 0 && <button onClick={() => setStep(step - 1)}>Back</button>}
{step < 2 ? (
<button onClick={() => setStep(step + 1)} className="px-3 py-1 rounded bg-brand text-white">Next</button>
) : (
<button onClick={() => { useAppStore.getState().newBuffer(TEMPLATES[template]); close(); }} className="px-3 py-1 rounded bg-brand text-white">Done</button>
)}
</div>
</div>
</div>
</div>
);
}
```
- [ ] **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(<CrashReportModal onClose={() => {}} />);
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(<CrashReportModal onClose={() => {}} />);
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(<CrashReportModal onClose={() => {}} />);
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(<CrashReportModal onClose={() => {}} />);
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<Dump[]>([]);
const refresh = async () => setDumps(await ipc.crash.read());
useEffect(() => { refresh(); }, []);
return (
<div role="dialog" aria-modal="true" data-testid="crash-report-modal" className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center">
<div className="bg-white dark:bg-neutral-900 rounded-lg p-6 w-[36rem] max-h-[80vh] overflow-y-auto shadow-xl">
<header className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Crash reports</h2>
<button onClick={onClose} aria-label="Close">×</button>
</header>
<button onClick={() => ipc.crash.openDir()} className="mb-4 px-3 py-1 text-sm border rounded">
Open dump folder
</button>
{dumps.length === 0 ? (
<p data-testid="empty-state" className="text-neutral-500">No crashes recorded nice work!</p>
) : (
<ul className="space-y-2">
{dumps.map((d) => (
<li key={d.filename} className="border rounded p-2 text-sm flex justify-between items-start gap-2">
<div>
<div className="font-mono text-xs text-neutral-500">{d.timestamp}</div>
<div>{d.message ?? '(no message)'}</div>
</div>
<button onClick={async () => { await ipc.crash.delete(d.filename); refresh(); }} className="text-red-600 text-xs">
Delete
</button>
</li>
))}
</ul>
)}
</div>
</div>
);
}
```
- [ ] **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 (`<Button>`, `<RadioGroup>`, `<Switch>`) found in the file.
- [ ] **Step 2: Verify the existing Settings sheet tests still pass**
Run: `npx vitest run tests/component/modals/SettingsSheet.test.tsx`
Expected: all pass
- [ ] **Step 3: Commit**
```bash
git add src/renderer/components/modals/SettingsSheet.tsx
git commit -m "feat(settings): add Updates section with channel radio and Check now"
```
---
## Task 15: Mount new components in App
**Files:**
- Modify: `src/renderer/App.tsx`
- [ ] **Step 1: Add imports and mounts**
At the top of `App.tsx`, add:
```tsx
import { UpdateBanner } from '@/components/UpdateBanner';
import { FirstRunWizard } from '@/components/FirstRunWizard';
import { useAppStore } from '@/lib/app-store';
```
Inside the render tree (above the existing layout), add:
```tsx
<FirstRunWizard />
<UpdateBanner />
```
- [ ] **Step 2: Add a Help menu item to open the crash modal**
In the same `App.tsx` (or wherever the help/about menu is registered), add a command that opens a "Crash reports" modal:
```tsx
useCommandStore.register('help.crashReports', () => {
useAppStore.getState().openModal({ kind: 'crashReports' });
});
```
Add `'crashReports'` to the `ModalState` discriminated union in `app-store.ts`. The `ModalLayer` should render `<CrashReportModal onClose={close} />` when `modal.kind === 'crashReports'`.
- [ ] **Step 3: Verify the app still boots**
Run: `npm run dev`
Expected: Vite + Electron start, no errors, no broken modal.
- [ ] **Step 4: Commit**
```bash
git add src/renderer/App.tsx src/renderer/lib/app-store.ts src/renderer/components/modals/ModalLayer.tsx
git commit -m "feat(renderer): mount UpdateBanner, FirstRunWizard, and CrashReportModal"
```
---
## Task 16: Auto-check on startup
**Files:**
- Modify: `src/renderer/App.tsx` (or a new `src/renderer/hooks/useAutoUpdateCheck.ts`)
- [ ] **Step 1: Add a startup hook**
Create `src/renderer/hooks/useAutoUpdateCheck.ts`:
```ts
import { useEffect } from 'react';
import { useSettingsStore } from '@/lib/settings-store';
import { useUpdaterStore } from '@/lib/updater-store';
export function useAutoUpdateCheck() {
const auto = useSettingsStore((s) => s.autoCheckUpdates);
const check = useUpdaterStore((s) => s.check);
useEffect(() => {
if (auto) {
const t = setTimeout(() => { check(); }, 5_000);
return () => clearTimeout(t);
}
}, [auto, check]);
}
```
- [ ] **Step 2: Call the hook in App.tsx**
In `App.tsx`, at the top of the component:
```tsx
useAutoUpdateCheck();
```
- [ ] **Step 3: Verify the test suite still passes**
Run: `npm test`
Expected: 497+ tests pass.
- [ ] **Step 4: Commit**
```bash
git add src/renderer/hooks/useAutoUpdateCheck.ts src/renderer/App.tsx
git commit -m "feat(renderer): auto-check for updates 5s after launch when enabled"
```
---
## Task 17: CI workflow — vitest on PRs
**Files:**
- Modify: `.github/workflows/ci.yml` (or create if absent)
- [ ] **Step 1: Add or update the CI workflow**
If `ci.yml` exists, ensure it runs both jest and vitest on every PR. If absent, create:
`.github/workflows/ci.yml`:
```yaml
name: CI
on:
pull_request:
push:
branches: [react-electron, master]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npx jest --colors=false
- run: npx vitest run --reporter=verbose
```
- [ ] **Step 2: Commit**
```bash
git add .github/workflows/ci.yml
git commit -m "ci: run jest and vitest on every PR"
```
---
## Task 18: Release workflow — publish latest.yml + ConcreteInfo mirror
**Files:**
- Modify: `.github/workflows/release.yml`
- [ ] **Step 1: Add `publish=always` to all three build jobs**
For each of the three build jobs (`build-linux`, `build-windows`, `build-macos`), change the `run:` line:
Linux:
```yaml
run: npm run build:linux-ci -- --publish=always
```
Windows (both signed and unsigned paths):
```yaml
run: npm run build:win-signed -- --publish=always
```
```yaml
run: npm run build:win-unsigned -- --publish=always
```
macOS:
```yaml
run: npm run build:mac -- --publish=always
```
- [ ] **Step 2: Add a ConcreteInfo mirror step in the `release` job**
After "Create GitHub Release", add:
```yaml
- name: Mirror artifacts to ConcreteInfo update feed
env:
CONCRETEINFO_DEPLOY_HOOK: ${{ secrets.CONCRETEINFO_DEPLOY_HOOK }}
run: |
if [ -n "$CONCRETEINFO_DEPLOY_HOOK" ]; then
curl -fsSL -X POST \
-H "Authorization: Bearer $CONCRETEINFO_DEPLOY_HOOK" \
-F "version=${GITHUB_REF_NAME#v}" \
-F "artifacts=@dist/latest-mac.yml" \
-F "artifacts=@dist/latest-linux.yml" \
-F "artifacts=@dist/latest-windows.yml" \
https://updates.concreteinfo.co.in/api/v1/ingest
fi
```
- [ ] **Step 3: Add `npm run publish:concreteinfo` script**
In `package.json`, add:
```json
"publish:concreteinfo": "node scripts/publish-concreteinfo.js"
```
Create `scripts/publish-concreteinfo.js`:
```js
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const https = require('https');
const { execSync } = require('child_process');
const version = process.argv[2] || require('../package.json').version;
const hook = process.env.CONCRETEINFO_DEPLOY_HOOK;
if (!hook) { console.error('CONCRETEINFO_DEPLOY_HOOK not set'); process.exit(1); }
const files = ['latest-mac.yml', 'latest-linux.yml', 'latest-windows.yml'].map((f) => path.join('dist', f));
for (const f of files) {
if (!fs.existsSync(f)) { console.error(`missing ${f} — run electron-builder first`); process.exit(1); }
}
const form = new FormData();
form.append('version', version);
for (const f of files) form.append('artifacts', fs.createReadStream(f), path.basename(f));
form.append('artifacts', fs.createReadStream(`dist/MarkdownConverter-${version}.dmg`));
form.append('artifacts', fs.createReadStream(`dist/markdown-converter_${version}_amd64.deb`));
form.append('artifacts', fs.createReadStream(`dist/MarkdownConverter-Setup-${version}.exe`));
fetch('https://updates.concreteinfo.co.in/api/v1/ingest', {
method: 'POST',
headers: { Authorization: `Bearer ${hook}` },
body: form,
}).then((r) => { console.log('ingest status', r.status); process.exit(r.ok ? 0 : 1); });
```
- [ ] **Step 4: Commit**
```bash
git add .github/workflows/release.yml package.json scripts/publish-concreteinfo.js
git commit -m "ci(release): publish latest.yml on all platforms, mirror to ConcreteInfo"
```
---
## Task 19: Documentation
**Files:**
- Modify: `CHANGELOG.md`
- Modify: `README.md`
- [ ] **Step 1: Add v5.1.0 entry to CHANGELOG**
At the top of `CHANGELOG.md`, prepend:
```markdown
## [5.1.0] — 2026-06-XX
### Added
- Auto-update via electron-updater against GitHub Releases (default) or ConcreteInfo self-hosted feed.
- Light, skippable first-run wizard: theme, update channel, starter template.
- One-shot v4.4.1 → v5 settings migration with backup at `settings.v4.bak.json`.
- Local crash dump capture (cap 20, auto-prune) and a CrashReportModal.
- "Updates" section in Settings: channel picker, Check now, auto-check toggle.
```
- [ ] **Step 2: Document the new env var in README**
In `README.md`, add a "Distribution & Updates" section with the `CONCRETEINFO_DEPLOY_HOOK` env var and a link to the public release page.
- [ ] **Step 3: Commit**
```bash
git add CHANGELOG.md README.md
git commit -m "docs: v5.1.0 changelog + Distribution & Updates section"
```
---
## Task 20: End-to-end verification
**Files:**
- Modify: `.claude/skills/run-desktop/verify-features.mjs`
- [ ] **Step 1: Add an E2E check for the wizard and the banner**
Append to `verify-features.mjs`:
```js
// 11) First-run wizard visible on a fresh app data dir
const fresh = await app.evaluate(({ app }) => {
const userData = app.getPath('userData');
const fs = require('fs');
const path = require('path');
// Mark migration as already-done so we don't try to re-migrate, but force firstRun=true
fs.writeFileSync(path.join(userData, 'settings.json'), JSON.stringify({ 'migration.version': 5, theme: 'light' }));
return true;
});
await new Promise((r) => setTimeout(r, 500));
const wizardVisible = await win.locator('[data-testid="first-run-wizard"]').count();
console.log('First-run wizard visible:', wizardVisible);
await win.click('text=Skip').catch(() => {});
await new Promise((r) => setTimeout(r, 300));
// 12) Update banner: emit a synthetic status from main
await app.evaluate(({ BrowserWindow }) => {
const win = BrowserWindow.getAllWindows()[0];
win.webContents.send('updater:status', { state: 'ready', version: '5.1.0' });
});
await new Promise((r) => setTimeout(r, 500));
const bannerVisible = await win.locator('[data-testid="update-banner"]').count();
console.log('Update banner visible:', bannerVisible);
await win.screenshot({ path: path.join(SHOT_DIR, '12-update-banner.png') });
```
- [ ] **Step 2: Run the verification**
```bash
node .claude/skills/run-desktop/verify-features.mjs
```
Expected: wizard renders on first launch, banner shows the synthetic update, screenshots saved to `/tmp/shots-verify/`.
- [ ] **Step 3: Commit**
```bash
git add .claude/skills/run-desktop/verify-features.mjs
git commit -m "test(e2e): verify first-run wizard and update banner in live app"
```
---
## Task 21: Final test sweep + production build
- [ ] **Step 1: Run the full test suite**
```bash
npm test
```
Expected: 497+ tests pass (was 497 before this plan; expect 540+ after all new tests).
- [ ] **Step 2: Build the renderer**
```bash
npm run build:renderer
```
Expected: clean build, no TS errors.
- [ ] **Step 3: Tag the release**
```bash
git tag v5.1.0
git push origin v5.1.0
```
The release workflow will build + publish `latest.yml` and mirror to ConcreteInfo.
- [ ] **Step 4: Commit (any final docs tweaks)**
```bash
git status
```
If there are uncommitted tweaks, commit them with a `chore: post-v5.1.0` message.
---
## Self-Review (inline)
**1. Spec coverage:**
- Goal: Two-channel distribution → Tasks 7, 18 ✓
- Goal: Non-blocking update banner with user-confirmed restart → Tasks 11, 16 ✓
- Goal: Light first-run wizard → Tasks 12, 15 ✓
- Goal: Auto-migrate v4.4.1 settings with backup → Tasks 5, 8 ✓
- Goal: Local crash dump capture → Tasks 4, 13, 15 ✓
- Goal: GitHub Actions CI tests + release with latest.yml → Tasks 17, 18 ✓
- Architecture: Module-level boundaries, IPC allowlist → Tasks 6, 7, 8 ✓
- Component breakdown (all 13 units from §3) → each has a task or is part of one (e.g. `feed-config` is Task 2)
- Data flow example: each step has matching code ✓
- Error handling: debounce, prune, idempotency → Tasks 3, 4, 5 ✓
- Testing: unit, component, integration, E2E — Tasks 25 unit, 1113 component, integration runner inside Task 5, E2E Task 20 ✓
- Distribution feed contracts → Tasks 18, 19 ✓
- First-run wizard contract → Task 12 ✓
- Settings migration contract → Task 5 ✓
- IPC allowlist additions → Task 7 ✓
- Risks & mitigations → addressed implicitly by error handling and the auto-check toggle (Task 14)
**2. Placeholder scan:** No "TBD"/"TODO"/"implement later" present. Every code step has the actual code.
**3. Type consistency:** `useUpdaterStore` defined Task 10, used in Tasks 11, 16. `ipc.updater.*` defined in Task 7, used everywhere. `crash.handleUncaught` defined Task 4, used in Task 8. `migration.transform` defined Task 5, used in Task 8.