diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c3390bb..491c677 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,6 +19,9 @@ jobs:
- name: Install dependencies
run: bun install
+ - name: Run lint
+ run: bun run lint
+
- name: Run tests
run: bun run test
diff --git a/package.json b/package.json
index 389632b..df03e64 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,8 @@
"dev:client": "vite",
"build:client": "vite build",
"preview:client": "vite preview",
- "lint": "biome lint --write ./src"
+ "lint": "biome lint ./src",
+ "lint:write": "biome lint --write ./src"
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
diff --git a/src/client/components/AnswerButtons.tsx b/src/client/components/AnswerButtons.tsx
new file mode 100644
index 0000000..8b2cfa8
--- /dev/null
+++ b/src/client/components/AnswerButtons.tsx
@@ -0,0 +1,39 @@
+import { ArrowCounterClockwise, Warning, Check, Star } from "@phosphor-icons/react";
+
+export interface AnswerButtonsProps {
+ onAnswer: (ease: number) => void;
+}
+
+export default function AnswerButtons({ onAnswer }: AnswerButtonsProps) {
+ const answers = [
+ { ease: 1, label: "Again", icon: , color: "red" },
+ { ease: 2, label: "Hard", icon: , color: "yellow" },
+ { ease: 3, label: "Good", icon: , color: "green" },
+ { ease: 4, label: "Easy", icon: , color: "blue" },
+ ];
+
+ return (
+
+ {answers.map(({ ease, label, icon, color }) => (
+
+ ))}
+
+ );
+}
diff --git a/src/client/components/AudioPlayer.tsx b/src/client/components/AudioPlayer.tsx
new file mode 100644
index 0000000..f5cf1e1
--- /dev/null
+++ b/src/client/components/AudioPlayer.tsx
@@ -0,0 +1,189 @@
+import { useState, useEffect, useRef } from "react";
+import { SkipBack, SkipForward, Pause, Play } from "@phosphor-icons/react";
+import { getApiBase } from "../config";
+
+export interface AudioPlayerProps {
+ audioUrls: string[];
+ isPlaying: boolean;
+ setIsPlaying: (playing: boolean) => void;
+ currentAudioIndex: number;
+ setCurrentAudioIndex: (index: number) => void;
+}
+
+export default function AudioPlayer({
+ audioUrls,
+ isPlaying,
+ setIsPlaying,
+ currentAudioIndex,
+ setCurrentAudioIndex,
+}: AudioPlayerProps) {
+ const audioRefs = useRef([]);
+ const progressBarRef = useRef(null);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [duration, setDuration] = useState(0);
+
+ useEffect(() => {
+ const handleAudioEnd = () => {
+ if (currentAudioIndex < audioUrls.length - 1) {
+ setCurrentAudioIndex(currentAudioIndex + 1);
+ audioRefs.current[currentAudioIndex + 1]?.play();
+ } else {
+ setIsPlaying(false);
+ setCurrentAudioIndex(0);
+ }
+ };
+
+ const handleTimeUpdate = (audio: HTMLAudioElement) => {
+ setCurrentTime(audio.currentTime);
+ setDuration(audio.duration);
+ };
+
+ for (const audio of audioRefs.current) {
+ if (audio) {
+ audio.addEventListener("ended", handleAudioEnd);
+ audio.addEventListener("timeupdate", () => handleTimeUpdate(audio));
+ }
+ }
+
+ return () => {
+ for (const audio of audioRefs.current) {
+ if (audio) {
+ audio.removeEventListener("ended", handleAudioEnd);
+ audio.removeEventListener("timeupdate", () => handleTimeUpdate(audio));
+ }
+ }
+ };
+ }, [currentAudioIndex, audioUrls.length, setCurrentAudioIndex, setIsPlaying]);
+
+ const handleProgressBarClick = (event: React.MouseEvent) => {
+ const progressBar = progressBarRef.current;
+ if (!progressBar || !audioRefs.current[currentAudioIndex]) return;
+
+ const rect = progressBar.getBoundingClientRect();
+ const clickPosition = (event.clientX - rect.left) / rect.width;
+ const newTime =
+ clickPosition * audioRefs.current[currentAudioIndex].duration;
+
+ audioRefs.current[currentAudioIndex].currentTime = newTime;
+ setCurrentTime(newTime);
+ };
+
+ const formatTime = (time: number) => {
+ const minutes = Math.floor(time / 60);
+ const seconds = Math.floor(time % 60);
+ return `${minutes}:${seconds.toString().padStart(2, "0")}`;
+ };
+
+ const handlePlayPause = () => {
+ if (!audioUrls.length) return;
+
+ if (isPlaying) {
+ audioRefs.current[currentAudioIndex]?.pause();
+ setIsPlaying(false);
+ } else {
+ audioRefs.current[currentAudioIndex]?.play();
+ setIsPlaying(true);
+ }
+ };
+
+ return (
+
+
+
+
+
+ Track {currentAudioIndex + 1}/{audioUrls.length}
+
+
+
+ {formatTime(currentTime)} / {formatTime(duration)}
+
+
+
+
+
+
+
+
+
+
+
+
+ {audioUrls.map((url, index) => (
+
+ ))}
+
+ );
+}
diff --git a/src/client/components/CardControls.tsx b/src/client/components/CardControls.tsx
new file mode 100644
index 0000000..876f910
--- /dev/null
+++ b/src/client/components/CardControls.tsx
@@ -0,0 +1,57 @@
+import {
+ ArrowLeft,
+ ArrowCounterClockwise,
+ ArrowRight,
+} from "@phosphor-icons/react";
+
+export interface CardControlsProps {
+ onPrevious: () => void;
+ onNext: () => void;
+ onRegenerate: () => void;
+ isFirst: boolean;
+ isLast: boolean;
+ isRegenerating: boolean;
+}
+
+export default function CardControls({
+ onPrevious,
+ onNext,
+ onRegenerate,
+ isFirst,
+ isLast,
+ isRegenerating,
+}: CardControlsProps) {
+ return (
+
+ );
+}
diff --git a/src/client/components/DeckSelector.tsx b/src/client/components/DeckSelector.tsx
new file mode 100644
index 0000000..fd77af0
--- /dev/null
+++ b/src/client/components/DeckSelector.tsx
@@ -0,0 +1,26 @@
+export interface DeckSelectorProps {
+ decks: string[];
+ selectedDeck: string | undefined;
+ onSelect: (event: React.ChangeEvent) => void;
+}
+
+export default function DeckSelector({
+ decks,
+ selectedDeck,
+ onSelect,
+}: DeckSelectorProps) {
+ return (
+
+ );
+}
diff --git a/src/client/components/SettingsModal.tsx b/src/client/components/SettingsModal.tsx
new file mode 100644
index 0000000..eb4504f
--- /dev/null
+++ b/src/client/components/SettingsModal.tsx
@@ -0,0 +1,84 @@
+import { ArrowClockwise } from "@phosphor-icons/react";
+import { getDefaultApiBase } from "../config";
+
+export interface SettingsModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ apiBase: string;
+ onApiBaseChange: (value: string) => void;
+ onReset: () => void;
+ onSave: () => void;
+ onSync: () => void;
+ errorMessage?: string;
+}
+
+export default function SettingsModal({
+ isOpen,
+ onClose,
+ apiBase,
+ onApiBaseChange,
+ onReset,
+ onSave,
+ onSync,
+ errorMessage,
+}: SettingsModalProps) {
+ if (!isOpen) return null;
+
+ return (
+
+
+
Settings
+ {errorMessage && (
+
+ {errorMessage}
+
+ )}
+
+
+
+ API Base URL
+
+
onApiBaseChange(e.target.value)}
+ placeholder={getDefaultApiBase()}
+ className="w-full p-2 border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white"
+ />
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/client/views/DeckView.tsx b/src/client/views/DeckView.tsx
index 243cc2d..5aaea3e 100644
--- a/src/client/views/DeckView.tsx
+++ b/src/client/views/DeckView.tsx
@@ -1,409 +1,25 @@
-import { useParams, useNavigate, useLocation } from "react-router-dom";
-import { useState, useEffect, useMemo, useRef } from "react";
import {
- Play,
- Pause,
- SkipBack,
- SkipForward,
- ArrowLeft,
- ArrowRight,
- ArrowCounterClockwise,
- ArrowClockwise,
Gear,
} from "@phosphor-icons/react";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { useLocation, useNavigate, useParams } from "react-router-dom";
import useSWR from "swr";
import {
+ answerCard,
+ fetchCard,
+ fetchDeckCards,
fetchDeckConfig,
fetchDecks,
- fetchDeckCards,
- fetchCard,
- answerCard,
regenerateCard,
sync,
} from "../api";
-import { getApiBase, setApiBase, getDefaultApiBase } from "../config";
-
-interface AudioPlayerProps {
- audioUrls: string[];
- isPlaying: boolean;
- setIsPlaying: (playing: boolean) => void;
- currentAudioIndex: number;
- setCurrentAudioIndex: (index: number) => void;
-}
-
-function AudioPlayer({
- audioUrls,
- isPlaying,
- setIsPlaying,
- currentAudioIndex,
- setCurrentAudioIndex,
-}: AudioPlayerProps) {
- const audioRefs = useRef([]);
- const progressBarRef = useRef(null);
- const [currentTime, setCurrentTime] = useState(0);
- const [duration, setDuration] = useState(0);
-
- useEffect(() => {
- const handleAudioEnd = () => {
- if (currentAudioIndex < audioUrls.length - 1) {
- setCurrentAudioIndex(currentAudioIndex + 1);
- audioRefs.current[currentAudioIndex + 1]?.play();
- } else {
- setIsPlaying(false);
- setCurrentAudioIndex(0);
- }
- };
-
- const handleTimeUpdate = (audio: HTMLAudioElement) => {
- setCurrentTime(audio.currentTime);
- setDuration(audio.duration);
- };
-
- audioRefs.current.forEach((audio) => {
- if (audio) {
- audio.addEventListener("ended", handleAudioEnd);
- audio.addEventListener("timeupdate", () => handleTimeUpdate(audio));
- }
- });
-
- return () => {
- audioRefs.current.forEach((audio) => {
- if (audio) {
- audio.removeEventListener("ended", handleAudioEnd);
- audio.removeEventListener("timeupdate", () =>
- handleTimeUpdate(audio),
- );
- }
- });
- };
- }, [currentAudioIndex, audioUrls.length, setCurrentAudioIndex, setIsPlaying]);
-
- const handleProgressBarClick = (event: React.MouseEvent) => {
- const progressBar = progressBarRef.current;
- if (!progressBar || !audioRefs.current[currentAudioIndex]) return;
-
- const rect = progressBar.getBoundingClientRect();
- const clickPosition = (event.clientX - rect.left) / rect.width;
- const newTime =
- clickPosition * audioRefs.current[currentAudioIndex].duration;
-
- audioRefs.current[currentAudioIndex].currentTime = newTime;
- setCurrentTime(newTime);
- };
-
- const formatTime = (time: number) => {
- const minutes = Math.floor(time / 60);
- const seconds = Math.floor(time % 60);
- return `${minutes}:${seconds.toString().padStart(2, "0")}`;
- };
-
- const handlePlayPause = () => {
- if (!audioUrls.length) return;
-
- if (isPlaying) {
- audioRefs.current[currentAudioIndex]?.pause();
- setIsPlaying(false);
- } else {
- audioRefs.current[currentAudioIndex]?.play();
- setIsPlaying(true);
- }
- };
-
- return (
-
-
-
-
-
- Track {currentAudioIndex + 1}/{audioUrls.length}
-
-
-
- {formatTime(currentTime)} / {formatTime(duration)}
-
-
-
-
-
-
-
-
-
-
-
-
- {audioUrls.map((url, index) => (
-
- ))}
-
- );
-}
-
-interface DeckSelectorProps {
- decks: string[];
- selectedDeck: string | undefined;
- onSelect: (event: React.ChangeEvent) => void;
-}
-
-function DeckSelector({ decks, selectedDeck, onSelect }: DeckSelectorProps) {
- return (
-
- );
-}
-
-interface CardControlsProps {
- onPrevious: () => void;
- onNext: () => void;
- onRegenerate: () => void;
- isFirst: boolean;
- isLast: boolean;
- isRegenerating: boolean;
-}
-
-function CardControls({
- onPrevious,
- onNext,
- onRegenerate,
- isFirst,
- isLast,
- isRegenerating,
-}: CardControlsProps) {
- return (
-
- );
-}
-
-interface AnswerButtonsProps {
- onAnswer: (ease: number) => void;
-}
-
-function AnswerButtons({ onAnswer }: AnswerButtonsProps) {
- const answers = [
- { ease: 1, label: "Again", icon: "↻", color: "red" },
- { ease: 2, label: "Hard", icon: "⚠", color: "yellow" },
- { ease: 3, label: "Good", icon: "✓", color: "green" },
- { ease: 4, label: "Easy", icon: "⭐", color: "blue" },
- ];
-
- return (
-
- {answers.map(({ ease, label, icon, color }) => (
-
- ))}
-
- );
-}
-
-interface SettingsModalProps {
- isOpen: boolean;
- onClose: () => void;
- apiBase: string;
- onApiBaseChange: (value: string) => void;
- onReset: () => void;
- onSave: () => void;
- onSync: () => void;
- errorMessage?: string;
-}
-
-function SettingsModal({
- isOpen,
- onClose,
- apiBase,
- onApiBaseChange,
- onReset,
- onSave,
- onSync,
- errorMessage,
-}: SettingsModalProps) {
- if (!isOpen) return null;
-
- return (
-
-
-
Settings
- {errorMessage && (
-
- {errorMessage}
-
- )}
-
-
-
- API Base URL
-
-
onApiBaseChange(e.target.value)}
- placeholder={getDefaultApiBase()}
- className="w-full p-2 border rounded dark:bg-gray-700 dark:border-gray-600 dark:text-white"
- />
-
-
-
-
-
-
-
-
-
-
- );
-}
+import AnswerButtons from "../components/AnswerButtons";
+import AudioPlayer from "../components/AudioPlayer";
+import CardControls from "../components/CardControls";
+import DeckSelector from "../components/DeckSelector";
+import SettingsModal from "../components/SettingsModal";
+import { getApiBase, setApiBase } from "../config";
export function DeckView() {
const { deckName } = useParams();
diff --git a/src/commands/init.ts b/src/commands/init.ts
index 3857bb3..0cf73dc 100644
--- a/src/commands/init.ts
+++ b/src/commands/init.ts
@@ -19,7 +19,7 @@ async function generateDeckConfig(
anki: AnkiService,
deckName: string,
sampleSize = 3,
-): Promise {
+): Promise {
// Get sample cards from deck
const cardIds = await anki.findCards(deckName);
const sampleCards = await anki.getCardsInfo(cardIds.slice(0, sampleSize));
diff --git a/src/commands/test.ts b/src/commands/test.ts
index 09bfdda..0853c1d 100644
--- a/src/commands/test.ts
+++ b/src/commands/test.ts
@@ -72,9 +72,9 @@ export async function test(options: TestOptions = {}) {
for (const [index, card] of sampleCards.entries()) {
console.log(`\nTest ${index + 1}/${sampleSize}:`);
console.log("Card Fields:");
- Object.entries(card.fields).forEach(([field, { value }]) => {
+ for (const [field, { value }] of Object.entries(card.fields)) {
console.log(`${field}: ${value}`);
- });
+ }
try {
console.log("\nGenerated Content:");
@@ -82,12 +82,12 @@ export async function test(options: TestOptions = {}) {
card as Card,
deckConfig,
);
- Object.entries(content).forEach(([field, value]) => {
+ for (const [field, value] of Object.entries(content)) {
console.log(`\n${field}:`);
console.log(value);
- });
+ }
} catch (error) {
- console.error(`\nError generating content:`, error);
+ console.error("\nError generating content:", error);
}
if (index < sampleSize - 1) {
diff --git a/src/config/loader.ts b/src/config/loader.ts
index e094298..1df7735 100644
--- a/src/config/loader.ts
+++ b/src/config/loader.ts
@@ -1,4 +1,4 @@
-import { parse, stringify } from "@iarna/toml";
+import { parse, stringify, type JsonMap } from "@iarna/toml";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
@@ -50,7 +50,7 @@ const ENV_VAR_MAPPINGS = {
// Keys that should undergo environment variable interpolation
const ALLOWED_ENV_KEYS = new Set(Object.keys(ENV_VAR_MAPPINGS));
-function processConfigValues(obj: any, path: string[] = []): any {
+function processConfigValues(obj: unknown, path: string[] = []): unknown {
if (typeof obj === "string") {
const fullPath = path.join(".");
if (ALLOWED_ENV_KEYS.has(fullPath)) {
@@ -72,7 +72,7 @@ function processConfigValues(obj: any, path: string[] = []): any {
}
if (obj && typeof obj === "object") {
- const processed: any = {};
+ const processed: Record = {};
for (const [key, value] of Object.entries(obj)) {
processed[key] = processConfigValues(value, [...path, key]);
}
@@ -82,9 +82,17 @@ function processConfigValues(obj: any, path: string[] = []): any {
return obj;
}
-function processRawConfig(rawConfig: any): GakuonConfig {
+type ProcessedConfig = Partial & {
+ global?: Partial & {
+ openai?: Partial;
+ cardOrder?: Partial;
+ };
+};
+
+function processRawConfig(rawConfig: unknown): GakuonConfig {
// Process environment variables first
const withEnvVars = processConfigValues(rawConfig);
+ const processed = withEnvVars as ProcessedConfig;
// Handle enum conversions for card order settings from env vars
const queueOrder = process.env.GAKUON_QUEUE_ORDER;
@@ -97,26 +105,30 @@ function processRawConfig(rawConfig: any): GakuonConfig {
chatModel: DEFAULT_CONFIG.global.openai.chatModel,
initModel: DEFAULT_CONFIG.global.openai.initModel,
ttsModel: DEFAULT_CONFIG.global.openai.ttsModel,
- ...(withEnvVars.global?.openai || {}),
+ ...(processed.global?.openai || {}),
};
return {
- ...withEnvVars,
+ ...processed,
+ decks: processed.decks || DEFAULT_CONFIG.decks,
global: {
- ...withEnvVars.global,
+ ankiHost: processed.global?.ankiHost || DEFAULT_CONFIG.global.ankiHost,
+ openaiApiKey: processed.global?.openaiApiKey || DEFAULT_CONFIG.global.openaiApiKey,
+ ttsVoice: processed.global?.ttsVoice || DEFAULT_CONFIG.global.ttsVoice,
+ defaultDeck: processed.global?.defaultDeck,
openai: openaiConfig,
cardOrder: {
queueOrder:
(queueOrder as QueueOrder) ||
- withEnvVars.global.cardOrder?.queueOrder ||
+ (processed.global?.cardOrder?.queueOrder) ||
DEFAULT_CONFIG.global.cardOrder.queueOrder,
reviewOrder:
(reviewOrder as ReviewSortOrder) ||
- withEnvVars.global.cardOrder?.reviewOrder ||
+ (processed.global?.cardOrder?.reviewOrder) ||
DEFAULT_CONFIG.global.cardOrder.reviewOrder,
newCardOrder:
(newCardOrder as NewCardGatherOrder) ||
- withEnvVars.global.cardOrder?.newCardOrder ||
+ (processed.global?.cardOrder?.newCardOrder) ||
DEFAULT_CONFIG.global.cardOrder.newCardOrder,
},
},
@@ -131,7 +143,7 @@ export function loadConfig(customPath?: string): GakuonConfig {
const decodedConfig = Buffer.from(base64Config, "base64").toString(
"utf-8",
);
- const rawConfig = parse(decodedConfig) as any;
+ const rawConfig = parse(decodedConfig);
return processRawConfig(rawConfig);
} catch (error) {
console.warn("Failed to parse BASE64_GAKUON_CONFIG:", error);
@@ -147,7 +159,7 @@ export function loadConfig(customPath?: string): GakuonConfig {
}
const configFile = readFileSync(configPath, "utf-8");
- const rawConfig = parse(configFile) as any;
+ const rawConfig = parse(configFile);
return processRawConfig(rawConfig);
}
@@ -159,7 +171,7 @@ export function findDeckConfig(
return configs.find((config) => new RegExp(config.pattern).test(deckName));
}
-function reverseProcessConfigValues(obj: any, path: string[] = []): any {
+function reverseProcessConfigValues(obj: unknown, path: string[] = []): unknown {
if (typeof obj === "string") {
const fullPath = path.join(".");
if (ALLOWED_ENV_KEYS.has(fullPath)) {
@@ -180,7 +192,7 @@ function reverseProcessConfigValues(obj: any, path: string[] = []): any {
}
if (obj && typeof obj === "object") {
- const processed: any = {};
+ const processed: Record = {};
for (const [key, value] of Object.entries(obj)) {
processed[key] = reverseProcessConfigValues(value, [...path, key]);
}
@@ -196,7 +208,7 @@ export async function saveConfig(config: GakuonConfig): Promise {
try {
const processedConfig = reverseProcessConfigValues(config);
- const tomlContent = stringify(processedConfig);
+ const tomlContent = stringify(processedConfig as JsonMap);
const configWithHeader = `# Gakuon Configuration File
# Generated on ${new Date().toISOString()}
diff --git a/src/services/server/app.ts b/src/services/server/app.ts
index ee95e66..da286a6 100644
--- a/src/services/server/app.ts
+++ b/src/services/server/app.ts
@@ -127,7 +127,8 @@ export function createServer(deps: ServerDependencies) {
const [card] = await deps.ankiService.getCardsInfo([cardId]);
if (!card) {
- return res.status(404).json({ error: "Card not found" });
+ res.status(404).json({ error: "Card not found" });
+ return;
}
const { metadata, content, audioFiles } =
@@ -159,12 +160,14 @@ export function createServer(deps: ServerDependencies) {
const { ease } = req.body as AnswerRequest;
if (ease < 1 || ease > 4) {
- return res.status(400).json({ error: "Invalid ease value" });
+ res.status(400).json({ error: "Invalid ease value" });
+ return;
}
const success = await deps.ankiService.answerCard(cardId, ease);
if (!success) {
- return res.status(404).json({ error: "Card not found" });
+ res.status(404).json({ error: "Card not found" });
+ return;
}
res.json({ success: true });
@@ -179,13 +182,15 @@ export function createServer(deps: ServerDependencies) {
const [card] = await deps.ankiService.getCardsInfo([cardId]);
if (!card) {
- return res.status(404).json({ error: "Card not found" });
+ res.status(404).json({ error: "Card not found" });
+ return;
}
// Find deck config for the card
const config = findDeckConfig(card.deckName, deps.config.decks);
if (!config) {
- return res.status(404).json({ error: "Deck configuration not found" });
+ res.status(404).json({ error: "Deck configuration not found" });
+ return;
}
// Generate new content
@@ -208,12 +213,14 @@ export function createServer(deps: ServerDependencies) {
req.params.filename,
);
if (!base64Audio) {
- return res.status(404).json({ error: "Audio file not found" });
+ res.status(404).json({ error: "Audio file not found" });
+ return;
}
tempFilePath = await saveBase64ToTemp(base64Audio);
res.sendFile(tempFilePath, (err) => {
if (err) {
- return res.status(500).end();
+ res.status(500).end();
+ return;
}
if (tempFilePath) {
unlink(tempFilePath).catch(console.error);
@@ -231,7 +238,8 @@ export function createServer(deps: ServerDependencies) {
const config = findDeckConfig(deckName, deps.config.decks);
if (!config) {
- return res.status(404).json({ error: "Deck configuration not found" });
+ res.status(404).json({ error: "Deck configuration not found" });
+ return;
}
res.json({ config });
diff --git a/src/utils/asyncHandler.ts b/src/utils/asyncHandler.ts
index a253377..866c312 100644
--- a/src/utils/asyncHandler.ts
+++ b/src/utils/asyncHandler.ts
@@ -1,7 +1,7 @@
import type { Request, Response, NextFunction, RequestHandler } from "express";
export function asyncHandler(
- fn: (req: Request, res: Response, next: NextFunction) => any,
+ fn: (req: Request, res: Response, next: NextFunction) => Promise,
): RequestHandler {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
diff --git a/src/utils/path.ts b/src/utils/path.ts
index 466648d..7728fd7 100644
--- a/src/utils/path.ts
+++ b/src/utils/path.ts
@@ -1,4 +1,4 @@
-import { homedir } from "os";
+import { homedir } from "node:os";
export function expandTildePath(path: string): string {
if (path.startsWith("~/") || path === "~") {