diff --git a/CHANGELOG.md b/CHANGELOG.md
index e937402..3f81433 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## Version 0.2.28
+
+### Added
+
+* Server permission checking system to validate data directory access on startup
+* Permission error display with troubleshooting guidance and recheck functionality
+
## Version 0.2.27
### Fixed
diff --git a/components/Layout.tsx b/components/Layout.tsx
index ae73a28..9512db2 100644
--- a/components/Layout.tsx
+++ b/components/Layout.tsx
@@ -1,18 +1,21 @@
import ClientWrapper from './ClientWrapper'
import Header from './Header'
import Navigation from './Navigation'
+import PermissionError from './PermissionError'
export default function Layout({ children }: { children: React.ReactNode }) {
return (
-
-
-
-
-
- {/* responsive container (optimized for mobile) */}
-
+
+
+
+
+
+
+ {/* responsive container (optimized for mobile) */}
+
diff --git a/components/PermissionError.tsx b/components/PermissionError.tsx
new file mode 100644
index 0000000..a1bc83a
--- /dev/null
+++ b/components/PermissionError.tsx
@@ -0,0 +1,43 @@
+import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
+import { AlertTriangle } from 'lucide-react'
+import { checkStartupPermissions } from '@/lib/startup-checks'
+import RecheckButton from './RecheckButton'
+
+export default async function PermissionError() {
+ const permissionResult = await checkStartupPermissions()
+
+ // If everything is fine, render nothing
+ if (permissionResult.success) {
+ return null
+ }
+
+ // Get error message
+ const getErrorMessage = () => {
+ return permissionResult.error?.message || 'Unknown permission error occurred.'
+ }
+
+ return (
+
+
+ Permission Error
+
+
+
+
+ )
+}
diff --git a/components/RecheckButton.tsx b/components/RecheckButton.tsx
new file mode 100644
index 0000000..da30091
--- /dev/null
+++ b/components/RecheckButton.tsx
@@ -0,0 +1,22 @@
+'use client'
+
+import { Button } from '@/components/ui/button'
+import { RefreshCw } from 'lucide-react'
+
+export default function RecheckButton() {
+ const handleRecheck = () => {
+ window.location.reload()
+ }
+
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx
new file mode 100644
index 0000000..896d1ee
--- /dev/null
+++ b/components/ui/alert.tsx
@@ -0,0 +1,61 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
+ {
+ variants: {
+ variant: {
+ default: "bg-background text-foreground",
+ destructive:
+ "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
+ warning:
+ "border-yellow-200 bg-yellow-50 text-yellow-800 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-200 [&>svg]:text-yellow-600 dark:[&>svg]:text-yellow-400",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+const Alert = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & VariantProps
+>(({ className, variant, ...props }, ref) => (
+
+))
+Alert.displayName = "Alert"
+
+const AlertTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+AlertTitle.displayName = "AlertTitle"
+
+const AlertDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+AlertDescription.displayName = "AlertDescription"
+
+export { Alert, AlertTitle, AlertDescription }
\ No newline at end of file
diff --git a/lib/startup-checks.test.ts b/lib/startup-checks.test.ts
new file mode 100644
index 0000000..ace0570
--- /dev/null
+++ b/lib/startup-checks.test.ts
@@ -0,0 +1,205 @@
+import { describe, expect, test, beforeEach, mock } from 'bun:test'
+import { checkStartupPermissions } from './startup-checks'
+
+// Mock the fs promises module
+const mockStat = mock()
+const mockWriteFile = mock()
+const mockReadFile = mock()
+const mockUnlink = mock()
+
+mock.module('fs', () => ({
+ promises: {
+ stat: mockStat,
+ writeFile: mockWriteFile,
+ readFile: mockReadFile,
+ unlink: mockUnlink,
+ },
+}))
+
+describe('checkStartupPermissions', () => {
+ beforeEach(() => {
+ // Reset all mocks before each test
+ mockStat.mockReset()
+ mockWriteFile.mockReset()
+ mockReadFile.mockReset()
+ mockUnlink.mockReset()
+ })
+
+ test('should return success when directory exists and has proper permissions', async () => {
+ // Mock successful directory stat
+ mockStat.mockResolvedValue({
+ isDirectory: () => true,
+ })
+
+ // Mock successful file operations
+ mockWriteFile.mockResolvedValue(undefined)
+ mockReadFile.mockResolvedValue('permission-test')
+ mockUnlink.mockResolvedValue(undefined)
+
+ const result = await checkStartupPermissions()
+
+ expect(result).toEqual({ success: true })
+ expect(mockStat).toHaveBeenCalledWith('data')
+ expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
+ expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
+ expect(mockUnlink).toHaveBeenCalledWith('data/.habittrove-permission-test')
+ })
+
+ test('should return error when directory does not exist', async () => {
+ mockStat.mockRejectedValue(new Error('ENOENT: no such file or directory'))
+
+ const result = await checkStartupPermissions()
+
+ expect(result).toEqual({
+ success: false,
+ error: {
+ path: 'data',
+ message: 'Data directory \'data\' does not exist or is not accessible. Check volume mounts and permissions.',
+ type: 'writable_data_dir'
+ }
+ })
+ expect(mockStat).toHaveBeenCalledWith('data')
+ expect(mockWriteFile).not.toHaveBeenCalled()
+ })
+
+ test('should return error when path exists but is not a directory', async () => {
+ // Mock path exists but is a file, not directory
+ mockStat.mockResolvedValue({
+ isDirectory: () => false,
+ })
+
+ const result = await checkStartupPermissions()
+
+ expect(result).toEqual({
+ success: false,
+ error: {
+ path: 'data',
+ message: 'Path \'data\' exists but is not a directory. Please ensure the data directory is properly configured.',
+ type: 'writable_data_dir'
+ }
+ })
+ expect(mockStat).toHaveBeenCalledWith('data')
+ expect(mockWriteFile).not.toHaveBeenCalled()
+ })
+
+ test('should return error when write permission fails', async () => {
+ // Mock successful directory stat
+ mockStat.mockResolvedValue({
+ isDirectory: () => true,
+ })
+
+ // Mock write failure
+ mockWriteFile.mockRejectedValue(new Error('EACCES: permission denied'))
+
+ const result = await checkStartupPermissions()
+
+ expect(result).toEqual({
+ success: false,
+ error: {
+ path: 'data',
+ message: 'Insufficient read/write permissions for data directory \'data\'. Check file permissions and ownership.',
+ type: 'writable_data_dir'
+ }
+ })
+ expect(mockStat).toHaveBeenCalledWith('data')
+ expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
+ expect(mockReadFile).not.toHaveBeenCalled()
+ })
+
+ test('should return error when read permission fails', async () => {
+ // Mock successful directory stat and write
+ mockStat.mockResolvedValue({
+ isDirectory: () => true,
+ })
+ mockWriteFile.mockResolvedValue(undefined)
+
+ // Mock read failure
+ mockReadFile.mockRejectedValue(new Error('EACCES: permission denied'))
+
+ const result = await checkStartupPermissions()
+
+ expect(result).toEqual({
+ success: false,
+ error: {
+ path: 'data',
+ message: 'Insufficient read/write permissions for data directory \'data\'. Check file permissions and ownership.',
+ type: 'writable_data_dir'
+ }
+ })
+ expect(mockStat).toHaveBeenCalledWith('data')
+ expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
+ expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
+ })
+
+ test('should return error when read content does not match written content', async () => {
+ // Mock successful directory stat and write
+ mockStat.mockResolvedValue({
+ isDirectory: () => true,
+ })
+ mockWriteFile.mockResolvedValue(undefined)
+
+ // Mock read with different content
+ mockReadFile.mockResolvedValue('different-content')
+
+ const result = await checkStartupPermissions()
+
+ expect(result).toEqual({
+ success: false,
+ error: {
+ path: 'data',
+ message: 'Data integrity check failed in \'data\'. File system may be corrupted or have inconsistent behavior.',
+ type: 'writable_data_dir'
+ }
+ })
+ expect(mockStat).toHaveBeenCalledWith('data')
+ expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
+ expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
+ expect(mockUnlink).not.toHaveBeenCalled()
+ })
+
+ test('should return error when cleanup (unlink) fails', async () => {
+ // Mock successful directory stat, write, and read
+ mockStat.mockResolvedValue({
+ isDirectory: () => true,
+ })
+ mockWriteFile.mockResolvedValue(undefined)
+ mockReadFile.mockResolvedValue('permission-test')
+
+ // Mock cleanup failure
+ mockUnlink.mockRejectedValue(new Error('EACCES: permission denied'))
+
+ const result = await checkStartupPermissions()
+
+ // Should return error since cleanup failed and is part of the try-catch block
+ expect(result).toEqual({
+ success: false,
+ error: {
+ path: 'data',
+ message: 'Insufficient read/write permissions for data directory \'data\'. Check file permissions and ownership.',
+ type: 'writable_data_dir'
+ }
+ })
+ expect(mockStat).toHaveBeenCalledWith('data')
+ expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
+ expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
+ expect(mockUnlink).toHaveBeenCalledWith('data/.habittrove-permission-test')
+ })
+
+ test('should use correct file paths', async () => {
+ // Mock successful operations
+ mockStat.mockResolvedValue({
+ isDirectory: () => true,
+ })
+ mockWriteFile.mockResolvedValue(undefined)
+ mockReadFile.mockResolvedValue('permission-test')
+ mockUnlink.mockResolvedValue(undefined)
+
+ await checkStartupPermissions()
+
+ // Verify the correct paths are used
+ expect(mockStat).toHaveBeenCalledWith('data')
+ expect(mockWriteFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'permission-test')
+ expect(mockReadFile).toHaveBeenCalledWith('data/.habittrove-permission-test', 'utf8')
+ expect(mockUnlink).toHaveBeenCalledWith('data/.habittrove-permission-test')
+ })
+})
\ No newline at end of file
diff --git a/lib/startup-checks.ts b/lib/startup-checks.ts
new file mode 100644
index 0000000..b6d9e38
--- /dev/null
+++ b/lib/startup-checks.ts
@@ -0,0 +1,73 @@
+import { promises as fs } from 'fs'
+import { join } from 'path'
+
+const DEFAULT_DATA_DIR = 'data'
+
+/**
+ * Checks startup permissions for the data directory
+ */
+interface StartupPermissionResult {
+ success: boolean
+ error?: { path: string; message: string; type?: 'writable_data_dir' }
+}
+
+export async function checkStartupPermissions(): Promise {
+ const dirPath = DEFAULT_DATA_DIR
+
+ // Check if directory exists and is accessible
+ try {
+ const stats = await fs.stat(dirPath)
+ if (!stats.isDirectory()) {
+ return {
+ success: false,
+ error: {
+ path: dirPath,
+ message: `Path '${dirPath}' exists but is not a directory. Please ensure the data directory is properly configured.`,
+ type: 'writable_data_dir'
+ }
+ }
+ }
+ } catch (statError) {
+ return {
+ success: false,
+ error: {
+ path: dirPath,
+ message: `Data directory '${dirPath}' does not exist or is not accessible. Check volume mounts and permissions.`,
+ type: 'writable_data_dir'
+ }
+ }
+ }
+
+ // Test read/write permissions with a temporary file
+ const testFilePath = join(dirPath, '.habittrove-permission-test')
+ const testContent = 'permission-test'
+
+ try {
+ await fs.writeFile(testFilePath, testContent)
+ const readContent = await fs.readFile(testFilePath, 'utf8')
+
+ if (readContent !== testContent) {
+ return {
+ success: false,
+ error: {
+ path: dirPath,
+ message: `Data integrity check failed in '${dirPath}'. File system may be corrupted or have inconsistent behavior.`,
+ type: 'writable_data_dir'
+ }
+ }
+ }
+
+ await fs.unlink(testFilePath)
+ return { success: true }
+
+ } catch (rwError) {
+ return {
+ success: false,
+ error: {
+ path: dirPath,
+ message: `Insufficient read/write permissions for data directory '${dirPath}'. Check file permissions and ownership.`,
+ type: 'writable_data_dir'
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
index b67b56a..ca02a86 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "habittrove",
- "version": "0.2.27",
+ "version": "0.2.28",
"private": true,
"scripts": {
"dev": "next dev --turbopack",