Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,6 @@ jobs:
- name: Verify Project Build
run: npm run build

- name: Verify Binary Packaging
if: matrix.node-version == '22.x' # Only run on the version used for packaging
run: npm run build:binaries

- name: Upload Coverage to Codecov
uses: codecov/codecov-action@v4
Expand Down
19 changes: 1 addition & 18 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,11 @@ jobs:
- run: npm run test:unit
- run: npm run lint

- name: Build binaries
run: npm run build:binaries


- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: |
dist/binaries/*
draft: false
prerelease: false
env:
Expand All @@ -42,18 +39,4 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

- name: Update Homebrew formula
if: startsWith(github.ref, 'refs/tags/v')
run: |
VERSION=${GITHUB_REF#refs/tags/v}
SHA256_MACOS=$(sha256sum dist/binaries/doro-cli-macos-x64 | awk '{print $1}')
# You would repeat this for arm64 and linux

# This part is conceptual as it depends on having a tap repo
# and a secret token (e.g., TAP_TOKEN) to push to it.
echo "Updating Homebrew formula for version $VERSION..."
# git clone https://${{ secrets.TAP_TOKEN }}@github.com/your-username/homebrew-doro-cli.git
# cd homebrew-doro-cli
# update doro.rb
# git add . && git commit -m "Update doro to $VERSION" && git push

48 changes: 48 additions & 0 deletions .opencode/plans/chore_investigate_artifact_size.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Investigation: Optimize Binary Artifact Sizes

## Goal
To understand the root causes of large binary artifact sizes generated by `pkg` and explore strategies to reduce them, or identify alternative packaging/distribution methods if necessary.

## Steps:

1. **Research `pkg` Size Optimization:**
* Investigate `pkg` documentation for flags or configuration options that could reduce binary size (e.g., stripping debug symbols, optimizing Node.js runtime inclusion).
* Explore options for including only necessary Node.js modules or features.

2. **Explore Alternative Bundling Tools:**
* Research Node.js bundlers like `esbuild`, `ncc`, `webpack` (with Node.js targets), or others known for producing smaller bundles or executables.
* Evaluate their suitability for creating single-file executables for `doro-cli`.
* Perform proof-of-concept builds with promising alternatives and compare artifact sizes.

3. **Analyze Homebrew Packaging:**
* Research how Node.js CLI tools are typically packaged for Homebrew.
* Determine if Homebrew installation inherently leads to smaller user installations compared to standalone `pkg` binaries.
* Understand the effort involved in creating and maintaining a Homebrew formula for `doro-cli`.

4. **Re-evaluate Distribution Strategy:**
* Based on the findings from steps 1-3, determine the most viable distribution strategy:
* **Option A**: Continue with `pkg` binaries, applying any found optimizations.
* **Option B**: Distribute primarily as a standard npm package.
* **Option C**: Use an alternative bundling tool that produces smaller executables.
* **Option D**: Rely on Homebrew for distribution, potentially alongside npm.
* Consider the trade-offs for each option (user experience, maintenance effort, size).

## Verification:
* Document the findings from each research step, including comparative artifact sizes (if possible) and implementation effort.
* Present a clear recommendation for the optimal packaging and distribution strategy for `doro-cli`.

## Findings and Recommendation

### Key Findings:
1. **`pkg` (vercel/pkg):** Is deprecated. Optimization efforts for it are no longer recommended.
2. **Node.js SEA (Single Executable Applications):** Official Node.js feature for creating standalone binaries (35-80MB). Recommended replacement for `pkg` for Node.js projects.
3. **Bun/Deno:** Offer smaller executables (40-90MB), but involve switching runtimes, which is outside the scope of this optimization for an existing Node.js project.
4. **Homebrew:** Standard packaging for Node.js CLIs via `Language::Node` (relying on Homebrew-managed Node.js) is robust for macOS. Standalone binaries are possible for third-party taps, but not `homebrew-core`.

### Recommended Distribution Strategy:

To balance user experience, artifact size, and maintenance:

* **Primary:** Distribute as a **standard npm package**. (Lowest effort, typical for Node.js CLIs).
* **Enhanced (Standalone):** Offer **Node.js SEA-generated binaries**. (Replaces `pkg` for users preferring single executables).
* **Enhanced (macOS):** Create a **Homebrew formula** using the standard `Language::Node` approach. (Integrates with macOS package management).
19 changes: 2 additions & 17 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
},
"scripts": {
"build": "tsc -p tsconfig.json",
"build:binaries": "npm run build && npx @yao-pkg/pkg . --out-path dist/binaries",

"dev": "tsx src/cli.ts",
"lint": "eslint . --ext .ts",
"lint:local": "eslint . --ext .ts --cache",
Expand All @@ -24,22 +24,7 @@
"engines": {
"node": ">=22"
},
"pkg": {
"scripts": [
"dist/**/*.js",
"node_modules/blessed/lib/widgets/*.js"
],
"assets": [
"node_modules/blessed/usr/**/*"
],
"targets": [
"node22-macos-x64",
"node22-macos-arm64",
"node22-linux-x64",
"node22-linux-arm64",
"node22-win-x64"
]
},

