diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f0b1e7..5fa5749 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,9 +6,15 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: verify: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest] steps: - name: Checkout Code @@ -36,6 +42,7 @@ jobs: - name: Upload Coverage to Codecov uses: codecov/codecov-action@v6 + if: matrix.os == 'ubuntu-latest' with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true diff --git a/README.md b/README.md index 8a77f40..c06afe8 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,15 @@ npm install -g doro-cli Once installed, just run `doro` in your terminal! +### Windows + +```powershell +npm.cmd install -g doro-cli +doro.cmd +``` + +Use the `.cmd` launchers in PowerShell to avoid script execution policy restrictions. + ## Quick Start (Build from source) ```bash diff --git a/src/__tests__/ui.test.ts b/src/__tests__/ui.test.ts index d238a90..1e09124 100644 --- a/src/__tests__/ui.test.ts +++ b/src/__tests__/ui.test.ts @@ -21,7 +21,7 @@ vi.mock('blessed', () => { return { default: { screen: vi.fn(() => mockScreen), - box: vi.fn(() => ({ ...mockBox })) + box: vi.fn((options) => ({ ...mockBox, style: { ...options.style } })) } }; }); @@ -74,6 +74,109 @@ describe('DoroUi', () => { expect(handlers.onAnyClick).toHaveBeenCalledTimes(1); }); + it('should use an ANSI-safe magenta palette on Windows', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + + try { + ui = new DoroUi(handlers as any); + expect(vi.mocked(blessed.box).mock.calls[0][0]).toMatchObject({ + style: { bg: 'magenta' } + }); + expect(vi.mocked(blessed.box).mock.calls[1][0]).toMatchObject({ + style: { bg: 'brightmagenta' } + }); + expect(vi.mocked(blessed.box).mock.calls[2][0]).toMatchObject({ + style: { bg: 'default', fg: 'default' } + }); + } finally { + ui?.destroy(); + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } + }); + + it('should use an ANSI-safe yellow palette for calm mode on Windows', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + + try { + ui = new DoroUi(handlers as any, 'calm'); + expect(vi.mocked(blessed.box).mock.calls[0][0]).toMatchObject({ + style: { bg: 'yellow' } + }); + expect(vi.mocked(blessed.box).mock.calls[1][0]).toMatchObject({ + style: { bg: 'brightyellow' } + }); + } finally { + ui?.destroy(); + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } + }); + + it('should use distinct ANSI colors for Windows short and long breaks', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + + try { + ui = new DoroUi(handlers as any); + const root = vi.mocked(blessed.box).mock.results[0].value; + const progressFill = vi.mocked(blessed.box).mock.results[1].value; + const banner = vi.mocked(blessed.box).mock.results[2].value; + const baseState = { + status: 'running' as const, + remainingSeconds: 600, + durationSeconds: 1500, + isLocked: false, + volumeMode: 'normal' as const, + hasPrompt: false, + promptCountdownSeconds: 0, + promptTotalSeconds: 0, + promptNextMode: null, + updatePromptState: 'none' as const, + updateCheckResult: null, + editDurationState: 'none' as const, + editDurationValue: null, + editDurationBlink: false + }; + + ui.render({ ...baseState, mode: 'short' }); + expect(root.style.bg).toBe('cyan'); + expect(progressFill.style.bg).toBe('brightcyan'); + expect(banner.setContent).toHaveBeenCalledWith(expect.stringContaining('{bright-cyan-bg}')); + + ui.render({ ...baseState, mode: 'long' }); + expect(root.style.bg).toBe('blue'); + expect(progressFill.style.bg).toBe('brightblue'); + expect(banner.setContent).toHaveBeenLastCalledWith( + expect.stringContaining('{bright-blue-bg}') + ); + + ui.destroy(); + const previousBoxCount = vi.mocked(blessed.box).mock.results.length; + ui = new DoroUi(handlers as any, 'calm'); + const calmBoxes = vi + .mocked(blessed.box) + .mock.results.slice(previousBoxCount, previousBoxCount + 3); + const calmRoot = calmBoxes[0].value; + const calmProgressFill = calmBoxes[1].value; + const calmBanner = calmBoxes[2].value; + + ui.render({ ...baseState, mode: 'short' }); + expect(calmRoot.style.bg).toBe('green'); + expect(calmProgressFill.style.bg).toBe('brightgreen'); + + ui.render({ ...baseState, mode: 'long' }); + expect(calmRoot.style.bg).toBe('cyan'); + expect(calmProgressFill.style.bg).toBe('brightcyan'); + expect(calmBanner.setContent).toHaveBeenLastCalledWith( + expect.stringContaining('{bright-cyan-bg}') + ); + } finally { + ui?.destroy(); + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } + }); + it('should render work mode state', () => { ui = new DoroUi(handlers as any); const mockScreen = vi.mocked(blessed.screen).mock.results[0].value; diff --git a/src/__tests__/update.test.ts b/src/__tests__/update.test.ts index 38f0b15..ba4e12f 100644 --- a/src/__tests__/update.test.ts +++ b/src/__tests__/update.test.ts @@ -216,8 +216,23 @@ describe('update functionality', () => { }); describe('getUpdateCommand', () => { - it('should return npm install command', () => { - expect(getUpdateCommand()).toBe('npm install -g doro-cli@latest && doro'); + it('should return a shell-compatible update command', () => { + expect(getUpdateCommand()).toBe( + process.platform === 'win32' + ? 'npm.cmd install -g doro-cli@latest; if ($?) { doro.cmd }' + : 'npm install -g doro-cli@latest && doro' + ); + }); + + it('should return a PowerShell-compatible command on Windows', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + + try { + expect(getUpdateCommand()).toBe('npm.cmd install -g doro-cli@latest; if ($?) { doro.cmd }'); + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } }); }); @@ -440,6 +455,7 @@ describe('update functionality', () => { const result = await resultPromise; expect(result.success).toBe(true); + expect(result.command).toBe('npm.cmd install -g doro-cli@latest; if ($?) { doro.cmd }'); expect(mockSpawnLocal).toHaveBeenCalledWith('clip', []); Object.defineProperty(process, 'platform', { value: originalPlatform }); diff --git a/src/ui.ts b/src/ui.ts index c02db5a..533ae4e 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -1,6 +1,7 @@ import blessed from 'blessed'; import { MODE_LABELS, MODE_LABELS_SHORT, type TimerMode, type TimerStatus } from './constants'; import { enableMouse, disableMouse } from './mouse'; +import { getUpdateCommand } from './update'; import type { UpdateCheckResult, UpdatePromptState } from './update'; type UiRenderState = { @@ -79,6 +80,10 @@ export function splitAtVisible(s: string, n: number): [string, string] { return [s, '']; } +function colorTagName(color: string): string { + return color.replace(/^(bright|light)(?=[a-z])/, '$1-'); +} + // Wide format: [key, remaining letters] pairs — key rendered in bold, rest appended. // Lock uses uppercase 'L' and Update uses uppercase 'U' to distinguish them from 'l'/'u'. const HELP_KEYS_WIDE: [string, string][] = [ @@ -236,19 +241,28 @@ function getUpdatePromptText( } if (updatePromptState === 'copyFallback') { - const candidates = [ - 'Run this command after doro exits: npm install -g doro-cli@latest && doro', - 'Run after exit: npm install -g doro-cli@latest && doro', - 'Run: npm install -g doro-cli@latest && doro', - 'Run: npm i -g doro-cli@latest && doro', - 'npm i -g doro-cli@latest && doro' - ]; + const updateCommand = getUpdateCommand(); + const candidates = + process.platform === 'win32' + ? [ + `Run this command after doro exits: ${updateCommand}`, + `Run after exit: ${updateCommand}`, + `Run: ${updateCommand}`, + updateCommand + ] + : [ + 'Run this command after doro exits: npm install -g doro-cli@latest && doro', + 'Run after exit: npm install -g doro-cli@latest && doro', + 'Run: npm install -g doro-cli@latest && doro', + 'Run: npm i -g doro-cli@latest && doro', + 'npm i -g doro-cli@latest && doro' + ]; for (const c of candidates) { if (c.length <= cols) { return c; } } - return 'npm i -g doro-cli@latest && doro'; + return updateCommand; } if (updatePromptState === 'skipped') { @@ -349,12 +363,15 @@ function buildProgressRow( const bO = bold ? '{bold}' : ''; const bC = bold ? '{/bold}' : ''; + const fillTag = colorTagName(fillBg); + const baseTag = colorTagName(baseBg); + const fgTag = colorTagName(fg); let content = ''; if (p1.length > 0) { - content += `{${fillBg}-bg}{${fg}-fg}${bO}${p1}${bC}`; + content += `{${fillTag}-bg}{${fgTag}-fg}${bO}${p1}${bC}`; } if (p2.length > 0) { - content += `{${baseBg}-bg}{${fg}-fg}${bO}${p2}${bC}`; + content += `{${baseTag}-bg}{${fgTag}-fg}${bO}${p2}${bC}`; } return content; } @@ -454,6 +471,40 @@ const PALETTES: Record = { } }; +function createWindowsModeStyle(base: string, fill: string): ModeStyle { + return { + base, + fill, + text: 'default', + bannerBg: 'default', + bannerFg: 'default', + statusBg: 'default', + statusFg: 'default', + promptBg: 'default', + promptFg: 'default' + }; +} + +const WINDOWS_PAUSED_STYLE = createWindowsModeStyle('default', 'brightblack'); +const WINDOWS_PALETTES: Record = { + modern: { + modes: { + work: createWindowsModeStyle('magenta', 'brightmagenta'), + short: createWindowsModeStyle('cyan', 'brightcyan'), + long: createWindowsModeStyle('blue', 'brightblue') + }, + pause: WINDOWS_PAUSED_STYLE + }, + calm: { + modes: { + work: createWindowsModeStyle('yellow', 'brightyellow'), + short: createWindowsModeStyle('green', 'brightgreen'), + long: createWindowsModeStyle('cyan', 'brightcyan') + }, + pause: WINDOWS_PAUSED_STYLE + } +}; + function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } @@ -506,7 +557,8 @@ export class DoroUi { public constructor(handlers: UiHandlers, initialColorScheme: ColorScheme = 'modern') { this.colorScheme = initialColorScheme; - const initialStyle = PALETTES[this.colorScheme].modes.work; + const palettes = process.platform === 'win32' ? WINDOWS_PALETTES : PALETTES; + const initialStyle = palettes[this.colorScheme].modes.work; this.screen = blessed.screen({ smartCSR: false, @@ -644,7 +696,7 @@ export class DoroUi { const cols = this.screen.cols; const rows = this.screen.rows; const isPaused = state.status === 'paused'; - const palette = PALETTES[this.colorScheme]; + const palette = (process.platform === 'win32' ? WINDOWS_PALETTES : PALETTES)[this.colorScheme]; const isTransition = state.hasPrompt; const hasUpdatePrompt = state.updatePromptState !== 'none'; const isEditingDuration = state.editDurationState === 'editing'; diff --git a/src/update.ts b/src/update.ts index 0b56385..d8acb7c 100644 --- a/src/update.ts +++ b/src/update.ts @@ -141,6 +141,10 @@ export async function checkForUpdates(): Promise { * Gets the update command for the current platform */ export function getUpdateCommand(): string { + if (process.platform === 'win32') { + return 'npm.cmd install -g doro-cli@latest; if ($?) { doro.cmd }'; + } + return 'npm install -g doro-cli@latest && doro'; } diff --git a/tests/snapshots/visual.spec.mts-snapshots/calm-tiny-update-copy-fallback-chromium-linux.png b/tests/snapshots/visual.spec.mts-snapshots/calm-tiny-update-copy-fallback-chromium-linux.png index b389b26..7cafa71 100644 Binary files a/tests/snapshots/visual.spec.mts-snapshots/calm-tiny-update-copy-fallback-chromium-linux.png and b/tests/snapshots/visual.spec.mts-snapshots/calm-tiny-update-copy-fallback-chromium-linux.png differ diff --git a/tests/snapshots/visual.spec.mts-snapshots/calm-ultra-small-update-copy-fallback-chromium-linux.png b/tests/snapshots/visual.spec.mts-snapshots/calm-ultra-small-update-copy-fallback-chromium-linux.png index a3d2443..76817d5 100644 Binary files a/tests/snapshots/visual.spec.mts-snapshots/calm-ultra-small-update-copy-fallback-chromium-linux.png and b/tests/snapshots/visual.spec.mts-snapshots/calm-ultra-small-update-copy-fallback-chromium-linux.png differ diff --git a/tests/snapshots/visual.spec.mts-snapshots/modern-tiny-update-copy-fallback-chromium-linux.png b/tests/snapshots/visual.spec.mts-snapshots/modern-tiny-update-copy-fallback-chromium-linux.png index b389b26..7cafa71 100644 Binary files a/tests/snapshots/visual.spec.mts-snapshots/modern-tiny-update-copy-fallback-chromium-linux.png and b/tests/snapshots/visual.spec.mts-snapshots/modern-tiny-update-copy-fallback-chromium-linux.png differ diff --git a/tests/snapshots/visual.spec.mts-snapshots/modern-ultra-small-update-copy-fallback-chromium-linux.png b/tests/snapshots/visual.spec.mts-snapshots/modern-ultra-small-update-copy-fallback-chromium-linux.png index a3d2443..76817d5 100644 Binary files a/tests/snapshots/visual.spec.mts-snapshots/modern-ultra-small-update-copy-fallback-chromium-linux.png and b/tests/snapshots/visual.spec.mts-snapshots/modern-ultra-small-update-copy-fallback-chromium-linux.png differ