"dependencies": {
"blessed": "npm:@rook2pawn/neo-blessed@^2.0.2"
},
Expand Down
209 changes: 209 additions & 0 deletions src/__tests__/app.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { DoroApp } from '../app';
import { TimerStateMachine } from '../stateMachine';
import { DoroUi } from '../ui';
import { playClip, stopPlayback } from '../audio/player';

Check failure on line 4 in src/__tests__/app.test.ts

View workflow job for this annotation

GitHub Actions / verify (22.x)

'playClip' is defined but never used
import {
createCompletionBeepClip,
createResetBeepClip,
createRestStartClip,
createWorkStartClip
} from '../audio/synth';
import { getDurationForMode } from '../constants';
import { isAllowedWhenLocked, isPromptConfirmEvent, resolveControlCommand } from '../input';

Check failure on line 12 in src/__tests__/app.test.ts

View workflow job for this annotation

GitHub Actions / verify (22.x)

'isPromptConfirmEvent' is defined but never used

Check failure on line 12 in src/__tests__/app.test.ts

View workflow job for this annotation

GitHub Actions / verify (22.x)

'isAllowedWhenLocked' is defined but never used

// Mock dependencies
jest.mock('../stateMachine');
jest.mock('../ui');
jest.mock('../audio/player');
jest.mock('../audio/synth');
jest.mock('../constants');
jest.mock('../input');

// Mock `process.exit` to prevent tests from terminating the process
const mockExit = jest.spyOn(process, 'exit').mockImplementation((() => {}) as never);

// Mock `setInterval` and `clearInterval`
jest.useFakeTimers();
let spySetInterval: jest.SpyInstance;
let spyClearInterval: jest.SpyInstance;

describe('DoroApp', () => {
let app: DoroApp;
let mockTimerStateMachine: jest.Mocked<TimerStateMachine>;
let mockDoroUi: jest.Mocked<DoroUi>;

beforeEach(() => {
// Clear all mocks before each test
jest.clearAllMocks();
jest.runOnlyPendingTimers(); // Clear any timers from previous tests

spySetInterval = jest.spyOn(global, 'setInterval');
spyClearInterval = jest.spyOn(global, 'clearInterval');

// Mock methods for TimerStateMachine instance
mockTimerStateMachine = {
startMode: jest.fn(),
getState: jest.fn(),
getConfig: jest.fn(),
tick: jest.fn(),
confirmPromptAndSwitch: jest.fn(),
toggleLock: jest.fn(),
togglePause: jest.fn(),
debugJumpToNearEnd: jest.fn(),
resetCurrentAndRun: jest.fn(),
// Add other methods of TimerStateMachine as they are used
} as jest.Mocked<TimerStateMachine>;

// Initialize mock DoroUi instance
mockDoroUi = {
render: jest.fn(),
destroy: jest.fn(),
toggleColorScheme: jest.fn(),
} as jest.Mocked<DoroUi>;

// Mock constructor implementations
(TimerStateMachine as jest.Mock).mockImplementation(() => mockTimerStateMachine);
(DoroUi as jest.Mock).mockImplementation((options) => {
// Capture the callbacks passed to DoroUi constructor
mockDoroUi.onKey = options.onKey;
mockDoroUi.onAnyClick = options.onAnyClick;
mockDoroUi.onResize = options.onResize;
return mockDoroUi;
});

// Mock synth functions to return dummy Buffers
(createWorkStartClip as jest.Mock).mockReturnValue(Buffer.from('work'));
(createRestStartClip as jest.Mock).mockReturnValue(Buffer.from('rest'));
(createCompletionBeepClip as jest.Mock).mockReturnValue(Buffer.from('complete'));
(createResetBeepClip as jest.Mock).mockReturnValue(Buffer.from('reset'));

// Default mock implementations for methods
mockTimerStateMachine.getState.mockReturnValue({
mode: 'work',
status: 'idle',
remainingSeconds: 0,
isLocked: false,
switchPrompt: null,
cycleCount: 0
});
mockTimerStateMachine.getConfig.mockReturnValue({
workSeconds: 25 * 60,
shortBreakSeconds: 5 * 60,
longBreakSeconds: 15 * 60,
longBreakInterval: 4,
switchConfirmSeconds: 5
});
(getDurationForMode as jest.Mock).mockReturnValue(25 * 60); // Default duration

mockTimerStateMachine.tick.mockReturnValue({
state: {
mode: 'work',
status: 'running',
remainingSeconds: 10,
isLocked: false,
switchPrompt: null,
cycleCount: 0
},
startedPrompt: false,
switchedRunning: false,
switchedToMode: null
});

app = new DoroApp();
});

afterAll(() => {
mockExit.mockRestore(); // Restore original process.exit
spySetInterval.mockRestore();
spyClearInterval.mockRestore();
jest.useRealTimers(); // Use real timers after all tests
});

it('should initialize correctly', () => {
expect(TimerStateMachine).toHaveBeenCalledTimes(1);
expect(DoroUi).toHaveBeenCalledTimes(1);
expect(createWorkStartClip).toHaveBeenCalledTimes(1);
expect(createRestStartClip).toHaveBeenCalledTimes(1);
expect(createCompletionBeepClip).toHaveBeenCalledTimes(1);
expect(createResetBeepClip).toHaveBeenCalledTimes(1);
});

describe('start', () => {
it('should start the timer in work mode, play audio, render, and set up tick interval', () => {
const mockRender = jest.spyOn(app as any, 'render'); // Access private method for spying

Check failure on line 133 in src/__tests__/app.test.ts

View workflow job for this annotation

GitHub Actions / verify (22.x)

Unexpected any. Specify a different type
const mockPlayModeClip = jest.spyOn(app as any, 'playModeClip'); // Access private method for spying

Check failure on line 134 in src/__tests__/app.test.ts

View workflow job for this annotation

GitHub Actions / verify (22.x)

Unexpected any. Specify a different type

app.start();

expect(mockTimerStateMachine.startMode).toHaveBeenCalledWith('work');
expect(mockPlayModeClip).toHaveBeenCalledWith('work');
expect(mockRender).toHaveBeenCalledTimes(1);
expect(setInterval).toHaveBeenCalledTimes(1);
expect(setInterval).toHaveBeenCalledWith(expect.any(Function), 250);
expect((app as any).lastTickTs).toBeGreaterThan(0); // Check that it's set

Check failure on line 143 in src/__tests__/app.test.ts

View workflow job for this annotation

GitHub Actions / verify (22.x)

Unexpected any. Specify a different type
});
});

describe('shutdown', () => {
it('should clear interval, stop playback, destroy UI, and exit process', () => {
const mockStopPlayback = jest.mocked(stopPlayback);
const mockDestroyUi = jest.mocked(mockDoroUi.destroy);

(app as any).isExiting = false; // Ensure it's not already exiting

Check failure on line 152 in src/__tests__/app.test.ts

View workflow job for this annotation

GitHub Actions / verify (22.x)

Unexpected any. Specify a different type
const intervalId = setInterval(() => {}, 1000); // Simulate an active interval
(app as any).tickInterval = intervalId;

Check failure on line 154 in src/__tests__/app.test.ts

View workflow job for this annotation

GitHub Actions / verify (22.x)

Unexpected any. Specify a different type

// Access private method
(app as any).shutdown();

Check failure on line 157 in src/__tests__/app.test.ts

View workflow job for this annotation

GitHub Actions / verify (22.x)

Unexpected any. Specify a different type

expect((app as any).isExiting).toBe(true);

Check failure on line 159 in src/__tests__/app.test.ts

View workflow job for this annotation

GitHub Actions / verify (22.x)

Unexpected any. Specify a different type
expect(clearInterval).toHaveBeenCalledWith(intervalId);
expect(mockStopPlayback).toHaveBeenCalledTimes(1);
expect(mockDestroyUi).toHaveBeenCalledTimes(1);
expect(mockExit).toHaveBeenCalledWith(0);
});

it('should not shutdown if already exiting', () => {
const mockStopPlayback = jest.mocked(stopPlayback);
const mockDestroyUi = jest.mocked(mockDoroUi.destroy);

(app as any).isExiting = true; // Already exiting
(app as any).tickInterval = setInterval(() => {}, 1000);

// Access private method
(app as any).shutdown();

expect(clearInterval).not.toHaveBeenCalled();
expect(mockStopPlayback).not.toHaveBeenCalled();
expect(mockDestroyUi).not.toHaveBeenCalled();
expect(mockExit).not.toHaveBeenCalled();
});
});

describe('handleInput', () => {
it('should ignore input if exiting', () => {
(app as any).isExiting = true;
(app as any).handleInput({ type: 'key', ch: 'a', keyName: 'a', keyFull: 'a', shift: false, ctrl: false });
expect(mockDoroUi.render).not.toHaveBeenCalled();
});

it('should toggle color scheme', () => {
(resolveControlCommand as jest.Mock).mockReturnValue('toggleColorScheme');
(app as any).handleInput({ type: 'key', ch: 'c', keyName: 'c', keyFull: 'c', shift: false, ctrl: false });
expect(mockDoroUi.toggleColorScheme).toHaveBeenCalledTimes(1);
});

it('should toggle pause', () => {
(resolveControlCommand as jest.Mock).mockReturnValue('pauseResume');
(app as any).handleInput({ type: 'key', ch: 'p', keyName: 'p', keyFull: 'p', shift: false, ctrl: false });
expect(mockTimerStateMachine.togglePause).toHaveBeenCalledTimes(1);
});
});

describe('stepClock', () => {
it('should step the timer and render', () => {
(app as any).stepClock();
expect(mockTimerStateMachine.tick).toHaveBeenCalled();
});
});
});
Loading
Loading